From 3cb59e3cf9208b1a9192c77ea16f1cb40630b0b0 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 1 Jun 2026 17:51:42 +0200 Subject: [PATCH 001/148] fix(web): fix race displaying active keyboard in menu Fixes: #16041 Build-bot: skip release:web Test-bot: skip --- web/src/app/ui/kmwuitoggle.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/app/ui/kmwuitoggle.ts b/web/src/app/ui/kmwuitoggle.ts index 1aa4561465..3f48c26197 100644 --- a/web/src/app/ui/kmwuitoggle.ts +++ b/web/src/app/ui/kmwuitoggle.ts @@ -542,8 +542,18 @@ if(!keymanweb) { } // Highlight the last active keyboard - const sk=keymanweb.getSavedKeyboard().split(':'); - this.updateMenu(sk[0],sk[1]); + let activeKeyboard = keymanweb.getActiveKeyboard(); + let activeLanguage = ''; + if (activeKeyboard) { + activeLanguage = keymanweb.getActiveLanguage(); + } else { + // savedKeyboard is only correct if we use global keyboard settings, + // otherwise it's set to the first keyboard in the list + const savedKeyboard = keymanweb.getSavedKeyboard().split(':'); + activeKeyboard = savedKeyboard[0]; + activeLanguage = savedKeyboard[1]; + } + this.updateMenu(activeKeyboard, activeLanguage); } /* ---------------------------------------- From f82793636223119eb3a5416a6c59f31bf6c75637 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 2 Jun 2026 12:15:12 +0200 Subject: [PATCH 002/148] fix(web): fix race in `kmwuifloat.ts` as well --- web/src/app/ui/kmwuifloat.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/web/src/app/ui/kmwuifloat.ts b/web/src/app/ui/kmwuifloat.ts index f67463cf48..add9b4f600 100644 --- a/web/src/app/ui/kmwuifloat.ts +++ b/web/src/app/ui/kmwuifloat.ts @@ -261,12 +261,19 @@ if(!keymanweb) { } this.updateList = false; - // Set the menu selector to the currently saved keyboard - const sk = keymanweb.getSavedKeyboard().split(':'); - if(sk.length < 2) { - sk[1] = ''; + // Set the menu selector to the last active keyboard + let activeKeyboard = keymanweb.getActiveKeyboard(); + let activeLanguage = ''; + if (activeKeyboard) { + activeLanguage = keymanweb.getActiveLanguage(); + } else { + // savedKeyboard is only correct if we use global keyboard settings, + // otherwise it's set to the first keyboard in the list + const savedKeyboard = keymanweb.getSavedKeyboard().split(':'); + activeKeyboard = savedKeyboard[0]; + activeLanguage = savedKeyboard.length < 2 ? '' : savedKeyboard[1]; } - this.updateMenu(sk[0],sk[1]); + this.updateMenu(activeKeyboard, activeLanguage); // Redisplay the UI to correct width for any new language entries if(keymanweb.getLastActiveElement()) { From 92440317b8b51ad8a82c24e704edd8c73962337a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:52:22 +0000 Subject: [PATCH 003/148] chore(deps): bump minimatch in /core/tests/unit/wasm Bumps and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together. Updates `minimatch` from 9.0.5 to 9.0.9 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v9.0.5...v9.0.9) Updates `minimatch` from 5.1.6 to 5.1.9 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v9.0.5...v9.0.9) --- updated-dependencies: - dependency-name: minimatch dependency-version: 9.0.9 dependency-type: indirect - dependency-name: minimatch dependency-version: 5.1.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- core/tests/unit/wasm/package-lock.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/core/tests/unit/wasm/package-lock.json b/core/tests/unit/wasm/package-lock.json index f2c3485da5..ab83293f54 100644 --- a/core/tests/unit/wasm/package-lock.json +++ b/core/tests/unit/wasm/package-lock.json @@ -133,9 +133,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -572,13 +572,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -770,9 +770,9 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { From 45329e96808ccdafd26b25d4b03434de4764d41e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:52:23 +0000 Subject: [PATCH 004/148] chore(deps): bump glob from 10.4.5 to 10.5.0 in /core/tests/unit/wasm Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0. - [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md) - [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0) --- updated-dependencies: - dependency-name: glob dependency-version: 10.5.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- core/tests/unit/wasm/package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/tests/unit/wasm/package-lock.json b/core/tests/unit/wasm/package-lock.json index f2c3485da5..4b185e0119 100644 --- a/core/tests/unit/wasm/package-lock.json +++ b/core/tests/unit/wasm/package-lock.json @@ -538,9 +538,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { From 199bd9ae075e8acc9be5d45e4ff4027e869d2069 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:52:24 +0000 Subject: [PATCH 005/148] chore(deps-dev): bump js-yaml in /core/tests/unit/wasm Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.2.0. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/commits) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.2.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- core/tests/unit/wasm/package-lock.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/core/tests/unit/wasm/package-lock.json b/core/tests/unit/wasm/package-lock.json index f2c3485da5..19d5fd4a48 100644 --- a/core/tests/unit/wasm/package-lock.json +++ b/core/tests/unit/wasm/package-lock.json @@ -710,10 +710,20 @@ } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" From 0cc44cf63e64018cc01acb13658294eb8dc265da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:52:33 +0000 Subject: [PATCH 006/148] chore(deps-dev): bump picomatch in /core/tests/unit/wasm Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2. - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) --- updated-dependencies: - dependency-name: picomatch dependency-version: 2.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- core/tests/unit/wasm/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/tests/unit/wasm/package-lock.json b/core/tests/unit/wasm/package-lock.json index f2c3485da5..0ba3473413 100644 --- a/core/tests/unit/wasm/package-lock.json +++ b/core/tests/unit/wasm/package-lock.json @@ -932,9 +932,9 @@ } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { From 9404a7457f9ac3a337708dc80b528c3640647b23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:53:38 +0000 Subject: [PATCH 007/148] chore(deps): bump qs and express Bumps [qs](https://github.com/ljharb/qs) to 6.15.2 and updates ancestor dependency [express](https://github.com/expressjs/express). These dependencies need to be updated together. Updates `qs` from 6.13.0 to 6.15.2 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.13.0...v6.15.2) Updates `express` from 4.22.1 to 4.22.2 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/v4.22.2/History.md) - [Commits](https://github.com/expressjs/express/compare/v4.22.1...v4.22.2) --- updated-dependencies: - dependency-name: express dependency-version: 4.22.2 dependency-type: direct:production - dependency-name: qs dependency-version: 6.15.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- developer/src/server/package.json | 2 +- package-lock.json | 147 +++++++++++++++++------------- web/package.json | 2 +- 3 files changed, 86 insertions(+), 65 deletions(-) diff --git a/developer/src/server/package.json b/developer/src/server/package.json index 662be2add6..37c92b7f51 100644 --- a/developer/src/server/package.json +++ b/developer/src/server/package.json @@ -13,7 +13,7 @@ "@ngrok/ngrok": "^1.7.0", "@sentry/node": "^7.57.0", "chalk": "^4.1.2", - "express": "^4.22.1", + "express": "^4.22.2", "multer": "^2.1.1", "open": "^8.4.0", "restructure": "^3.0.1", diff --git a/package-lock.json b/package-lock.json index dd4169fb4e..7871a01886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1144,7 +1144,7 @@ "@ngrok/ngrok": "^1.7.0", "@sentry/node": "^7.57.0", "chalk": "^4.1.2", - "express": "^4.22.1", + "express": "^4.22.2", "multer": "^2.1.1", "open": "^8.4.0", "restructure": "^3.0.1", @@ -5191,22 +5191,23 @@ "license": "Apache-2.0" }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -5217,6 +5218,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -5225,14 +5227,60 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, "node_modules/brace-expansion": { "version": "1.1.12", @@ -5490,6 +5538,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -6262,6 +6311,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -7722,14 +7772,14 @@ "dev": true }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -7748,7 +7798,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -7798,40 +7848,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/express/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/express/node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/express/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -8557,6 +8573,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -11676,12 +11693,12 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -11736,6 +11753,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -12165,6 +12183,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -12232,14 +12251,16 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14149,7 +14170,7 @@ "@sentry/cli": "^2.31.0", "@zip.js/zip.js": "^2.7.32", "c8": "^7.12.0", - "express": "^4.19.2", + "express": "^4.22.2", "jsdom": "^23.0.1", "promise-status-async": "^1.2.10", "tsx": "^4.19.0" diff --git a/web/package.json b/web/package.json index fcc6d76790..5ec0135add 100644 --- a/web/package.json +++ b/web/package.json @@ -107,7 +107,7 @@ "@sentry/cli": "^2.31.0", "@zip.js/zip.js": "^2.7.32", "c8": "^7.12.0", - "express": "^4.19.2", + "express": "^4.22.2", "jsdom": "^23.0.1", "promise-status-async": "^1.2.10", "tsx": "^4.19.0" From a7b9d5da5aa0e290b7aba6b88ef6770fb29c4214 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 2 Jun 2026 18:24:16 +0200 Subject: [PATCH 008/148] fix(web): fix displaying of keyboard menu The recent partial merge of epic/web-core missed some code that causes the keyboard menu to work. This change re-applies those changes. Test-bot: skip --- web/src/app/browser/src/keymanEngine.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index bf5543e2cd..76f02bc941 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -400,7 +400,7 @@ export class KeymanEngine extends KeymanEngineBase Date: Tue, 2 Jun 2026 13:07:57 -0500 Subject: [PATCH 009/148] auto: increment master version to 19.0.242 Test-bot: skip Build-bot: skip --- HISTORY.md | 6 ++++++ VERSION.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 462bb9a48d..9615026796 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,11 @@ # Keyman Version History +## 19.0.241 alpha 2026-06-02 + +* chore(web): web-core preflight - strip core references (#16040) +* docs: add note on how to use composer on dockerized websites (#16029) +* fix(web): fix race displaying active keyboard in menu (#16042) + ## 19.0.240 alpha 2026-05-28 * chore(web): add filename to link text of manual web tests (#16019) diff --git a/VERSION.md b/VERSION.md index e14cb86f3e..bfdab4c783 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.241 \ No newline at end of file +19.0.242 \ No newline at end of file From ef9c0331d0b75ae5c037d3ad0b23173a25bf63fd Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 1 Jun 2026 19:23:19 +0200 Subject: [PATCH 010/148] chore(web): remove unnecessary .editorconfig file Build-bot: skip Test-bot: skip --- web/src/.editorconfig | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 web/src/.editorconfig diff --git a/web/src/.editorconfig b/web/src/.editorconfig deleted file mode 100644 index 34f03b7b15..0000000000 --- a/web/src/.editorconfig +++ /dev/null @@ -1,10 +0,0 @@ -# top-most EditorConfig file -root = true - -; applies to all files -[*] -indent_style = space - -[*.{java, js, ts, sh, css, html, xml}] -indent_size = 2 - From 43398df2432cd35024378a17b101182132815216 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 2 Jun 2026 15:41:58 +0200 Subject: [PATCH 011/148] refactor(web): extract function, rename variables Test-bot: skip --- web/src/app/browser/src/keymanEngine.ts | 27 +++---- web/src/app/ui/kmwuitoggle.ts | 100 +++++++++++++----------- 2 files changed, 66 insertions(+), 61 deletions(-) diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index 76f02bc941..16afdee6c9 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -275,35 +275,34 @@ export class KeymanEngine extends KeymanEngineBase { - // Ensure that the ui.controller is visible if help is displayed - this.controller.style.display = 'block'; - this.oskButton._setSelected(true); + // Ensure that the ui.controller is visible if help is displayed + this.controller.style.display = 'block'; + this.oskButton._setSelected(true); - return oskPosition; - }); + return oskPosition; + }); osk.addEventListener('hide', (byUser) => { if(byUser['HiddenByUser']) { @@ -565,24 +565,24 @@ if(!keymanweb) { /** * Function selecKbd * Scope Private - * @param {number} _kbd + * @param {number} kbdIndex * Description Select a keyboard from the drop down menu **/ - private async selectKbd(_kbd: number): Promise { - let _name,_lgCode; - if(_kbd < 0) { - _name = ''; - _lgCode=''; + private async selectKbd(kbdIndex: number): Promise { + let name, languageCode; + if (kbdIndex < 0) { + name = ''; + languageCode = ''; } else { - _name = this.keyboards[_kbd]._InternalName; - _lgCode = this.keyboards[_kbd]._LanguageCode; + name = this.keyboards[kbdIndex]._InternalName; + languageCode = this.keyboards[kbdIndex]._LanguageCode; } - await keymanweb.setActiveKeyboard(_name,_lgCode); + await keymanweb.setActiveKeyboard(name, languageCode); keymanweb.focusLastActiveElement(); - this.kbdButton._setSelected(_name != ''); - if(_kbd >= 0) { - this.lastActiveKeyboard = _kbd; + this.kbdButton._setSelected(name != ''); + if (kbdIndex >= 0) { + this.lastActiveKeyboard = kbdIndex; } return false; @@ -697,6 +697,22 @@ if(!keymanweb) { `; } + private createMenuItem(index: number, id: string, name: string, isSelected: boolean): { li: HTMLLIElement, a: HTMLAnchorElement } { + const li = util.createElement('li'); + const a = util.createElement('a'); + a.innerHTML = name; + a.href = "#"; + a.onclick = ((x) => { + return () => this.selectKbd(x); + })(index); + a.id = id; + if (isSelected) { + a.className = 'selected'; + } + li.appendChild(a); + return { li, a }; + } + /** * Function createMenu * Scope Private @@ -711,38 +727,29 @@ if(!keymanweb) { this.keyboardMenu.innerHTML = ''; // I2403 - Allow toggle design to be loaded twice } - const _li = util.createElement('li'); - const _a = util.createElement('a'); - _a.innerHTML='(System keyboard)'; - _a.href="#"; - _a.onclick = () => { - return this.selectKbd(-1); - }; - _a.id='KMWSel_$'; - _a.className='selected'; - _li.appendChild(_a); + // Add the default (system) keyboard as the first entry in the menu + const { li, a } = this.createMenuItem(-1, 'KMWSel_$', '(System keyboard)', true); + this.selectedMenuItem = a; + this.keyboardMenu.appendChild(li); - this.selectedMenuItem=_a; - this.keyboardMenu.appendChild(_li); - - const _kbds=keymanweb.getKeyboards(), _added: Record = {}; + // Add loaded keyboards to the menu this.keyboards = []; - for(let _kbd = 0; _kbd < _kbds.length; _kbd++) { - const _li1=util.createElement('li'); - const _a1=util.createElement('a'); - _a1.innerHTML=_kbds[_kbd].LanguageName + ' - ' + _kbds[_kbd].Name; - if(!_added[_kbds[_kbd].InternalName]) _added[_kbds[_kbd].InternalName]=0; - _added[_kbds[_kbd].InternalName]++; + const keyboards = keymanweb.getKeyboards(); + const added: Record = {}; + for (let kbdIndex = 0; kbdIndex < keyboards.length; kbdIndex++) { + if (!added[keyboards[kbdIndex].InternalName]) { + added[keyboards[kbdIndex].InternalName] = 0; + } + added[keyboards[kbdIndex].InternalName]++; - const _n=_added[_kbds[_kbd].InternalName]; - this.keyboards.push({_InternalName: _kbds[_kbd].InternalName, _LanguageCode:_kbds[_kbd].LanguageCode, _Index: _n}); + const kbdInstanceCount = added[keyboards[kbdIndex].InternalName]; + this.keyboards.push({ _InternalName: keyboards[kbdIndex].InternalName, _LanguageCode: keyboards[kbdIndex].LanguageCode, _Index: kbdInstanceCount }); - _a1.href="#"; - _a1.onclick = ((x) => { return () => this.selectKbd(x); })(this.keyboards.length-1); - _a1.id='KMWSel_'+_kbds[_kbd].InternalName+'$'+_n; - - _li1.appendChild(_a1); - this.keyboardMenu.appendChild(_li1); + const { li } = this.createMenuItem(this.keyboards.length - 1, + `KMWSel_${keyboards[kbdIndex].InternalName}\$${kbdInstanceCount}`, + `${keyboards[kbdIndex].LanguageName} - ${keyboards[kbdIndex].Name}`, + false); + this.keyboardMenu.appendChild(li); } //if(!ui.initialized) // I2403 - Allow toggle design to be loaded twice @@ -750,7 +757,6 @@ if(!keymanweb) { this.kbdButton.getElem().appendChild(this.keyboardMenu); } }; - } @@ -805,4 +811,4 @@ if(!keymanweb) { ui.initialize(); } catch(ex){} -} \ No newline at end of file +} From 9ced04684a2c2f8b23013e670fde7aa29f5d7101 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 3 Jun 2026 18:54:26 +0200 Subject: [PATCH 012/148] chore(web): improve the KeymanWeb integration documentation - replace use of `kmw` with `keyman` - unify pattern to load keyboards: do it in `keyman.init().then()` instead of in the document `onload` Part-of: keymanapp/keyman.com#449 Test-bot: skip --- .../engine/guide/examples/__auto-control.html | 25 ++++---- .../guide/examples/__control-by-control.html | 38 ++++++------ .../guide/examples/__first-example.html | 15 +++-- .../guide/examples/__full-manual-control.html | 40 ++++++------- .../guide/examples/__manual-control.html | 20 +++---- .../guide/examples/automatic-control.md | 20 +++---- .../guide/examples/control-by-control.md | 40 ++++++------- .../guide/examples/full-manual-control.md | 36 +++++------- .../guide/examples/js/unified_loader.js | 30 +++++----- .../engine/guide/examples/manual-control.md | 13 +---- web/src/samples/complex/root/pages/index.html | 5 +- web/src/samples/samplehdr.js | 12 ++-- web/src/samples/simplest/index.html | 3 +- web/src/samples/subfolder_toggle/index.html | 3 +- web/src/samples/subfolder_toolbar/index.html | 3 +- .../test/manual/web/attachment-api/index.html | 7 ++- .../manual/web/attachment-api/utilities.js | 27 +++++---- .../test/manual/web/basic-iframe/index.html | 7 ++- .../web/build-visual-keyboard/index.html | 6 +- web/src/test/manual/web/chirality/index.html | 7 ++- .../test/manual/web/chirality/utilities.js | 12 ++-- web/src/test/manual/web/ckeditor/index.html | 7 ++- web/src/test/manual/web/ckeditor/inline.html | 7 ++- web/src/test/manual/web/ckeditor/utilities.js | 10 ++-- web/src/test/manual/web/commonHeader.js | 8 +-- .../test/manual/web/default-subkey/index.html | 5 +- .../test/manual/web/desktop-ui/button.html | 3 +- web/src/test/manual/web/desktop-ui/float.html | 3 +- .../test/manual/web/desktop-ui/toggle.html | 5 +- .../test/manual/web/desktop-ui/toolbar.html | 7 ++- web/src/test/manual/web/empty-row/index.html | 8 +-- .../manual/web/init-race-10743/index.html | 3 +- web/src/test/manual/web/inline-osk/index.html | 3 +- web/src/test/manual/web/issue005/header.js | 40 ++++++------- web/src/test/manual/web/issue005/index.html | 16 ++--- web/src/test/manual/web/issue103/index.html | 9 +-- web/src/test/manual/web/issue103/samplehdr.js | 58 +++++++++---------- web/src/test/manual/web/issue115/index.html | 5 +- web/src/test/manual/web/issue116/index.html | 8 +-- web/src/test/manual/web/issue11785/index.html | 5 +- .../manual/web/issue11785/load-keyboards.js | 8 +-- web/src/test/manual/web/issue1332/header.js | 12 ++-- web/src/test/manual/web/issue1332/index.html | 7 ++- web/src/test/manual/web/issue160/index.html | 10 ++-- web/src/test/manual/web/issue266/index.html | 10 ++-- web/src/test/manual/web/issue271/header.js | 4 +- web/src/test/manual/web/issue271/index.html | 7 ++- web/src/test/manual/web/issue29/index.html | 7 ++- web/src/test/manual/web/issue3701/index.html | 7 +-- web/src/test/manual/web/issue382/index.html | 22 +++---- web/src/test/manual/web/issue53/index.html | 7 ++- web/src/test/manual/web/issue53/issue53.js | 16 +++-- web/src/test/manual/web/issue6005/index.html | 31 +++++----- web/src/test/manual/web/issue62/index.html | 7 ++- web/src/test/manual/web/issue63/index.html | 7 ++- .../issue917-context-and-notany/index.html | 5 +- web/src/test/manual/web/issue920/index.html | 15 ++--- web/src/test/manual/web/issue9469/index.html | 9 ++- .../manual/web/keyboard-errors/errorhdr.js | 10 ++-- .../manual/web/keyboard-errors/index.html | 7 ++- web/src/test/manual/web/mnemonic/index.html | 25 ++++---- .../manual/web/options-with-save/index.html | 25 ++++---- .../manual/web/osk-event-buttons/index.html | 3 +- .../test/manual/web/osk-movement/index.html | 5 +- web/src/test/manual/web/platform/index.html | 11 ++-- web/src/test/manual/web/platform/utilities.js | 8 +-- web/src/test/manual/web/pr10506/header.js | 5 +- web/src/test/manual/web/pr10506/index.html | 7 ++- .../manual/web/prediction-mtnt/index.html | 15 +++-- .../test/manual/web/prediction-ui/index.html | 13 ++--- .../test/manual/web/promise-api/index.html | 6 +- .../test/manual/web/promise-api/promisehdr.js | 14 ++--- .../manual/web/rotation-events/index.html | 29 +++++----- .../manual/web/sentry-integration/errorhdr.js | 28 +++++---- .../manual/web/sentry-integration/index.html | 7 ++- .../manual/web/test-updateLayer/index.html | 7 ++- .../manual/web/test-updateLayer/utilities.js | 8 +-- .../web/text_selection_tests_9073/index.html | 30 +++++----- .../test/manual/web/unminified - manual.html | 5 +- web/src/test/manual/web/unminified.html | 3 +- .../tools/testing/bulk_rendering/index.html | 9 ++- .../bulk_rendering/local-renderer.html | 5 +- .../testing/bulk_rendering/renderer_core.ts | 2 +- web/src/tools/testing/recorder/index.html | 4 +- 84 files changed, 485 insertions(+), 576 deletions(-) diff --git a/web/docs/engine/guide/examples/__auto-control.html b/web/docs/engine/guide/examples/__auto-control.html index 050e03a9f0..efe5467bfd 100644 --- a/web/docs/engine/guide/examples/__auto-control.html +++ b/web/docs/engine/guide/examples/__auto-control.html @@ -1,20 +1,17 @@ + - - @@ -28,4 +25,4 @@ Back to Document - \ No newline at end of file + diff --git a/web/docs/engine/guide/examples/__control-by-control.html b/web/docs/engine/guide/examples/__control-by-control.html index 93a481a75f..07736a05db 100644 --- a/web/docs/engine/guide/examples/__control-by-control.html +++ b/web/docs/engine/guide/examples/__control-by-control.html @@ -1,3 +1,4 @@ + @@ -8,28 +9,23 @@ diff --git a/web/docs/engine/guide/examples/__first-example.html b/web/docs/engine/guide/examples/__first-example.html index feb96f4daa..ece6a9b9ea 100644 --- a/web/docs/engine/guide/examples/__first-example.html +++ b/web/docs/engine/guide/examples/__first-example.html @@ -6,15 +6,14 @@ - @@ -23,4 +22,4 @@ Back to Document - \ No newline at end of file + diff --git a/web/docs/engine/guide/examples/__full-manual-control.html b/web/docs/engine/guide/examples/__full-manual-control.html index 5a70c7183f..6313c13518 100644 --- a/web/docs/engine/guide/examples/__full-manual-control.html +++ b/web/docs/engine/guide/examples/__full-manual-control.html @@ -1,45 +1,39 @@ + - diff --git a/web/docs/engine/guide/examples/__manual-control.html b/web/docs/engine/guide/examples/__manual-control.html index 4680400e4d..209c8fe176 100644 --- a/web/docs/engine/guide/examples/__manual-control.html +++ b/web/docs/engine/guide/examples/__manual-control.html @@ -1,29 +1,23 @@ + - @@ -38,4 +32,4 @@ Back to Document - \ No newline at end of file + diff --git a/web/docs/engine/guide/examples/automatic-control.md b/web/docs/engine/guide/examples/automatic-control.md index c804556e18..14b703d35a 100644 --- a/web/docs/engine/guide/examples/automatic-control.md +++ b/web/docs/engine/guide/examples/automatic-control.md @@ -13,17 +13,15 @@ In this example, we use only the LaoKey keyboard. Please click [this link](./__a diff --git a/web/docs/engine/guide/examples/control-by-control.md b/web/docs/engine/guide/examples/control-by-control.md index 974338ef1f..5f57a5a778 100644 --- a/web/docs/engine/guide/examples/control-by-control.md +++ b/web/docs/engine/guide/examples/control-by-control.md @@ -10,28 +10,23 @@ Include the following script in the HEAD of your page: ```js ``` @@ -48,8 +43,9 @@ Also include the following HTML code: - + ``` + --- **Note:** In this example we disabled the first element (`document.f.address`) by API call. A later API call can re-enable KeymanWeb for this control should it fit the page's design. Alternatively, this can be done by instead adding the class `'kmw-disabled'` to the control. This will permanently block KeymanWeb from handling its input. diff --git a/web/docs/engine/guide/examples/full-manual-control.md b/web/docs/engine/guide/examples/full-manual-control.md index e9e8d2908a..b03d493357 100644 --- a/web/docs/engine/guide/examples/full-manual-control.md +++ b/web/docs/engine/guide/examples/full-manual-control.md @@ -12,27 +12,23 @@ Include the following script in the HEAD of your page: @@ -56,7 +57,7 @@ - +

KeymanWeb Sample Page - Attachment API Testing

This page is designed to stress-test the new attachment/enablement API model and its functions.

You are currently testing under the attachment mode. Two other modes are available: diff --git a/web/src/test/manual/web/attachment-api/utilities.js b/web/src/test/manual/web/attachment-api/utilities.js index 5793c12af6..f6c404932e 100644 --- a/web/src/test/manual/web/attachment-api/utilities.js +++ b/web/src/test/manual/web/attachment-api/utilities.js @@ -1,7 +1,6 @@ // Page-global variable definitions. { var inputCounter = 0; -var kmw = keyman; } function generateDiagnosticDiv(elem) { @@ -23,8 +22,8 @@ function generateDiagnosticDiv(elem) { attachBtn.type = 'button'; attachBtn.id = 'attach_' + elemId; attachBtn.onclick = function() { - kmw.attachToControl(document.getElementById(elemId)); - var attached = kmw.isAttached(elem); + keyman.attachToControl(document.getElementById(elemId)); + var attached = keyman.isAttached(elem); document.getElementById('attachment_' + elemId).textContent = " Currently " + (attached ? "attached." : "detached."); const indepLabel = document.getElementById("independence_" + elemId); // does not exist for iframes @@ -40,8 +39,8 @@ function generateDiagnosticDiv(elem) { detachBtn.type = 'button'; detachBtn.id = 'detach_' + elemId; detachBtn.onclick = function() { - kmw.detachFromControl(document.getElementById(elemId)); - var attached = kmw.isAttached(elem); + keyman.detachFromControl(document.getElementById(elemId)); + var attached = keyman.isAttached(elem); document.getElementById('attachment_' + elemId).textContent = " Currently " + (attached ? "attached." : "detached."); const indepLabel = document.getElementById("independence_" + elemId); // does not exist for iframes @@ -58,7 +57,7 @@ function generateDiagnosticDiv(elem) { var attachLabel = document.createElement("a"); attachLabel.id = 'attachment_' + elemId; window.setTimeout(function() { - attachLabel.textContent = " Currently " + (kmw.isAttached(elem) ? "attached." : "detached."); + attachLabel.textContent = " Currently " + (keyman.isAttached(elem) ? "attached." : "detached."); }, attachTimer); div.appendChild(attachLabel); @@ -69,7 +68,7 @@ function generateDiagnosticDiv(elem) { enableBtn.type = 'button'; enableBtn.id = 'enable_' + elemId; enableBtn.onclick = function() { - kmw.enableControl(document.getElementById(elemId)); + keyman.enableControl(document.getElementById(elemId)); }; enableBtn.value = 'Enable'; div.appendChild(enableBtn); @@ -79,7 +78,7 @@ function generateDiagnosticDiv(elem) { disableBtn.type = 'button'; disableBtn.id = 'disable_' + elemId; disableBtn.onclick = function() { - kmw.disableControl(document.getElementById(elemId)); + keyman.disableControl(document.getElementById(elemId)); }; disableBtn.value = 'Disable'; div.appendChild(disableBtn); @@ -107,9 +106,9 @@ function generateDiagnosticDiv(elem) { setBtn.type = 'button'; setBtn.id = 'set_' + elemId; setBtn.onclick = function() { - kmw.setKeyboardForControl(document.getElementById(elemId), 'dzongkha', 'dz'); + keyman.setKeyboardForControl(document.getElementById(elemId), 'dzongkha', 'dz'); - if(kmw.isAttached(elem)) { + if(keyman.isAttached(elem)) { kbdLabel.textContent = " Using independently-tracked keyboard."; } }; @@ -121,9 +120,9 @@ function generateDiagnosticDiv(elem) { clearBtn.type = 'button'; clearBtn.id = 'clear_' + elemId; clearBtn.onclick = function() { - kmw.setKeyboardForControl(document.getElementById(elemId), null, null); + keyman.setKeyboardForControl(document.getElementById(elemId), null, null); - if(kmw.isAttached(elem)) { + if(keyman.isAttached(elem)) { kbdLabel.textContent = " Using globally-selected keyboard."; } }; @@ -133,8 +132,8 @@ function generateDiagnosticDiv(elem) { var kbdLabel = document.createElement("a"); kbdLabel.id = 'independence_' + elemId; window.setTimeout(function () { - if(kmw.isAttached(elem)) { - var attached = kmw.isAttached(elem); + if(keyman.isAttached(elem)) { + var attached = keyman.isAttached(elem); kbdLabel.textContent = attached ? " Using globally-selected keyboard." : ''; } }, 1); diff --git a/web/src/test/manual/web/basic-iframe/index.html b/web/src/test/manual/web/basic-iframe/index.html index 8a935f2cc6..56441455dd 100644 --- a/web/src/test/manual/web/basic-iframe/index.html +++ b/web/src/test/manual/web/basic-iframe/index.html @@ -36,8 +36,9 @@ @@ -46,7 +47,7 @@ - +

KeymanWeb: Test IFrame connectivity

diff --git a/web/src/test/manual/web/build-visual-keyboard/index.html b/web/src/test/manual/web/build-visual-keyboard/index.html index 2845f3db86..54a47172d0 100644 --- a/web/src/test/manual/web/build-visual-keyboard/index.html +++ b/web/src/test/manual/web/build-visual-keyboard/index.html @@ -37,8 +37,6 @@ @@ -51,7 +52,7 @@ - +

KeymanWeb Sample Page - Chirality Testing

This page is designed to stress-test the new support for chiral modifiers and state keys.

Be sure to reference the developer console for additional feedback.

diff --git a/web/src/test/manual/web/chirality/utilities.js b/web/src/test/manual/web/chirality/utilities.js index 16c9caaf02..5c5fe5c805 100644 --- a/web/src/test/manual/web/chirality/utilities.js +++ b/web/src/test/manual/web/chirality/utilities.js @@ -1,9 +1,7 @@ -function loadKeyboards() -{ - var kmw=keyman; - +async function loadKeyboards() +{ // A testing keyboard handwritten with chirality information. - kmw.addKeyboards({id:'chirality',name:'Chirality Testing', + await keyman.addKeyboards({id:'chirality',name:'Chirality Testing', languages:{ id:'en',name:'English',region:'North America' }, @@ -12,8 +10,8 @@ function loadKeyboards() // A testing keyboard using 10.0 format and the KLS layout specifier // without the 'shift' layer properly defined. - // Add a fully-specified, locally-sourced, keyboard with custom font - kmw.addKeyboards({id:'halfDefined',name:'Undefined Shift Layer Keyboard', + // Add a fully-specified, locally-sourced, keyboard with custom font + await keyman.addKeyboards({id:'halfDefined',name:'Undefined Shift Layer Keyboard', languages:{ id:'en',name:'English',region:'North America' }, diff --git a/web/src/test/manual/web/ckeditor/index.html b/web/src/test/manual/web/ckeditor/index.html index 950f8bcb9b..25dfc7e9e8 100644 --- a/web/src/test/manual/web/ckeditor/index.html +++ b/web/src/test/manual/web/ckeditor/index.html @@ -30,9 +30,10 @@ @@ -44,7 +45,7 @@ - +

KeymanWeb Sample Page - Test CKEditor Integration

Test CKEditor Integration

diff --git a/web/src/test/manual/web/ckeditor/inline.html b/web/src/test/manual/web/ckeditor/inline.html index 463ebf2138..f54d98e0ff 100644 --- a/web/src/test/manual/web/ckeditor/inline.html +++ b/web/src/test/manual/web/ckeditor/inline.html @@ -30,9 +30,10 @@ @@ -44,7 +45,7 @@ - +

KeymanWeb Sample Page - Test CKEditor Integration

Test CKEditor Integration

diff --git a/web/src/test/manual/web/ckeditor/utilities.js b/web/src/test/manual/web/ckeditor/utilities.js index 2ca0fd324c..8c281e2223 100644 --- a/web/src/test/manual/web/ckeditor/utilities.js +++ b/web/src/test/manual/web/ckeditor/utilities.js @@ -1,15 +1,13 @@ -function loadKeyboards() +async function loadKeyboards() { - var kmw=keyman; - // Add more keyboards to the language menu, by keyboard name, // keyboard name and language code, or just the BCP-47 language code. // We use a different loading pattern here than in the samples version to provide a slightly different set of test cases. - kmw.addKeyboards('french','@he'); - kmw.addKeyboards({id:'sil_euro_latin', name:'SIL EuroLatin', languages: [{id:'no'}, {id:'sv'}]}); // Loads from partial stub instead of the compact string. + await keyman.addKeyboards('french', '@he', + { id: 'sil_euro_latin', name: 'SIL EuroLatin', languages: [{ id: 'no' }, { id: 'sv' }] }); // Loads from partial stub instead of the compact string. // Add a keyboard by language name. Note that the name must be spelled // correctly, or the keyboard will not be found. (Using BCP-47 codes is // usually easier.) - kmw.addKeyboardsForLanguage('Dzongkha'); + await keyman.addKeyboardsForLanguage('Dzongkha'); } \ No newline at end of file diff --git a/web/src/test/manual/web/commonHeader.js b/web/src/test/manual/web/commonHeader.js index 74cb22e1cd..53f6611b26 100644 --- a/web/src/test/manual/web/commonHeader.js +++ b/web/src/test/manual/web/commonHeader.js @@ -74,8 +74,6 @@ function loadKeyboards(nestLevel) { - var kmw=keyman; - var base_prefix = '../'; var prefix = './'; // The default - when prefix == 0. @@ -133,8 +131,8 @@ // The following two optional calls should be delayed until language menus are fully loaded: // (a) a specific mapped input element input is focused, to ensure that the OSK appears // (b) a specific keyboard is loaded, rather than the keyboard last used. - //window.setTimeout(function(){kmw.setActiveElement('ta1',true);},2500); - //window.setTimeout(function(){kmw.setActiveKeyboard('Keyboard_french','fr');},3000); + //window.setTimeout(function(){keyman.setActiveElement('ta1',true);},2500); + //window.setTimeout(function(){keyman.setActiveKeyboard('Keyboard_french','fr');},3000); // Note that locally specified keyboards will be listed before keyboards // requested from the remote server by user interfaces that do not order @@ -144,7 +142,7 @@ // Script to allow a user to add any keyboard to the keyboard menu function addKeyboard(n) { - var sKbd,kmw=keyman; + var sKbd; switch(n) { case 1: diff --git a/web/src/test/manual/web/default-subkey/index.html b/web/src/test/manual/web/default-subkey/index.html index 728355c43b..6ce3bee562 100644 --- a/web/src/test/manual/web/default-subkey/index.html +++ b/web/src/test/manual/web/default-subkey/index.html @@ -36,11 +36,10 @@ - +

KeymanWeb Testing Page - Desktop UI module Testing

This page is designed to facilitate testing of various aspects of the Toolbar UI for KeymanWeb. Originally developed to help address issue #1275. diff --git a/web/src/test/manual/web/empty-row/index.html b/web/src/test/manual/web/empty-row/index.html index 02953493e9..ee6da66cca 100644 --- a/web/src/test/manual/web/empty-row/index.html +++ b/web/src/test/manual/web/empty-row/index.html @@ -36,18 +36,16 @@ - - + + @@ -47,7 +49,7 @@ - +

KeymanWeb: 'Test case' for proper implementation of the removeKeyboards API function

diff --git a/web/src/test/manual/web/issue103/index.html b/web/src/test/manual/web/issue103/index.html index 35b006e347..e242071511 100644 --- a/web/src/test/manual/web/issue103/index.html +++ b/web/src/test/manual/web/issue103/index.html @@ -36,10 +36,11 @@ @@ -48,7 +49,7 @@ - +

KeymanWeb Sample Page - Keyboard Registration Check

This page is designed to test KeymanWeb's behavior when being told to load a keyboard multiple times, both via diff --git a/web/src/test/manual/web/issue103/samplehdr.js b/web/src/test/manual/web/issue103/samplehdr.js index 36b08861c1..51a38e4eff 100644 --- a/web/src/test/manual/web/issue103/samplehdr.js +++ b/web/src/test/manual/web/issue103/samplehdr.js @@ -1,70 +1,66 @@ // Modified version of commonHeader - contains repeated requests for the same language. - function loadKeyboards() - { - var kmw=keyman; - + function loadKeyboards() { // The first keyboard added will be the default keyboard for touch devices. // For faster loading, it may be best for the default keyboard to be // locally sourced. - kmw.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, + keyman.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, filename:'../us-1.0.js'}); - + // Add more keyboards to the language menu, by keyboard name, // keyboard name and language code, or just the BCP-47 language code. - kmw.addKeyboards('french','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@no','@he'); - kmw.addKeyboards('@he'); - kmw.addKeyboards('@he'); - + keyman.addKeyboards('french','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@no','@he'); + keyman.addKeyboards('@he'); + keyman.addKeyboards('@he'); + // Add a keyboard by language name. Note that the name must be spelled // correctly, or the keyboard will not be found. (Using BCP-47 codes is // usually easier.) - kmw.addKeyboardsForLanguage('Dzongkha'); - - // Add a fully-specified, locally-sourced, keyboard with custom font - kmw.addKeyboards({id:'lao_2008_basic',name:'Lao Basic', + keyman.addKeyboardsForLanguage('Dzongkha'); + + // Add a fully-specified, locally-sourced, keyboard with custom font + keyman.addKeyboards({id:'lao_2008_basic',name:'Lao Basic', languages:{ id:'lo',name:'Lao',region:'Asia' }, filename:'../lao_2008_basic.js' - }); + }); // The following two optional calls should be delayed until language menus are fully loaded: // (a) a specific mapped input element input is focused, to ensure that the OSK appears - // (b) a specific keyboard is loaded, rather than the keyboard last used. - //window.setTimeout(function(){kmw.setActiveElement('ta1',true);},2500); - //window.setTimeout(function(){kmw.setActiveKeyboard('Keyboard_french','fr');},3000); - - // Note that locally specified keyboards will be listed before keyboards + // (b) a specific keyboard is loaded, rather than the keyboard last used. + //window.setTimeout(function(){keyman.setActiveElement('ta1',true);},2500); + //window.setTimeout(function(){keyman.setActiveKeyboard('Keyboard_french','fr');},3000); + + // Note that locally specified keyboards will be listed before keyboards // requested from the remote server by user interfaces that do not order // keyboards alphabetically by language. } - - // Script to allow a user to add any keyboard to the keyboard menu + + // Script to allow a user to add any keyboard to the keyboard menu function addKeyboard(n) - { - var sKbd,kmw=keyman; + { + let sKbd; switch(n) { case 1: sKbd=document.getElementById('kbd_id1').value; - kmw.addKeyboards(sKbd); + keyman.addKeyboards(sKbd); break; case 2: sKbd=document.getElementById('kbd_id2').value.toLowerCase(); - kmw.addKeyboards('@'+sKbd); + keyman.addKeyboards('@'+sKbd); break; case 3: sKbd=document.getElementById('kbd_id3').value; - kmw.addKeyboardsForLanguage(sKbd); + keyman.addKeyboardsForLanguage(sKbd); break; } } - + // Add keyboard on Enter (as well as pressing button) - function clickOnEnter(e,id) - { + function clickOnEnter(e,id) { e = e || window.event; - if(e.keyCode == 13) addKeyboard(id); + if(e.keyCode == 13) addKeyboard(id); } diff --git a/web/src/test/manual/web/issue115/index.html b/web/src/test/manual/web/issue115/index.html index cd9afcb8fa..f55d98d382 100644 --- a/web/src/test/manual/web/issue115/index.html +++ b/web/src/test/manual/web/issue115/index.html @@ -36,11 +36,10 @@ diff --git a/web/src/test/manual/web/issue116/index.html b/web/src/test/manual/web/issue116/index.html index d81fb947c0..d8f64d9f74 100644 --- a/web/src/test/manual/web/issue116/index.html +++ b/web/src/test/manual/web/issue116/index.html @@ -36,12 +36,12 @@ diff --git a/web/src/test/manual/web/issue11785/index.html b/web/src/test/manual/web/issue11785/index.html index 5df453aab9..fb51a97bcf 100644 --- a/web/src/test/manual/web/issue11785/index.html +++ b/web/src/test/manual/web/issue11785/index.html @@ -40,9 +40,8 @@ @@ -49,7 +50,7 @@ - +

KeymanWeb Sample Page - Mixed Case of TTF font files

Previously, KMW was not applying .TTF fonts on mobile devices.

See issue #1332 diff --git a/web/src/test/manual/web/issue160/index.html b/web/src/test/manual/web/issue160/index.html index 33219e7fdf..3808341b7d 100644 --- a/web/src/test/manual/web/issue160/index.html +++ b/web/src/test/manual/web/issue160/index.html @@ -36,12 +36,12 @@ diff --git a/web/src/test/manual/web/issue266/index.html b/web/src/test/manual/web/issue266/index.html index 56e52016d7..adf03ffe70 100644 --- a/web/src/test/manual/web/issue266/index.html +++ b/web/src/test/manual/web/issue266/index.html @@ -36,12 +36,12 @@ diff --git a/web/src/test/manual/web/issue271/header.js b/web/src/test/manual/web/issue271/header.js index b0d52effe9..6fd6bec984 100644 --- a/web/src/test/manual/web/issue271/header.js +++ b/web/src/test/manual/web/issue271/header.js @@ -1,7 +1,5 @@ function setActiveKeyboard() { - var kmw=window['keyman'] ? keyman : tavultesoft.keymanweb; - var sKbd = document.getElementById('kbd_id4').value; var sLng = document.getElementById('lang_id4').value; - var result = kmw.setActiveKeyboard("Keyboard_" + sKbd, sLng); + var result = keyman.setActiveKeyboard("Keyboard_" + sKbd, sLng); } \ No newline at end of file diff --git a/web/src/test/manual/web/issue271/index.html b/web/src/test/manual/web/issue271/index.html index fa969195a1..0bf9624779 100644 --- a/web/src/test/manual/web/issue271/index.html +++ b/web/src/test/manual/web/issue271/index.html @@ -36,9 +36,10 @@ @@ -49,7 +50,7 @@ - +

KeymanWeb: Test page for interactions between setActiveKeyboard and the toolbar UI

This version is used to test the interactions of SetActiveKeyboard with the Toolbar UI.

diff --git a/web/src/test/manual/web/issue29/index.html b/web/src/test/manual/web/issue29/index.html index 24a226326c..3f007c9cb4 100644 --- a/web/src/test/manual/web/issue29/index.html +++ b/web/src/test/manual/web/issue29/index.html @@ -36,8 +36,9 @@ @@ -47,7 +48,7 @@ - +

KeymanWeb: Test desktop MutationObserver functionality

diff --git a/web/src/test/manual/web/issue3701/index.html b/web/src/test/manual/web/issue3701/index.html index c27bba6c81..6393f5fe7c 100644 --- a/web/src/test/manual/web/issue3701/index.html +++ b/web/src/test/manual/web/issue3701/index.html @@ -36,11 +36,10 @@ diff --git a/web/src/test/manual/web/issue53/index.html b/web/src/test/manual/web/issue53/index.html index 4c730a3f39..d58dbcfdf4 100644 --- a/web/src/test/manual/web/issue53/index.html +++ b/web/src/test/manual/web/issue53/index.html @@ -36,9 +36,10 @@ @@ -48,7 +49,7 @@ - +

KeymanWeb: Tests layering transitions for keyboards with variable layer sizes.

This test should be run from mobile devices or with Chrome's mobile device emulation settings available within Developer Mode.

diff --git a/web/src/test/manual/web/issue53/issue53.js b/web/src/test/manual/web/issue53/issue53.js index 03d1175866..4cef9371cd 100644 --- a/web/src/test/manual/web/issue53/issue53.js +++ b/web/src/test/manual/web/issue53/issue53.js @@ -1,9 +1,7 @@ function loadKeyboards() { - var kmw=keyman; - // Add a fully-specified, locally-sourced, keyboard. - kmw.addKeyboards({id:'layered_debug_keyboard',name:'Web_Layer_Debugging', + keyman.addKeyboards({id:'layered_debug_keyboard',name:'Web_Layer_Debugging', languages:{ id:'dbg',name:'Debug',region:'North America' }, @@ -14,28 +12,28 @@ function loadKeyboards() // Script to allow a user to add any keyboard to the keyboard menu function addKeyboard(n) { - var sKbd,kmw=keyman; + var sKbd; switch(n) { case 1: sKbd=document.getElementById('kbd_id1').value; - kmw.addKeyboards(sKbd); + keyman.addKeyboards(sKbd); break; case 2: sKbd=document.getElementById('kbd_id2').value.toLowerCase(); - kmw.addKeyboards('@'+sKbd); + keyman.addKeyboards('@'+sKbd); break; case 3: sKbd=document.getElementById('kbd_id3').value; - kmw.addKeyboardsForLanguage(sKbd); + keyman.addKeyboardsForLanguage(sKbd); break; } } function removeKeyboard(n) { - var sKbd=document.getElementById('kbd_id4').value, kmw=keyman; - kmw["removeKeyboards"](sKbd); + var sKbd=document.getElementById('kbd_id4').value; + keyman.removeKeyboards(sKbd); } // Add keyboard on Enter (as well as pressing button) diff --git a/web/src/test/manual/web/issue6005/index.html b/web/src/test/manual/web/issue6005/index.html index 7326f99c7d..9410bed564 100644 --- a/web/src/test/manual/web/issue6005/index.html +++ b/web/src/test/manual/web/issue6005/index.html @@ -40,13 +40,11 @@ const alertType = (new URLSearchParams(window.location.search)).get("useAlerts") != "false"; var alertText = alertType ? 'enabled(default)' : 'disabled'; - var kmw=window.keyman; - - kmw.init({ + keyman.init({ 'attachType': 'auto' }).then(function() { // if mobile device, activate pred text. - if(kmw.util.isTouchDevice()) { + if(keyman.util.isTouchDevice()) { var pageRef = (window.location.protocol == 'file:') ? window.location.href.substr(0, window.location.href.lastIndexOf('/')+1) : window.location.href; @@ -60,24 +58,25 @@ languages: ['en'], path: (pageRef + "prediction-mtnt/nrc.en.mtnt.model.js") }; - kmw.addModel(modelStub1); + keyman.addModel(modelStub1); var modelStub2 = {'id': 'nrc.en.mtnt', languages: ['pny-latn'], // yep. Total cheat for the sake of testing. path: (pageRef + "prediction-mtnt/nrc.en.mtnt.model.js") }; - kmw.addModel(modelStub2); + keyman.addModel(modelStub2); } - }); - kmw.addKeyboards('sil_euro_latin@en', - 'galaxie_hebrew_positional@he', - 'sil_cameroon_qwerty@pny-latn'); - kmw.addKeyboards({ - id:'unmatched_final_group_model_interactions_6005', - name:'"Unmatched Vowel Rule"', - languages:{id:'en',name:'English'}, - filename:('unmatched_final_group_model_interactions_6005.js') - }); + keyman.addKeyboards('sil_euro_latin@en', + 'galaxie_hebrew_positional@he', + 'sil_cameroon_qwerty@pny-latn'); + + keyman.addKeyboards({ + id:'unmatched_final_group_model_interactions_6005', + name:'"Unmatched Vowel Rule"', + languages:{id:'en',name:'English'}, + filename:('unmatched_final_group_model_interactions_6005.js') + }); + }); diff --git a/web/src/test/manual/web/issue62/index.html b/web/src/test/manual/web/issue62/index.html index 23ac9889df..c647b03e88 100644 --- a/web/src/test/manual/web/issue62/index.html +++ b/web/src/test/manual/web/issue62/index.html @@ -36,9 +36,10 @@ @@ -49,7 +50,7 @@ - +

KeymanWeb: Light test for touch-based MutationObserver functionality

diff --git a/web/src/test/manual/web/issue63/index.html b/web/src/test/manual/web/issue63/index.html index 55cee63c97..acb2cdd40f 100644 --- a/web/src/test/manual/web/issue63/index.html +++ b/web/src/test/manual/web/issue63/index.html @@ -36,9 +36,10 @@ @@ -49,7 +50,7 @@ - +

KeymanWeb: Test/stress-test touch-based MutationObserver functionality

diff --git a/web/src/test/manual/web/issue917-context-and-notany/index.html b/web/src/test/manual/web/issue917-context-and-notany/index.html index 251edb9142..f7941b8a0d 100644 --- a/web/src/test/manual/web/issue917-context-and-notany/index.html +++ b/web/src/test/manual/web/issue917-context-and-notany/index.html @@ -36,11 +36,10 @@ diff --git a/web/src/test/manual/web/issue920/index.html b/web/src/test/manual/web/issue920/index.html index b20f536906..03265f30bf 100644 --- a/web/src/test/manual/web/issue920/index.html +++ b/web/src/test/manual/web/issue920/index.html @@ -36,15 +36,10 @@ - +

KeymanWeb Sample Page - deadkeys and multiple groups Testing

See issue #920 for details on what this test is all about. Both ;e and .e should produce ə but ;e uses a deadkey and fails.

diff --git a/web/src/test/manual/web/issue9469/index.html b/web/src/test/manual/web/issue9469/index.html index 78c3a15f95..5c7e33daa3 100644 --- a/web/src/test/manual/web/issue9469/index.html +++ b/web/src/test/manual/web/issue9469/index.html @@ -36,11 +36,10 @@ @@ -48,7 +49,7 @@ - +

KeymanWeb Sample Page - Error Testing page

Pick one of the 'debugging' keyboards to test whether the represented error is handled correctly.

diff --git a/web/src/test/manual/web/mnemonic/index.html b/web/src/test/manual/web/mnemonic/index.html index e0b0f0de79..a77c862219 100644 --- a/web/src/test/manual/web/mnemonic/index.html +++ b/web/src/test/manual/web/mnemonic/index.html @@ -33,24 +33,21 @@ - - diff --git a/web/src/test/manual/web/options-with-save/index.html b/web/src/test/manual/web/options-with-save/index.html index 9f607fd11f..2e69318bbc 100644 --- a/web/src/test/manual/web/options-with-save/index.html +++ b/web/src/test/manual/web/options-with-save/index.html @@ -33,24 +33,21 @@ - - diff --git a/web/src/test/manual/web/osk-event-buttons/index.html b/web/src/test/manual/web/osk-event-buttons/index.html index 92dafcdb4a..95ce7c7707 100644 --- a/web/src/test/manual/web/osk-event-buttons/index.html +++ b/web/src/test/manual/web/osk-event-buttons/index.html @@ -36,8 +36,7 @@ @@ -51,7 +52,7 @@ - +

KeymanWeb Sample Page - Platform Testing

This page is designed to test the Platform statement for consistency. Refer to PR #969.
diff --git a/web/src/test/manual/web/platform/utilities.js b/web/src/test/manual/web/platform/utilities.js index 5c351e0317..bb02bdd6a1 100644 --- a/web/src/test/manual/web/platform/utilities.js +++ b/web/src/test/manual/web/platform/utilities.js @@ -1,8 +1,6 @@ -function loadKeyboards() -{ - var kmw=keyman; - - kmw.addKeyboards({id:'platformtest',name:'Platform Testing', +function loadKeyboards() +{ + keyman.addKeyboards({id:'platformtest',name:'Platform Testing', languages:{ id:'en',name:'English',region:'North America' }, diff --git a/web/src/test/manual/web/pr10506/header.js b/web/src/test/manual/web/pr10506/header.js index 0107af3525..986cc00433 100644 --- a/web/src/test/manual/web/pr10506/header.js +++ b/web/src/test/manual/web/pr10506/header.js @@ -1,11 +1,10 @@ function loadKeyboards() { - var kmw=keyman; - kmw.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, + keyman.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, filename:('../us-1.0.js')}); // Add a fully-specified, locally-sourced, keyboard with custom font - kmw.addKeyboards({id:'dotty_keys',name:'Dotty Keys', + keyman.addKeyboards({id:'dotty_keys',name:'Dotty Keys', languages:{ id:'und',name:'Special',region:'World', font:{family:'DejaVu Dots',source:['./DejaVu-Dots.ttf']} diff --git a/web/src/test/manual/web/pr10506/index.html b/web/src/test/manual/web/pr10506/index.html index 06eae11362..84ff48f868 100644 --- a/web/src/test/manual/web/pr10506/index.html +++ b/web/src/test/manual/web/pr10506/index.html @@ -36,9 +36,10 @@ @@ -49,7 +50,7 @@ - +

KeymanWeb Test Page - Key-cap scaling / font-load delay interactions

To run the test, simply change from the default (English) keyboard to the "Special" keyboard.

When things are working properly, the base keys on the "Special" keyboard should contain diff --git a/web/src/test/manual/web/prediction-mtnt/index.html b/web/src/test/manual/web/prediction-mtnt/index.html index 34f43f3c5b..0e41b5b63a 100644 --- a/web/src/test/manual/web/prediction-mtnt/index.html +++ b/web/src/test/manual/web/prediction-mtnt/index.html @@ -36,16 +36,15 @@ - +

@@ -72,8 +73,8 @@ e.value = e.value + event + ': w.iw='+window.innerWidth+' w.ih='+window.innerHeight+orientText+orient+'\n'; - if(event == 'kmw.ri' || event == 'kmw.rh' || event == 'kmw.re') { - e.value = e.value + ' osk.w=' + keyman.osk.computedWidth + ' osk.h=' + keyman.osk.computedHeight + ' kmw.vs=' + keyman.util.getViewportScale() + '\n'; + if(event == 'keyman.ri' || event == 'keyman.rh' || event == 'keyman.re') { + e.value = e.value + ' osk.w=' + keyman.osk.computedWidth + ' osk.h=' + keyman.osk.computedHeight + ' keyman.vs=' + keyman.util.getViewportScale() + '\n'; } e.scrollTop = e.scrollHeight; @@ -89,22 +90,22 @@ window.addEventListener('orientationchange', function() { doLog('orientationchange-b', false) }, false); window.addEventListener('orientationchange', function() { doLog('orientationchange-c', false) }, true); - var kmw_ri = com.keyman.RotationManager.prototype.initNewRotation; + var keyman_ri = com.keyman.RotationManager.prototype.initNewRotation; com.keyman.RotationManager.prototype.initNewRotation = function() { - doLog('kmw.ri'); - kmw_ri.call(this); + doLog('keyman.ri'); + keyman_ri.call(this); } - var kmw_rh = com.keyman.RotationManager.prototype.iOSEventHandler; + var keyman_rh = com.keyman.RotationManager.prototype.iOSEventHandler; com.keyman.RotationManager.prototype.iOSEventHandler = function() { - doLog('kmw.rh'); - kmw_rh.call(this); + doLog('keyman.rh'); + keyman_rh.call(this); } - var kmw_irh = com.keyman.RotationManager.prototype.resolve; + var keyman_irh = com.keyman.RotationManager.prototype.resolve; com.keyman.RotationManager.prototype.resolve = function() { - doLog('kmw.re'); - kmw_irh.call(this); + doLog('keyman.re'); + keyman_irh.call(this); }

Return to main index. diff --git a/web/src/test/manual/web/sentry-integration/errorhdr.js b/web/src/test/manual/web/sentry-integration/errorhdr.js index f5e4ec283f..81ccf2abbd 100644 --- a/web/src/test/manual/web/sentry-integration/errorhdr.js +++ b/web/src/test/manual/web/sentry-integration/errorhdr.js @@ -1,27 +1,25 @@ // JavaScript Document samplehdr.js: Keyboard management for KeymanWeb demonstration pages -/* +/* This script is designed to test KeymanWeb error message handling. */ -function loadKeyboards() { - var kmw=keyman; - +function loadKeyboards() { // We start by adding a keyboard correctly. It's best to include a 'control' in our experiment. - kmw.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, + keyman.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, filename:'../us-1.0.js'}); - + // Insert a keyboard that cannot be found. - kmw.addKeyboards({id:'lao_2008_basic',name:'wrong-filename', + keyman.addKeyboards({id:'lao_2008_basic',name:'wrong-filename', languages:{ id:'lo',name:'debugging',region:'Asia', font:{family:'LaoWeb',source:['../font/saysettha_web.ttf','../font/saysettha_web.woff','../font/saysettha_web.eot']} }, filename:'./missing_file.js' // Intentional error - the file doesn't exist, so the @@ -51,7 +52,7 @@ - +

KeymanWeb Sample Page - Shift Testing

See issue #978 for details on what this test is all about.

diff --git a/web/src/test/manual/web/test-updateLayer/utilities.js b/web/src/test/manual/web/test-updateLayer/utilities.js index 41704fa0da..378f44c87e 100644 --- a/web/src/test/manual/web/test-updateLayer/utilities.js +++ b/web/src/test/manual/web/test-updateLayer/utilities.js @@ -1,8 +1,6 @@ -function loadKeyboards() -{ - var kmw=keyman; - - kmw.addKeyboards({id:'rac_balti',name:'RAC Balti', +function loadKeyboards() +{ + keyman.addKeyboards({id:'rac_balti',name:'RAC Balti', languages:{ id:'en',name:'English',region:'North America' }, diff --git a/web/src/test/manual/web/text_selection_tests_9073/index.html b/web/src/test/manual/web/text_selection_tests_9073/index.html index 20bd3cda68..7851186892 100644 --- a/web/src/test/manual/web/text_selection_tests_9073/index.html +++ b/web/src/test/manual/web/text_selection_tests_9073/index.html @@ -20,23 +20,21 @@ + }); + diff --git a/web/src/test/manual/web/unminified - manual.html b/web/src/test/manual/web/unminified - manual.html index dbe7cdae48..5ff0c686e3 100644 --- a/web/src/test/manual/web/unminified - manual.html +++ b/web/src/test/manual/web/unminified - manual.html @@ -36,8 +36,7 @@ diff --git a/web/src/tools/testing/bulk_rendering/renderer_core.ts b/web/src/tools/testing/bulk_rendering/renderer_core.ts index fa90283838..597ef36950 100644 --- a/web/src/tools/testing/bulk_rendering/renderer_core.ts +++ b/web/src/tools/testing/bulk_rendering/renderer_core.ts @@ -326,5 +326,5 @@ export class BatchRenderer { // BatchRenderer's internal state stuff is static on the class and is not exposed with // the line below. // @ts-ignore - window['kmw_renderer'] = new BatchRenderer(); + window['keyman_renderer'] = new BatchRenderer(); })(); \ No newline at end of file diff --git a/web/src/tools/testing/recorder/index.html b/web/src/tools/testing/recorder/index.html index f4a68ba769..a9af8e2029 100644 --- a/web/src/tools/testing/recorder/index.html +++ b/web/src/tools/testing/recorder/index.html @@ -57,6 +57,8 @@ keyman.init({ attachType: 'auto', keyboards: '' + }).then(function () { + loadKeyboards(); }); document.getElementById("body").focus(); @@ -67,7 +69,7 @@ - +
From d7439e68ac7bd817de045213defadd0cd6c0b1f1 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 3 Jun 2026 19:40:01 +0200 Subject: [PATCH 013/148] chore(web): more improvements to documentation - update text in `__control-by-control.html` sample - some small updates to the text Build-bot: skip Test-bot: skip --- web/docs/engine/guide/examples/__control-by-control.html | 2 +- web/docs/engine/guide/examples/automatic-control.md | 2 -- web/docs/engine/guide/examples/control-by-control.md | 2 -- .../headless/engine/main/headless/languageProcessor.tests.js | 2 +- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/web/docs/engine/guide/examples/__control-by-control.html b/web/docs/engine/guide/examples/__control-by-control.html index 07736a05db..349faf9a39 100644 --- a/web/docs/engine/guide/examples/__control-by-control.html +++ b/web/docs/engine/guide/examples/__control-by-control.html @@ -34,7 +34,7 @@

Email to (KeymanWeb not enabled)

-

Subject (Defaults to 'English' or 'off' unless on a touch-based device)

+

Subject (Defaults to the first language added to KeymanWeb after initialization)

Message body (Defaults to 'LaoKeys')

diff --git a/web/docs/engine/guide/examples/automatic-control.md b/web/docs/engine/guide/examples/automatic-control.md index 14b703d35a..a12de04b49 100644 --- a/web/docs/engine/guide/examples/automatic-control.md +++ b/web/docs/engine/guide/examples/automatic-control.md @@ -29,8 +29,6 @@ In this example, we use only the LaoKey keyboard. Please click [this link](./__a ``` -As you can see above, the second line in the code snippet above references the LaoKey keyboard loader JavaScript file. This is a small stub file, typically less than 200 bytes, that defines the name and actual location of the real keyboard file (in this case, **laokeys.js**). When a page may reference many keyboards, this saves downloading potentially hundreds of kilobytes of unused Javascript keyboards - the keyboard is downloaded when it is first selected by the user. - ## API References On initialization: [`keyman.init()`](../../reference/core/init). diff --git a/web/docs/engine/guide/examples/control-by-control.md b/web/docs/engine/guide/examples/control-by-control.md index 5f57a5a778..b637b0b693 100644 --- a/web/docs/engine/guide/examples/control-by-control.md +++ b/web/docs/engine/guide/examples/control-by-control.md @@ -41,8 +41,6 @@ Also include the following HTML code: // example without detailing paths precisely in the init() call's parameter.--> - - ``` diff --git a/web/src/test/auto/headless/engine/main/headless/languageProcessor.tests.js b/web/src/test/auto/headless/engine/main/headless/languageProcessor.tests.js index 3375279649..6364832fc4 100644 --- a/web/src/test/auto/headless/engine/main/headless/languageProcessor.tests.js +++ b/web/src/test/auto/headless/engine/main/headless/languageProcessor.tests.js @@ -63,7 +63,7 @@ describe('LanguageProcessor', function() { assert.isTrue(languageProcessor.mayPredict); // Some aspects of initialization must wait until after construction and overall - // load of the core. See /web/source/kmwbase.ts, in the final IIFE. + // load of the core. assert.isOk(languageProcessor.lmEngine); }); }); From f218aa92db6845f3187067099bc88cebbf484c91 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 3 Jun 2026 19:41:47 +0200 Subject: [PATCH 014/148] refactor(web): reformatting of files Test-bot: skip --- .../guide/examples/automatic-control.md | 6 +++-- .../guide/examples/control-by-control.md | 10 ++++++-- .../guide/examples/full-manual-control.md | 7 +++--- .../engine/guide/examples/manual-control.md | 5 ++-- .../app/browser/src/context/focusAssistant.ts | 2 +- .../keyboard/keyboards/keyboardProperties.ts | 2 +- .../web/build-visual-keyboard/index.html | 23 ++++++++----------- .../test/manual/web/chirality/utilities.js | 2 +- 8 files changed, 32 insertions(+), 25 deletions(-) diff --git a/web/docs/engine/guide/examples/automatic-control.md b/web/docs/engine/guide/examples/automatic-control.md index c804556e18..ef8a57f00f 100644 --- a/web/docs/engine/guide/examples/automatic-control.md +++ b/web/docs/engine/guide/examples/automatic-control.md @@ -2,9 +2,11 @@ title: Automatic Mode Example --- -This page shows how to include a local keyboard from an arbitrary location in your website's file structure. +This page shows how to include a local keyboard from an arbitrary location +in your website's file structure. -In this example, we use only the LaoKey keyboard. Please click [this link](./__auto-control.html) to open the test page. +In this example, we use only the LaoKey keyboard. Please click +[this link](./__auto-control.html) to open the test page. ## Code Walkthrough diff --git a/web/docs/engine/guide/examples/control-by-control.md b/web/docs/engine/guide/examples/control-by-control.md index 974338ef1f..d8492b99bb 100644 --- a/web/docs/engine/guide/examples/control-by-control.md +++ b/web/docs/engine/guide/examples/control-by-control.md @@ -2,7 +2,10 @@ title: Control-by-Control Example --- -In this example, a simulated webmail form, the default and permissible keyboard for each control is managed by the web page. We use the automatic mode for simplicity of demonstration. We also link to the CDN for KeymanWeb in this example. Please click [this link](__control-by-control.html) to open the test page. +In this example, a simulated webmail form, the default and permissible keyboard for each control +is managed by the web page. We use the automatic mode for simplicity of demonstration. We also +link to the CDN for KeymanWeb in this example. Please click [this link](__control-by-control.html) +to open the test page. ## Code Walkthrough @@ -51,7 +54,10 @@ Also include the following HTML code: ``` --- -**Note:** In this example we disabled the first element (`document.f.address`) by API call. A later API call can re-enable KeymanWeb for this control should it fit the page's design. Alternatively, this can be done by instead adding the class `'kmw-disabled'` to the control. This will permanently block KeymanWeb from handling its input. +**Note:** In this example we disabled the first element (`document.f.address`) by API call. A +later API call can re-enable KeymanWeb for this control should it fit the page's design. +Alternatively, this can be done by instead adding the class `'kmw-disabled'` to the control. This +will permanently block KeymanWeb from handling its input. --- diff --git a/web/docs/engine/guide/examples/full-manual-control.md b/web/docs/engine/guide/examples/full-manual-control.md index e9e8d2908a..5aa9189316 100644 --- a/web/docs/engine/guide/examples/full-manual-control.md +++ b/web/docs/engine/guide/examples/full-manual-control.md @@ -2,7 +2,9 @@ title: Manual Control - Custom Interface --- -In this example, the web page designer has opted for their own user interface instead of the KeymanWeb interface. The keyboards in the selector are populated from the KeymanWeb list of keyboards. Please click [this link](__full-manual-control.html) to open the test page. +In this example, the web page designer has opted for their own user interface instead of the +KeymanWeb interface. The keyboards in the selector are populated from the KeymanWeb list of +keyboards. Please click [this link](__full-manual-control.html) to open the test page. ## Code Walkthrough @@ -35,8 +37,7 @@ Include the following script in the HEAD of your page: } /* KWControlChange: Called when user selects an item in the KWControl SELECT */ - function KWControlChange() - { + function KWControlChange() { /* Select the keyboard in KeymanWeb */ var name = KWControl.value.substr(0, KWControl.value.indexOf("$$")); var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$"+2)); diff --git a/web/docs/engine/guide/examples/manual-control.md b/web/docs/engine/guide/examples/manual-control.md index 1087c518c8..d1bd854d9b 100644 --- a/web/docs/engine/guide/examples/manual-control.md +++ b/web/docs/engine/guide/examples/manual-control.md @@ -23,8 +23,7 @@ Include the following script in the HEAD of your page: } /* KWControlClick: Called when user clicks on the KWControl IMG */ - function KWControlClick() - { + function KWControlClick() { if(keyman.osk.isEnabled()) { keyman.osk.hide(); } else { @@ -61,9 +60,11 @@ And finally, include the control img for KeymanWeb: ## API References On programmatically setting the keyboard: + - [`keyman.setActiveKeyboard()`](../../reference/core/setActiveKeyboard). On managing the visibility of the OSK: + - [`keyman.osk.hide()`](../../reference/osk/hide) - [`keyman.osk.show()`](../../reference/osk/show). diff --git a/web/src/app/browser/src/context/focusAssistant.ts b/web/src/app/browser/src/context/focusAssistant.ts index accb9b811e..ade72ea65a 100644 --- a/web/src/app/browser/src/context/focusAssistant.ts +++ b/web/src/app/browser/src/context/focusAssistant.ts @@ -149,7 +149,7 @@ export class FocusAssistant extends EventEmitter { * @param {(boolean|number)} state Activate (true,false) */ setMaintainingFocus(state: boolean) { - this.maintainingFocus = state ? true : false; + this.maintainingFocus = !!state; } setFocusTimer(): void { diff --git a/web/src/engine/src/keyboard/keyboards/keyboardProperties.ts b/web/src/engine/src/keyboard/keyboards/keyboardProperties.ts index 1a3a9e215a..b9b32b444a 100644 --- a/web/src/engine/src/keyboard/keyboards/keyboardProperties.ts +++ b/web/src/engine/src/keyboard/keyboards/keyboardProperties.ts @@ -255,7 +255,7 @@ export class KeyboardProperties implements KeyboardInternalPropertySpec { return null; } - public validateForCustomKeyboard(): Error { + public validateForCustomKeyboard(): Error|null { if(!this.KI || !this.KN || !this.KL || !this.KLC) { return new Error("To use a custom keyboard, you must specify keyboard id, keyboard name, language and language code."); } else { diff --git a/web/src/test/manual/web/build-visual-keyboard/index.html b/web/src/test/manual/web/build-visual-keyboard/index.html index 2845f3db86..e6231dd691 100644 --- a/web/src/test/manual/web/build-visual-keyboard/index.html +++ b/web/src/test/manual/web/build-visual-keyboard/index.html @@ -51,7 +51,7 @@ platform == 'phone' ? 240 : 360; }; - let shadowfy = function(container) { + function shadowfy(container) { const shadow = container.attachShadow({ mode: "open" }); const docBase = container.children[0]; @@ -67,7 +67,7 @@ shadow.appendChild(docBase); } - let documentKeyboard = async function(kbdid, langid, formFactor, layer) { + async function documentKeyboard(kbdid, langid, formFactor, layer) { let docTarget = document.getElementById("docTarget"); label = document.createElement('h3'); docTarget.appendChild(label); @@ -77,8 +77,8 @@ const container = document.createElement('div'); - await kmw.addKeyboards(`${kbdid}@${langid}`); - await kmw.setActiveKeyboard(kbdid, langid); + await keyman.addKeyboards(`${kbdid}@${langid}`); + await keyman.setActiveKeyboard(kbdid, langid); docBase = keyman.BuildVisualKeyboard(kbdid, 1, formFactor, layer); label.textContent = `${keyman.getKeyboard(kbdid).Name} - ${formFactor} - ${layer} layer`; @@ -121,15 +121,12 @@ (It is ignored by other User Interfaces.) -->
-

This test page exists for testing KMW's keyboard-documentation rendering system.

- -
-
- -

Return to testing home page

- - - +

This test page exists for testing KMW's keyboard-documentation rendering system.

+ +
+
+ +

Return to testing home page

From c1113ac21d5d876b69504e557c0c9aa97459fdbb Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 8 Jun 2026 19:27:12 +0200 Subject: [PATCH 021/148] chore(web): address code review comments Also change a few places that I missed. --- .../keyboards/test_8568_deadkeys.html | 28 ++++++++--------- .../help/reference/kmc/cli/get-started.md | 2 +- developer/src/kmc/README.md | 2 +- web/docs/engine/guide/adding-keyboards.md | 18 +++++------ .../engine/guide/examples/__auto-control.html | 4 +-- .../guide/examples/__control-by-control.html | 7 ++--- .../guide/examples/__first-example.html | 6 ++-- .../guide/examples/automatic-control.md | 4 +-- web/docs/engine/guide/get-started.md | 10 +++---- .../engine/reference/core/addKeyboards.md | 12 +++++++- .../reference/core/addKeyboardsForLanguage.md | 2 +- web/docs/engine/reference/core/getKeyboard.md | 4 +-- .../engine/reference/core/getKeyboards.md | 4 +-- .../reference/interface/registerStub.md | 4 +-- web/src/app/ui/kmwuitoolbar.ts | 4 +-- web/src/samples/complex/root/pages/index.html | 10 +++---- web/src/samples/samplehdr.js | 30 ++++++++++--------- web/src/samples/subfolder_toolbar/index.html | 11 ++++--- .../web/build-visual-keyboard/index.html | 5 ++-- web/src/test/manual/web/ckeditor/index.html | 4 +-- web/src/test/manual/web/ckeditor/inline.html | 4 +-- .../test/manual/web/default-subkey/index.html | 4 +-- .../test/manual/web/desktop-ui/toolbar.html | 4 +-- web/src/test/manual/web/issue103/index.html | 4 +-- web/src/test/manual/web/issue103/samplehdr.js | 28 +++++++++-------- 25 files changed, 110 insertions(+), 105 deletions(-) diff --git a/common/test/resources/keyboards/test_8568_deadkeys.html b/common/test/resources/keyboards/test_8568_deadkeys.html index 1b4cf31424..054f19b4f8 100644 --- a/common/test/resources/keyboards/test_8568_deadkeys.html +++ b/common/test/resources/keyboards/test_8568_deadkeys.html @@ -36,22 +36,20 @@ + }); + diff --git a/developer/docs/help/reference/kmc/cli/get-started.md b/developer/docs/help/reference/kmc/cli/get-started.md index 348bf4c4b9..2316abef38 100644 --- a/developer/docs/help/reference/kmc/cli/get-started.md +++ b/developer/docs/help/reference/kmc/cli/get-started.md @@ -110,7 +110,7 @@ hamburger menu in the Keyman app. **Web**: copy khmer_angkor.js to your website, then [load it with KeymanWeb][load-keymanweb-keyboard]: ```js -keyman.addKeyboards({ +await keyman.addKeyboards({ id:'khmer_angkor', // The keyboard's unique identification code. name:'Khmer Angkor', // The keyboard's user-readable name. language:{ diff --git a/developer/src/kmc/README.md b/developer/src/kmc/README.md index 0490998e4d..05fd265a50 100644 --- a/developer/src/kmc/README.md +++ b/developer/src/kmc/README.md @@ -109,7 +109,7 @@ hamburger menu in the Keyman app. **Web**: copy khmer_angkor.js to your website, then [load it with KeymanWeb][load-keymanweb-keyboard]: ```js -keyman.addKeyboards({ +await keyman.addKeyboards({ id:'khmer_angkor', // The keyboard's unique identification code. name:'Khmer Angkor', // The keyboard's user-readable name. language:{ diff --git a/web/docs/engine/guide/adding-keyboards.md b/web/docs/engine/guide/adding-keyboards.md index 221f495de1..2874d449bc 100644 --- a/web/docs/engine/guide/adding-keyboards.md +++ b/web/docs/engine/guide/adding-keyboards.md @@ -8,8 +8,8 @@ There are multiple ways to add and install keyboards into your KeymanWeb install The most efficient way to utilize a keyboard is to obtain a local copy of it and place this copy in a static location on your website. Once this is done, it can be directly linked into KeymanWeb as follows. -```c -keyman.addKeyboards({ +```typescript +await keyman.addKeyboards({ id:'us', // The keyboard's unique identification code. name:'English', // The keyboard's user-readable name. language:{ @@ -22,7 +22,7 @@ keyman.addKeyboards({ Custom fonts may also be utilized via the `language.font` property. For example: -```c +```typescript font:{ family:'LaoWeb', source:['../font/saysettha_web.ttf','../font/saysettha_web.woff','../font/saysettha_web.eot'] @@ -33,16 +33,16 @@ font:{ To obtain the default Keyman keyboard for a given language, call the following function. -```c -keyman.addKeyboardsForLanguage('Dzongkha'); +```typescript +await keyman.addKeyboardsForLanguage('Dzongkha'); ``` This example would find the default keyboard for the Dzongkha language. This method will fail if the name doesn't perfectly match any language found in the CDN's repository. Alternatively, languages may be looked up via their BCP 47 language code as follows: -```c -keyman.addKeyboards('@he') +```typescript +await keyman.addKeyboards('@he') ``` The `@` prefix indicates the use of the BCP 47 language code, which in this case corresponds with Hebrew. @@ -51,8 +51,8 @@ The `@` prefix indicates the use of the BCP 47 language code, which in this case To obtain a specific keyboard by name or by keyboard name and language code as a pair, see the following: -```c -keyman.addKeyboards('french','sil_euro_latin@sv','sil_euro_latin@no') +```typescript +await keyman.addKeyboards('french','sil_euro_latin@sv','sil_euro_latin@no') ``` This will install three keyboards - one for French (named, quite simply, "French") and two copies of the EuroLatin keyboard - one for Swedish and one for Norwegian. diff --git a/web/docs/engine/guide/examples/__auto-control.html b/web/docs/engine/guide/examples/__auto-control.html index efe5467bfd..c2a8b472b0 100644 --- a/web/docs/engine/guide/examples/__auto-control.html +++ b/web/docs/engine/guide/examples/__auto-control.html @@ -3,8 +3,8 @@ - @@ -20,8 +19,8 @@ // Disable KeymanWeb interaction on the 'Email to' TEXT control keyman.disableControl(document.f.address); - // Set the default keyboard for the 'Subject' TEXT control to 'off' (i.e. default - // browser keyboard) + // Set the default keyboard for the 'Subject' TEXT control to the first language + // added to KeymanWeb after initialization. keyman.setKeyboardForControl(document.f.subject, '', ''); // Set the default keyboard for the 'Message body' TEXTAREA to the LaoKeys keyboard keyman.setKeyboardForControl(document.f.text, 'Keyboard_laokeys', 'lo'); @@ -42,4 +41,4 @@ Back to Document - \ No newline at end of file + diff --git a/web/docs/engine/guide/examples/__first-example.html b/web/docs/engine/guide/examples/__first-example.html index ece6a9b9ea..24a58c4891 100644 --- a/web/docs/engine/guide/examples/__first-example.html +++ b/web/docs/engine/guide/examples/__first-example.html @@ -10,9 +10,9 @@ diff --git a/web/docs/engine/guide/examples/automatic-control.md b/web/docs/engine/guide/examples/automatic-control.md index 14b703d35a..d185e4efe3 100644 --- a/web/docs/engine/guide/examples/automatic-control.md +++ b/web/docs/engine/guide/examples/automatic-control.md @@ -13,8 +13,8 @@ In this example, we use only the LaoKey keyboard. Please click [this link](./__a diff --git a/web/docs/engine/reference/core/addKeyboards.md b/web/docs/engine/reference/core/addKeyboards.md index fc5e3ce07b..fd4049f221 100644 --- a/web/docs/engine/reference/core/addKeyboards.md +++ b/web/docs/engine/reference/core/addKeyboards.md @@ -9,7 +9,7 @@ Adds keyboards to KeymanWeb. ## Syntax ```js -keyman.addKeyboards(spec[, spec...]) +await keyman.addKeyboards([spec...]) ``` ### Parameters @@ -179,3 +179,13 @@ The `spec.languages.font` object contains the following members: : `string` optional Font size (in CSS dimensions). If not specified, then `1em` is used. + +### Not passing any parameter + +Not passing any parameter will obtain the Keyman keyboards for English +from the CDN, i.e. these two functions calls are identical: + +```js +await keyman.addKeyboards(); +await keyman.addKeyboards('@en'); +``` diff --git a/web/docs/engine/reference/core/addKeyboardsForLanguage.md b/web/docs/engine/reference/core/addKeyboardsForLanguage.md index bf97a06b8d..e3a1e553c7 100644 --- a/web/docs/engine/reference/core/addKeyboardsForLanguage.md +++ b/web/docs/engine/reference/core/addKeyboardsForLanguage.md @@ -9,7 +9,7 @@ Add default or all keyboards for a given language to KeymanWeb. ## Syntax ```js -keyman.addKeyboardsForLanguage(spec[, spec...]) +await keyman.addKeyboardsForLanguage(spec[, spec...]) ``` ### Parameters diff --git a/web/docs/engine/reference/core/getKeyboard.md b/web/docs/engine/reference/core/getKeyboard.md index 0d6f8eb52e..9b728995d0 100644 --- a/web/docs/engine/reference/core/getKeyboard.md +++ b/web/docs/engine/reference/core/getKeyboard.md @@ -8,7 +8,7 @@ Get keyboard meta data for the selected keyboard and language. ## Syntax -```c +```js keyman.getKeyboard(keyboardName, languageCode) ``` @@ -75,5 +75,5 @@ The `keyboard` object contains the following members: : `string` *optional* : The font packaged with the keyboard to properly display specialized OSK characters. -## See also +## See also - [keyman.addKeyboards()](addKeyboards) and its documentation about keyboard specification objects. diff --git a/web/docs/engine/reference/core/getKeyboards.md b/web/docs/engine/reference/core/getKeyboards.md index 8d84521b31..cd8b0f2d9c 100644 --- a/web/docs/engine/reference/core/getKeyboards.md +++ b/web/docs/engine/reference/core/getKeyboards.md @@ -8,7 +8,7 @@ Get details of currently installed keyboards. ## Syntax -```c +```js keyman.getKeyboards() ``` @@ -25,5 +25,5 @@ None. See [keyman.getKeyboard()](getKeyboard) for detail on the returned keyboard specification objects. -## See also +## See also - [keyman.addKeyboards()](addKeyboards) diff --git a/web/docs/engine/reference/interface/registerStub.md b/web/docs/engine/reference/interface/registerStub.md index 2d9c8ef5a8..9dc8e10c34 100644 --- a/web/docs/engine/reference/interface/registerStub.md +++ b/web/docs/engine/reference/interface/registerStub.md @@ -8,13 +8,13 @@ Registers the keyboard stub or returns true if already registered. ## Syntax -```c +```js keyman.interface.registerStub(Pstub); ``` or -```c +```js KeymanWeb.KRS(Pstub); // Shorthand ``` diff --git a/web/src/app/ui/kmwuitoolbar.ts b/web/src/app/ui/kmwuitoolbar.ts index cf736777f9..6ed8d82e23 100644 --- a/web/src/app/ui/kmwuitoolbar.ts +++ b/web/src/app/ui/kmwuitoolbar.ts @@ -1150,8 +1150,8 @@ if(!keymanweb) { internalName: string, languageCode: string }) => { // Uses a different format than .getKeyboards(), b/c why not? - // https://help.keyman.com/developer/engine/web/16.0/reference/core/getKeyboards vs - // https://help.keyman.com/developer/engine/web/16.0/reference/events/kmw.keyboardchange + // https://help.keyman.com/developer/engine/web/current-version/reference/core/getKeyboards vs + // https://help.keyman.com/developer/engine/web/current-version/reference/events/kmw.keyboardchange this.lastSelectedKeyboard = null; const kbName=p.internalName, lgName=keymanweb.util.getLanguageCodes(p.languageCode)[0]; diff --git a/web/src/samples/complex/root/pages/index.html b/web/src/samples/complex/root/pages/index.html index 8da72fcccb..2e8122b1da 100644 --- a/web/src/samples/complex/root/pages/index.html +++ b/web/src/samples/complex/root/pages/index.html @@ -44,9 +44,9 @@ // KMW will look for them here - two folders up from root, which is the base sample directory. keyboards: '/../../', attachType:'auto' - }).then(function() { + }).then(async function() { // Loads keyboards from the base sample folder - keyman.addKeyboards({ + await keyman.addKeyboards({ id:'us', name:'English', languages: { @@ -57,7 +57,7 @@ filename: 'us-1.0.js' }); // For contrast: - keyman.addKeyboards({ + await keyman.addKeyboards({ id:'lao_2008_basic', name:'Lao Basic', languages: { @@ -73,12 +73,12 @@ // 1. keyboard name ('french'), // 2. keyboard name and language code ('sil_euro_latin@no,sv'), // 3. or just the BCP-47 language code ('@he'). - keyman.addKeyboards('french', 'sil_euro_latin@no,sv', '@he'); + await keyman.addKeyboards('french', 'sil_euro_latin@no,sv', '@he'); // Add a keyboard by language name. Note that the name must be spelled // correctly, or the keyboard will not be found. (Using BCP-47 codes is // usually easier.) - doAddKeyboardsForLanguage('Dzongkha'); + await doAddKeyboardsForLanguage('Dzongkha'); }); diff --git a/web/src/samples/samplehdr.js b/web/src/samples/samplehdr.js index ad9dd762a3..d3f89e5c4a 100644 --- a/web/src/samples/samplehdr.js +++ b/web/src/samples/samplehdr.js @@ -43,9 +43,9 @@ function errToString(err) { // Painful? Kinda. But needed on un-updated Android API 21! if(Array.isArray(err)) { - var result = ''; - for(var i = 0; i < err.length; i++) { - var e = err[i]; + let result = ''; + for(let i = 0; i < err.length; i++) { + const e = err[i]; if(e.error instanceof Error) { result += e.error.message + '\n'; } else { @@ -73,13 +73,13 @@ } async function loadKeyboards(nestLevel) { - var base_prefix = '../'; - var prefix = './'; // The default - when prefix == 0. + const base_prefix = '../'; + let prefix = './'; // The default - when prefix == 0. if(nestLevel !== undefined && nestLevel > 0) { prefix = ''; - for(var i=0; i < nestLevel; i++) { - prefix = prefix + base_prefix; + for(let i=0; i < nestLevel; i++) { + prefix += base_prefix; } } @@ -116,29 +116,31 @@ } // Script to allow a user to add any keyboard to the keyboard menu - function addKeyboard(n) { - var sKbd; + async function addKeyboard(n) { + let sKbd; switch(n) { case 1: sKbd=document.getElementById('kbd_id1').value; - doAddKeyboards(sKbd); + await doAddKeyboards(sKbd); break; case 2: sKbd=document.getElementById('kbd_id2').value.toLowerCase(); - doAddKeyboards('@'+sKbd); + await doAddKeyboards('@'+sKbd); break; case 3: // Add keyboard for comma-separated language name(s) sKbd=document.getElementById('kbd_id3').value; - doAddKeyboardsForLanguage(sKbd); + await doAddKeyboardsForLanguage(sKbd); break; } } // Add keyboard on Enter (as well as pressing button) - function clickOnEnter(e,id) + async function clickOnEnter(e,id) { e = e || window.event; - if(e.keyCode == 13) addKeyboard(id); + if (e.keyCode == 13) { + await addKeyboard(id); + } } diff --git a/web/src/samples/subfolder_toolbar/index.html b/web/src/samples/subfolder_toolbar/index.html index e3813e4b52..8a4c8d6b03 100644 --- a/web/src/samples/subfolder_toolbar/index.html +++ b/web/src/samples/subfolder_toolbar/index.html @@ -30,8 +30,8 @@ diff --git a/web/src/test/manual/web/build-visual-keyboard/index.html b/web/src/test/manual/web/build-visual-keyboard/index.html index 54a47172d0..77ccc07fbc 100644 --- a/web/src/test/manual/web/build-visual-keyboard/index.html +++ b/web/src/test/manual/web/build-visual-keyboard/index.html @@ -75,8 +75,8 @@ const container = document.createElement('div'); - await kmw.addKeyboards(`${kbdid}@${langid}`); - await kmw.setActiveKeyboard(kbdid, langid); + await keyman.addKeyboards(`${kbdid}@${langid}`); + await keyman.setActiveKeyboard(kbdid, langid); docBase = keyman.BuildVisualKeyboard(kbdid, 1, formFactor, layer); label.textContent = `${keyman.getKeyboard(kbdid).Name} - ${formFactor} - ${layer} layer`; @@ -94,6 +94,7 @@ } keyman.init().then(async function() { + await loadKeyboards(1); await documentKeyboard("gff_amharic", "am", "phone", "default"); await documentKeyboard("sil_euro_latin", "no", "phone", "shift"); await documentKeyboard("lao_2008_basic", "lo", "tablet", "default"); diff --git a/web/src/test/manual/web/ckeditor/index.html b/web/src/test/manual/web/ckeditor/index.html index 25dfc7e9e8..16de19ba02 100644 --- a/web/src/test/manual/web/ckeditor/index.html +++ b/web/src/test/manual/web/ckeditor/index.html @@ -32,9 +32,7 @@ diff --git a/web/src/test/manual/web/ckeditor/inline.html b/web/src/test/manual/web/ckeditor/inline.html index f54d98e0ff..06ec8ca0e5 100644 --- a/web/src/test/manual/web/ckeditor/inline.html +++ b/web/src/test/manual/web/ckeditor/inline.html @@ -32,9 +32,7 @@ diff --git a/web/src/test/manual/web/default-subkey/index.html b/web/src/test/manual/web/default-subkey/index.html index 6ce3bee562..22f00ef3fe 100644 --- a/web/src/test/manual/web/default-subkey/index.html +++ b/web/src/test/manual/web/default-subkey/index.html @@ -38,8 +38,8 @@ diff --git a/web/src/test/manual/web/issue103/index.html b/web/src/test/manual/web/issue103/index.html index e242071511..53c0447cbd 100644 --- a/web/src/test/manual/web/issue103/index.html +++ b/web/src/test/manual/web/issue103/index.html @@ -38,8 +38,8 @@ diff --git a/web/src/test/manual/web/issue103/samplehdr.js b/web/src/test/manual/web/issue103/samplehdr.js index 51a38e4eff..bf8cabae0c 100644 --- a/web/src/test/manual/web/issue103/samplehdr.js +++ b/web/src/test/manual/web/issue103/samplehdr.js @@ -1,25 +1,25 @@ // Modified version of commonHeader - contains repeated requests for the same language. - function loadKeyboards() { + async function loadKeyboards() { // The first keyboard added will be the default keyboard for touch devices. // For faster loading, it may be best for the default keyboard to be // locally sourced. - keyman.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, + await keyman.addKeyboards({id:'us',name:'English',languages:{id:'en',name:'English'}, filename:'../us-1.0.js'}); // Add more keyboards to the language menu, by keyboard name, // keyboard name and language code, or just the BCP-47 language code. - keyman.addKeyboards('french','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@no','@he'); - keyman.addKeyboards('@he'); - keyman.addKeyboards('@he'); + await keyman.addKeyboards('french','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@sv','european2@no','@he'); + await keyman.addKeyboards('@he'); + await keyman.addKeyboards('@he'); // Add a keyboard by language name. Note that the name must be spelled // correctly, or the keyboard will not be found. (Using BCP-47 codes is // usually easier.) - keyman.addKeyboardsForLanguage('Dzongkha'); + await keyman.addKeyboardsForLanguage('Dzongkha'); // Add a fully-specified, locally-sourced, keyboard with custom font - keyman.addKeyboards({id:'lao_2008_basic',name:'Lao Basic', + await keyman.addKeyboards({id:'lao_2008_basic',name:'Lao Basic', languages:{ id:'lo',name:'Lao',region:'Asia' }, @@ -38,29 +38,31 @@ } // Script to allow a user to add any keyboard to the keyboard menu - function addKeyboard(n) + async function addKeyboard(n) { let sKbd; switch(n) { case 1: sKbd=document.getElementById('kbd_id1').value; - keyman.addKeyboards(sKbd); + await keyman.addKeyboards(sKbd); break; case 2: sKbd=document.getElementById('kbd_id2').value.toLowerCase(); - keyman.addKeyboards('@'+sKbd); + await keyman.addKeyboards('@'+sKbd); break; case 3: sKbd=document.getElementById('kbd_id3').value; - keyman.addKeyboardsForLanguage(sKbd); + await keyman.addKeyboardsForLanguage(sKbd); break; } } // Add keyboard on Enter (as well as pressing button) - function clickOnEnter(e,id) { + async function clickOnEnter(e,id) { e = e || window.event; - if(e.keyCode == 13) addKeyboard(id); + if (e.keyCode == 13) { + await addKeyboard(id); + } } From 19e397de3532dc42f13cde0f96922135fb034aa9 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 8 Jun 2026 13:02:23 -0500 Subject: [PATCH 022/148] auto: increment master version to 19.0.244 Test-bot: skip Build-bot: skip --- HISTORY.md | 9 +++++++++ VERSION.md | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 7c83b63e6a..53fbcf5934 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,14 @@ # Keyman Version History +## 19.0.243 alpha 2026-06-08 + +* fix(developer): reduce km_core_keyboard_attrs size to 12 (#16075) +* chore(deps): bump minimatch in /core/tests/unit/wasm (#16045) +* chore(deps): bump glob from 10.4.5 to 10.5.0 in /core/tests/unit/wasm (#16046) +* chore(deps-dev): bump js-yaml from 4.1.0 to 4.2.0 in /core/tests/unit/wasm (#16047) +* chore(deps-dev): bump picomatch from 2.3.1 to 2.3.2 in /core/tests/unit/wasm (#16048) +* chore(deps): bump qs and express (#16009) + ## 19.0.242 alpha 2026-06-03 * fix(web): fix displaying of keyboard menu (#16061) diff --git a/VERSION.md b/VERSION.md index 1b1ffdead8..9cfd5bdf98 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.243 \ No newline at end of file +19.0.244 \ No newline at end of file From 135de352c4ba67166669975356f7db6edfee2212 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 9 Jun 2026 17:11:43 +0200 Subject: [PATCH 023/148] Apply suggestions from code review Co-authored-by: Marc Durdin --- web/docs/engine/guide/adding-keyboards.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/docs/engine/guide/adding-keyboards.md b/web/docs/engine/guide/adding-keyboards.md index 2874d449bc..957df80270 100644 --- a/web/docs/engine/guide/adding-keyboards.md +++ b/web/docs/engine/guide/adding-keyboards.md @@ -42,7 +42,7 @@ This example would find the default keyboard for the Dzongkha language. This met Alternatively, languages may be looked up via their BCP 47 language code as follows: ```typescript -await keyman.addKeyboards('@he') +await keyman.addKeyboards('@he'); ``` The `@` prefix indicates the use of the BCP 47 language code, which in this case corresponds with Hebrew. @@ -52,7 +52,7 @@ The `@` prefix indicates the use of the BCP 47 language code, which in this case To obtain a specific keyboard by name or by keyboard name and language code as a pair, see the following: ```typescript -await keyman.addKeyboards('french','sil_euro_latin@sv','sil_euro_latin@no') +await keyman.addKeyboards('french', 'sil_euro_latin@sv', 'sil_euro_latin@no'); ``` This will install three keyboards - one for French (named, quite simply, "French") and two copies of the EuroLatin keyboard - one for Swedish and one for Norwegian. From cf3d87d2f377fe7127731906a22acf9429b5c87e Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 9 Jun 2026 15:30:55 +0200 Subject: [PATCH 024/148] chore(web): update sample doc to match sample html The guide examples load `keymanweb.js` from the CDN, but the documentation explaining the examples showed loading local file. This change adjusts the docs to show loading from CDN. Built-bot: skip Test-bot: skip --- web/docs/engine/guide/examples/automatic-control.md | 2 +- web/docs/engine/guide/examples/control-by-control.md | 8 ++------ web/docs/engine/guide/examples/full-manual-control.md | 5 +---- web/docs/engine/guide/examples/manual-control.md | 5 +---- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/web/docs/engine/guide/examples/automatic-control.md b/web/docs/engine/guide/examples/automatic-control.md index d185e4efe3..6e6231d09d 100644 --- a/web/docs/engine/guide/examples/automatic-control.md +++ b/web/docs/engine/guide/examples/automatic-control.md @@ -11,7 +11,7 @@ In this example, we use only the LaoKey keyboard. Please click [this link](./__a ```html - + - + + - - - - ``` --- diff --git a/web/docs/engine/guide/examples/full-manual-control.md b/web/docs/engine/guide/examples/full-manual-control.md index c958bd87d9..e9c56a3a0c 100644 --- a/web/docs/engine/guide/examples/full-manual-control.md +++ b/web/docs/engine/guide/examples/full-manual-control.md @@ -47,14 +47,11 @@ Also include the following HTML code: ```html - + - - - ``` - File: [unified_loader.js](js/unified_loader.js) diff --git a/web/docs/engine/guide/examples/manual-control.md b/web/docs/engine/guide/examples/manual-control.md index 3f536d02e8..7a6f4f0988 100644 --- a/web/docs/engine/guide/examples/manual-control.md +++ b/web/docs/engine/guide/examples/manual-control.md @@ -33,13 +33,10 @@ Also include the following HTML code: ```html - + - - - ``` - File: [laokeys_load.js](js/laokeys_load.js) From cea706c562b87c3515ad24f5e7b44d4ca972fc44 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 9 Jun 2026 17:13:47 +0200 Subject: [PATCH 025/148] chore(web): address code review comments --- .../guide/examples/__control-by-control.html | 9 +- .../engine/reference/core/addKeyboards.md | 8 +- web/src/samples/samplehdr.js | 79 ++--- web/src/test/manual/web/commonHeader.js | 272 ++++++++---------- web/src/test/manual/web/issue103/samplehdr.js | 112 ++++---- 5 files changed, 218 insertions(+), 262 deletions(-) diff --git a/web/docs/engine/guide/examples/__control-by-control.html b/web/docs/engine/guide/examples/__control-by-control.html index a76cb12fd2..4f6e6d4850 100644 --- a/web/docs/engine/guide/examples/__control-by-control.html +++ b/web/docs/engine/guide/examples/__control-by-control.html @@ -10,7 +10,7 @@ - - - - ``` -- File: [laokeys_load.js](js/laokeys_load.js) - And finally, include the control img for KeymanWeb: ```html diff --git a/web/src/engine/src/main/keyboardInterfaceBase.ts b/web/src/engine/src/main/keyboardInterfaceBase.ts index 4f83056103..fbf1d20f24 100644 --- a/web/src/engine/src/main/keyboardInterfaceBase.ts +++ b/web/src/engine/src/main/keyboardInterfaceBase.ts @@ -85,7 +85,6 @@ export class KeyboardInterfaceBase Date: Tue, 9 Jun 2026 13:01:55 -0500 Subject: [PATCH 031/148] auto: increment master version to 19.0.245 Test-bot: skip Build-bot: skip --- HISTORY.md | 7 +++++++ VERSION.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 53fbcf5934..436ef8c5f5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,12 @@ # Keyman Version History +## 19.0.244 alpha 2026-06-09 + +* refactor(web): extract function, rename variables (#16063) +* refactor(web): reformatting of files (#16066) +* chore(web): improve the KeymanWeb integration documentation (#16064) +* chore(web): more improvements to documentation (#16070) + ## 19.0.243 alpha 2026-06-08 * fix(developer): reduce km_core_keyboard_attrs size to 12 (#16075) diff --git a/VERSION.md b/VERSION.md index 9cfd5bdf98..fdf064ce16 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.244 \ No newline at end of file +19.0.245 \ No newline at end of file From a005e45d25c438d55d577ef3a22dbc22a7f4ff3b Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 9 Jun 2026 20:58:12 +0200 Subject: [PATCH 032/148] chore: Apply suggestions from code review Co-authored-by: rc-swag <58423624+rc-swag@users.noreply.github.com> --- .../src/tike/compile/Keyman.Developer.System.KmcWrapper.pas | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/developer/src/tike/compile/Keyman.Developer.System.KmcWrapper.pas b/developer/src/tike/compile/Keyman.Developer.System.KmcWrapper.pas index f64e961b15..6c33760ffc 100644 --- a/developer/src/tike/compile/Keyman.Developer.System.KmcWrapper.pas +++ b/developer/src/tike/compile/Keyman.Developer.System.KmcWrapper.pas @@ -63,7 +63,7 @@ begin else cmdline := cmdline + ['--no-compiler-warnings-as-errors']; - if not FGlobalProject.Options.WarnDeprecatedCode then + if Assigned(FGlobalProject) and (not FGlobalProject.Options.WarnDeprecatedCode) then cmdline := cmdline + ['--no-warn-deprecated-code']; if debug then From a47fd7d36584d593f44096bc3c32e2805354b0a9 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 10 Jun 2026 05:43:42 +0200 Subject: [PATCH 033/148] fix: use meson subsystem and needs_exe_wrapper for cross builds on Windows Relates-to: mesonbuild/meson#15840 Fixes: #15753 Test-bot: skip --- .github/workflows/core-arm64-windows-test.yml | 2 +- core/cross-arm64.build | 1 + core/cross-x64.build | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-arm64-windows-test.yml b/.github/workflows/core-arm64-windows-test.yml index 0eb4e8f8f6..76d273de22 100644 --- a/.github/workflows/core-arm64-windows-test.yml +++ b/.github/workflows/core-arm64-windows-test.yml @@ -119,7 +119,7 @@ jobs: - name: Install meson shell: bash run: | - pip3 install --user meson==1.10.1 + pip3 install --user meson==1.11.0 - name: Run test shell: bash diff --git a/core/cross-arm64.build b/core/cross-arm64.build index 9fd7cc6d14..7bea64992e 100644 --- a/core/cross-arm64.build +++ b/core/cross-arm64.build @@ -1,5 +1,6 @@ [host_machine] system = 'windows' +subsystem = 'windows' cpu_family = 'aarch64' cpu = 'arm64' endian = 'little' diff --git a/core/cross-x64.build b/core/cross-x64.build index df3ab0b5dc..ecc0486e08 100644 --- a/core/cross-x64.build +++ b/core/cross-x64.build @@ -1,5 +1,6 @@ [host_machine] system = 'windows' +subsystem = 'windows' cpu_family = 'x86_64' cpu = 'x86_64' endian = 'little' @@ -11,3 +12,6 @@ ar = 'lib' ld = 'link' strip = 'llvm-strip' pkgconfig = 'pkg-config' + +[properties] +needs_exe_wrapper = false From 6cd27503fe1c08390f03f21ef9b58075c61975d9 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 10 Jun 2026 10:28:09 +0200 Subject: [PATCH 034/148] docs(windows): add context documentation for additional Advanced options Add Base Keyboard, Proxy Settings, and Keyman System Settings documentation and links to the Options context help. Fixes: keymanapp/help.keyman.com#10 Test-bot: skip --- windows/docs/help/basic/config/options.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/windows/docs/help/basic/config/options.md b/windows/docs/help/basic/config/options.md index 3b4bbd86b0..f4084f6465 100644 --- a/windows/docs/help/basic/config/options.md +++ b/windows/docs/help/basic/config/options.md @@ -180,6 +180,28 @@ To open the Options tab of Keyman Configuration: log on for short and isolated tests as specifically requested by Keyman Technical Support. +- Proxy Settings + + In some environments, you may need to configure proxy settings in + order to download keyboards or updates for Keyman. Check with your + network administrator. More information is available in the + [Proxy Configuration](../../advanced/proxy_config) documentation. + +- Keyman System Settings + + The Keyman System Settings dialog provides access to various low-level + Keyman configuration settings. Generally, these settings do not need + to be changed, unless you instructed to do so by Keyman support staff. + +- Base Keyboard + + The Base Keyboard dialog allows you to select the physical Latin-script + layout of your keyboard, which will allow certain Keyman keyboards to remap + themselves dynamically to match your key caps. + + [Base Keyboard dialog](../../context/base-keyboard) + + ## Related Topics - [Keyman Configuration](../config/) From 624e076fe7e46513742e1d167eb5beb7feedb056 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Wed, 10 Jun 2026 13:02:19 -0500 Subject: [PATCH 035/148] auto: increment master version to 19.0.246 Test-bot: skip Build-bot: skip --- HISTORY.md | 7 +++++++ VERSION.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 436ef8c5f5..fa463aa2a4 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,12 @@ # Keyman Version History +## 19.0.245 alpha 2026-06-10 + +* chore(web): hide KMX keyboard test page (#16083) +* docs(web): remove unnecessary mentioning of Keyman 17 from docs (#16085) +* chore(web): remove outdated `*_load.js` files (#16086) +* fix(web): fix `KeyboardStub.validateForCustomKeyboard` (#16067) + ## 19.0.244 alpha 2026-06-09 * refactor(web): extract function, rename variables (#16063) diff --git a/VERSION.md b/VERSION.md index fdf064ce16..825791dd34 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.245 \ No newline at end of file +19.0.246 \ No newline at end of file From 389586fbdc65ddc4465c690cdbb9656a20b63f5b Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 12 Jun 2026 08:32:19 -0500 Subject: [PATCH 036/148] change(web): cover .deleteLeft in unit testing --- .../src/main/correction/context-token.ts | 2 +- .../determine-suggestion-range.tests.ts | 42 +++++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts index ce1d8f9830..e21b9709b0 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts @@ -37,7 +37,7 @@ function textToCharTransforms(text: string, transformId?: number): Transform[] { /** - * Implements an interface similar to ContextToken that is useful for handling + * Defines an interface compatible with ContextToken that is useful for handling * cases that should not be considered correctable. */ export interface ContextTokenLike { diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-range.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-range.tests.ts index e18174895e..a6b5bcfaeb 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-range.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-range.tests.ts @@ -107,7 +107,10 @@ function buildQuickBrownFixture() { null ); + const deleteLeftCalc = (tokens: ContextToken[]) => tokens.reduce((accum, curr) => accum + curr.codepointLength, 0); + return { + deleteLeftCalc, baseTokenization, variations: { noChange: { @@ -115,7 +118,8 @@ function buildQuickBrownFixture() { tokenization: baseTokenization, range: { tokensToRemove: [baseTokenization.tail], - tokensToPredict: [baseTokenization.tail] + tokensToPredict: [baseTokenization.tail], + deleteLeft: deleteLeftCalc([baseTokenization.tail]) } }, plainInsert: { @@ -123,7 +127,8 @@ function buildQuickBrownFixture() { tokenization: plainInsertTokenization, range: { tokensToRemove: [baseTokenization.tail], - tokensToPredict: [plainInsertTokenization.tail] + tokensToPredict: [plainInsertTokenization.tail], + deleteLeft: deleteLeftCalc([baseTokenization.tail]) } }, newTokenInsert: { @@ -131,7 +136,8 @@ function buildQuickBrownFixture() { tokenization: newTokenInsertTokenization, range: { tokensToRemove: [] as ContextToken[], - tokensToPredict: [newTokenInsertTokenization.tail] + tokensToPredict: [newTokenInsertTokenization.tail], + deleteLeft: deleteLeftCalc([]) } }, charReplace: { @@ -139,7 +145,8 @@ function buildQuickBrownFixture() { tokenization: charReplaceTokenization, range: { tokensToRemove: [baseTokenization.tail], - tokensToPredict: [charReplaceTokenization.tail] + tokensToPredict: [charReplaceTokenization.tail], + deleteLeft: deleteLeftCalc([baseTokenization.tail]) } }, eraseToken: { @@ -147,7 +154,8 @@ function buildQuickBrownFixture() { tokenization: eraseTokenTokenization, range: { tokensToRemove: [baseTokenization.tail], - tokensToPredict: [eraseTokenTokenization.tail] + tokensToPredict: [eraseTokenTokenization.tail], + deleteLeft: deleteLeftCalc([baseTokenization.tail]) } }, del5Insert5: { @@ -155,7 +163,8 @@ function buildQuickBrownFixture() { tokenization: del5Insert5Tokenization, range: { tokensToRemove: baseTokenization.tokens.slice(baseTokenCount-3), - tokensToPredict: [del5Insert5Tokenization.tail] + tokensToPredict: [del5Insert5Tokenization.tail], + deleteLeft: deleteLeftCalc(baseTokenization.tokens.slice(baseTokenCount-3)) } }, deleteToBound: { @@ -163,7 +172,8 @@ function buildQuickBrownFixture() { tokenization: deleteToBoundTokenization, range: { tokensToRemove: baseTokenization.tokens.slice(baseTokenCount-3), - tokensToPredict: [deleteToBoundTokenization.tail] + tokensToPredict: [deleteToBoundTokenization.tail], + deleteLeft: deleteLeftCalc(baseTokenization.tokens.slice(baseTokenCount-3)) } } } @@ -181,6 +191,7 @@ describe('determineSuggestionRange', () => { assert.sameOrderedMembers(analysis.tokensToRemove, noChange.range.tokensToRemove); assert.sameOrderedMembers(analysis.tokensToPredict, noChange.range.tokensToPredict); + assert.equal(analysis.deleteLeft, noChange.range.deleteLeft); }); it('adjusts the final token after a simple same-token insert', () => { @@ -191,6 +202,7 @@ describe('determineSuggestionRange', () => { assert.sameOrderedMembers(analysis.tokensToRemove, plainInsert.range.tokensToRemove); assert.sameOrderedMembers(analysis.tokensToPredict, plainInsert.range.tokensToPredict); + assert.equal(analysis.deleteLeft, plainInsert.range.deleteLeft); }); it('adjusts the final token after a simple word-breaking insert', () => { @@ -201,6 +213,7 @@ describe('determineSuggestionRange', () => { assert.sameOrderedMembers(analysis.tokensToRemove, newTokenInsert.range.tokensToRemove); assert.sameOrderedMembers(analysis.tokensToPredict, newTokenInsert.range.tokensToPredict); + assert.equal(analysis.deleteLeft, newTokenInsert.range.deleteLeft); }); it('adjusts the final token after a simple same-token character replacement', () => { @@ -211,6 +224,7 @@ describe('determineSuggestionRange', () => { assert.sameOrderedMembers(analysis.tokensToRemove, charReplace.range.tokensToRemove); assert.sameOrderedMembers(analysis.tokensToPredict, charReplace.range.tokensToPredict); + assert.equal(analysis.deleteLeft, charReplace.range.deleteLeft); }); it('handles deletion of two tokens + alteration of the token before', () => { @@ -221,6 +235,7 @@ describe('determineSuggestionRange', () => { assert.sameOrderedMembers(analysis.tokensToRemove, del5Insert5.range.tokensToRemove); assert.sameOrderedMembers(analysis.tokensToPredict, del5Insert5.range.tokensToPredict); + assert.equal(analysis.deleteLeft, del5Insert5.range.deleteLeft); }); it('handles deletion of chars up to closest whitespace', () => { @@ -231,6 +246,7 @@ describe('determineSuggestionRange', () => { assert.sameOrderedMembers(analysis.tokensToRemove, eraseToken.range.tokensToRemove); assert.sameOrderedMembers(analysis.tokensToPredict, eraseToken.range.tokensToPredict); + assert.equal(analysis.deleteLeft, eraseToken.range.deleteLeft); }); it('handles deletion up to boundary of ancestor non-whitespace token', () => { @@ -241,10 +257,11 @@ describe('determineSuggestionRange', () => { assert.sameOrderedMembers(analysis.tokensToRemove, deleteToBound.range.tokensToRemove); assert.sameOrderedMembers(analysis.tokensToPredict, deleteToBound.range.tokensToPredict); + assert.equal(analysis.deleteLeft, deleteToBound.range.deleteLeft); }); it('handles large variation in intermediate tokens', () => { - const originalQuickBrownTokenization = buildQuickBrownFixture().baseTokenization; + const { deleteLeftCalc, baseTokenization: originalQuickBrownTokenization } = buildQuickBrownFixture(); const rawText = ['beyond', ' ', 'the', ' ', 'hungry', ' ', 'green', ' ', 'alligator']; // the quick brown fox jumped | // Final whitespace is immediately before index 10. @@ -267,10 +284,15 @@ describe('determineSuggestionRange', () => { analysis.tokensToPredict, tokensToAppend ); + + // TODO: Once we allow multiple tokens to contribute to deleteLeft, replace + // RHS with + // originalQuickBrownTokenization.tokens.slice(transitionSliceIndex). + assert.equal(analysis.deleteLeft, deleteLeftCalc([originalQuickBrownTokenization.tail])); }); it('handles insertion of many extra new tokens at once', () => { - const originalQuickBrownTokenization = buildQuickBrownFixture().baseTokenization; + const { deleteLeftCalc, baseTokenization: originalQuickBrownTokenization } = buildQuickBrownFixture(); const originalTokenCount = originalQuickBrownTokenization.tokens.length; const rawText = ['dogs', ' ', 'and', ' ', 'the', ' ', 'sleeping', ' ', 'cat']; const tokensToAppend = rawText.map((t) => ContextToken.fromRawText(plainModel, t, false)); @@ -291,5 +313,7 @@ describe('determineSuggestionRange', () => { analysis.tokensToPredict, tokensToAppend ); + + assert.equal(analysis.deleteLeft, deleteLeftCalc([originalQuickBrownTokenization.tail])); }); }); \ No newline at end of file From e9a85dfd2f8f3bf6bcc0ade07a1cfee06ecc00b2 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 15 Jun 2026 13:02:00 -0500 Subject: [PATCH 037/148] auto: increment master version to 19.0.247 Test-bot: skip Build-bot: skip --- HISTORY.md | 6 ++++++ VERSION.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index fa463aa2a4..4ef4e13561 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,11 @@ # Keyman Version History +## 19.0.246 alpha 2026-06-15 + +* feat(developer): run project validation from IDE (#16076) +* fix(core): use meson subsystem and needs_exe_wrapper for cross builds on Windows (#16088) +* docs(windows): add context documentation for additional Advanced options (#16089) + ## 19.0.245 alpha 2026-06-10 * chore(web): hide KMX keyboard test page (#16083) diff --git a/VERSION.md b/VERSION.md index 825791dd34..5c233ca148 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.246 \ No newline at end of file +19.0.247 \ No newline at end of file From 72a17703216109d1e1852340fbfb126ef7545755 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 16 Jun 2026 10:33:34 +0200 Subject: [PATCH 038/148] chore: update multi-labeler to 5.0.0 Also update actions/labeler to 6.1.0. This moves to node v24. Relates-to: keymanapp/s.keyman.com#1607 --- .github/workflows/labeler.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index ee7fe7d03d..9eb25a49e7 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -8,12 +8,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Update labels based on changed files - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - name: Update labels based on PR title id: labeler - uses: fuxingloh/multi-labeler@f5bd7323b53b0833c1e4ed8d7b797ae995ef75b4 # v2.0.1 + uses: fuxingloh/multi-labeler@bcd50af464202999e57f556b4aefcf05a34abf85 # v5.0.0 with: github-token: ${{secrets.GITHUB_TOKEN}} config-path: .github/multi-labeler.yml From 06eb7dee930b5e1f01a3cb99e02d6cbd95417475 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 16 Jun 2026 13:01:42 -0500 Subject: [PATCH 039/148] auto: increment master version to 19.0.248 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 4ef4e13561..f2d3cab56b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.247 alpha 2026-06-16 + +* chore: update multi-labeler to 5.0.0 (#16100) + ## 19.0.246 alpha 2026-06-15 * feat(developer): run project validation from IDE (#16076) diff --git a/VERSION.md b/VERSION.md index 5c233ca148..a0b5f4d5f2 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.247 \ No newline at end of file +19.0.248 \ No newline at end of file From bda4b50e2a7e7e60e8d8f232b482571eeacb310f Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Fri, 29 May 2026 19:31:00 +0200 Subject: [PATCH 040/148] test(web): add e2e tests for examples from guide Build-bot: skip build:web Test-bot: skip --- web/build.sh | 15 + .../engine/guide/examples/__auto-control.html | 4 +- .../guide/examples/__full-manual-control.html | 8 +- .../guide/examples/__manual-control.html | 6 +- .../guide/examples/full-manual-control.md | 8 +- .../engine/guide/examples/manual-control.md | 7 +- web/src/test/auto/e2e/e2eUtils.ts | 64 +++++ web/src/test/auto/e2e/guide-examples.tests.ts | 256 ++++++++++++++++++ 8 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 web/src/test/auto/e2e/e2eUtils.ts create mode 100644 web/src/test/auto/e2e/guide-examples.tests.ts diff --git a/web/build.sh b/web/build.sh index ca253ab82c..4154c6203c 100755 --- a/web/build.sh +++ b/web/build.sh @@ -114,6 +114,21 @@ build_tests_action() { cp "${KEYMAN_ROOT}/web/src/test/auto/dom/cases/attachment/textStoreForElement.tests.html" \ "${KEYMAN_ROOT}/web/build/test/dom/cases/attachment/" + + # Copy and update guide examples - for local, PR, and test builds we + # replace the CDN URL with the local build path, so that we can test + # against the current build + mkdir -p "${KEYMAN_ROOT}/web/build/docs/engine/guide" + cp -r "${KEYMAN_ROOT}/web/docs/engine/guide/examples" \ + "${KEYMAN_ROOT}/web/build/docs/engine/guide/" + + # shellcheck disable=SC2310 + if ! builder_is_ci_release_build; then + for f in "${KEYMAN_ROOT}/web/build/docs/engine/guide/examples"/*.html; do + sed "s|https://s\.keyman\.com/kmw/engine/[0-9]*\.[0-9]*\.[0-9]*/|/build/publish/${config}/|g" \ + "${f}" > "${f}.tmp" && mv "${f}.tmp" "${f}" + done + fi } coverage_action() { diff --git a/web/docs/engine/guide/examples/__auto-control.html b/web/docs/engine/guide/examples/__auto-control.html index c2a8b472b0..76ff91cf98 100644 --- a/web/docs/engine/guide/examples/__auto-control.html +++ b/web/docs/engine/guide/examples/__auto-control.html @@ -19,8 +19,8 @@

Automatic Mode Example

-

-

+

+

Back to Document diff --git a/web/docs/engine/guide/examples/__full-manual-control.html b/web/docs/engine/guide/examples/__full-manual-control.html index 6313c13518..b862184ff0 100644 --- a/web/docs/engine/guide/examples/__full-manual-control.html +++ b/web/docs/engine/guide/examples/__full-manual-control.html @@ -24,13 +24,13 @@ keyman.init().then(async function() { } document.f.multilingual.focus(); - keyman.setActiveKeyboard('', ''); + await keyman.setActiveKeyboard('', ''); }); -function KWControlChange() { +async function KWControlChange() { var name = KWControl.value.substr(0, KWControl.value.indexOf("$$")); var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$") + 2); - keyman.setActiveKeyboard(name, languageCode); + await keyman.setActiveKeyboard(name, languageCode); document.f.multilingual.focus(); } @@ -40,7 +40,7 @@ function KWControlChange() {

Manual Control - Custom Interface

-

Keyboard:

+

diff --git a/web/docs/engine/guide/examples/__manual-control.html b/web/docs/engine/guide/examples/__manual-control.html index a1cfa72c83..f3c1b7a80e 100644 --- a/web/docs/engine/guide/examples/__manual-control.html +++ b/web/docs/engine/guide/examples/__manual-control.html @@ -11,7 +11,7 @@ languages: { id: 'lo', name: 'Lao' }, filename: "./js/laokeys.js" }); - keyman.setActiveKeyboard('laokeys'); + await keyman.setActiveKeyboard('laokeys'); keyman.osk.hide(); }); @@ -30,8 +30,8 @@

KeymanWeb

-

-

+

+

Back to Document diff --git a/web/docs/engine/guide/examples/full-manual-control.md b/web/docs/engine/guide/examples/full-manual-control.md index 39be4d34b3..98d4236f4d 100644 --- a/web/docs/engine/guide/examples/full-manual-control.md +++ b/web/docs/engine/guide/examples/full-manual-control.md @@ -28,15 +28,15 @@ Include the following script in the HEAD of your page: } document.f.multilingual.focus(); - keyman.setActiveKeyboard('', ''); + await keyman.setActiveKeyboard('', ''); }); /* KWControlChange: Called when user selects an item in the KWControl SELECT */ - function KWControlChange() { + async function KWControlChange() { /* Select the keyboard in KeymanWeb */ var name = KWControl.value.substr(0, KWControl.value.indexOf("$$")); - var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$"+2)); - keyman.setActiveKeyboard(name, languageCode); + var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$") + 2); + await keyman.setActiveKeyboard(name, languageCode); /* Focus onto the multilingual field in the form */ document.f.multilingual.focus(); } diff --git a/web/docs/engine/guide/examples/manual-control.md b/web/docs/engine/guide/examples/manual-control.md index d67db736e9..8675949936 100644 --- a/web/docs/engine/guide/examples/manual-control.md +++ b/web/docs/engine/guide/examples/manual-control.md @@ -2,7 +2,10 @@ title: Manual Mode Example --- -In this example, the web page designer specifies when KeymanWeb's on-screen keyboard may be displayed on non-mobile devices. They have also specified that the LaoKeys keyboard should be activated by default. This example continues to use the KeymanWeb default interface. Please click [this link](__manual-control.html) to open the test page. +In this example, the web page designer specifies when KeymanWeb's on-screen keyboard may be +displayed on non-mobile devices. They have also specified that the LaoKeys keyboard should be +activated by default. This example continues to use the KeymanWeb default interface. Please click +[this link](__manual-control.html) to open the test page. ## Code Walkthrough @@ -17,7 +20,7 @@ Include the following script in the HEAD of your page: languages:{id:'lo',name:'Lao'}, filename: "./js/laokeys.js" }); - keyman.setActiveKeyboard('laokeys'); + await keyman.setActiveKeyboard('laokeys'); keyman.osk.hide(); }); diff --git a/web/src/test/auto/e2e/e2eUtils.ts b/web/src/test/auto/e2e/e2eUtils.ts new file mode 100644 index 0000000000..0ef45ffec0 --- /dev/null +++ b/web/src/test/auto/e2e/e2eUtils.ts @@ -0,0 +1,64 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { type Locator, type Page } from "@playwright/test"; + +/** + * Expands the keyboard selection menu and returns the text content of the + * currently selected keyboard. + */ +export async function getSelectedKeyboardMenuText(page: Page): Promise { + const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); + await page.getByRole('img', { name: 'Use Web Keyboard' }).click(); + await watchDog; + return page.evaluate(() => { + const selectedKbd = document.querySelector('#kmwico .selected'); + return selectedKbd?.textContent; + }); +}; + +/** + * Expands the keyboard selection menu and returns the menu items as an array + */ +export async function getAllKeyboardMenuText(page: Page): Promise<(string|undefined)[]> { + const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); + await page.getByRole('img', { name: 'Use Web Keyboard' }).hover(); + await watchDog; + return page.evaluate(() => { + const menuItems = []; + const menuDiv = document.querySelector('#kmwico'); + const kbdList = menuDiv?.lastElementChild; + for (let i = 0; i < (kbdList ? kbdList.children.length : 0); i++) { + const item = kbdList?.children[i]; + menuItems.push(item?.textContent); + } + return menuItems; + }); +} + +/** + * Loads the specified URL and waits for the page load event. + */ +export async function loadPage(page: Page, url: string): Promise { + const loadPromise = page.waitForEvent('load'); + await page.goto(url); + return loadPromise; +} + +/** + * Clicks the specified field and waits for the OSK to be shown, returning a + * locator for the OSK title bar. + */ +export async function clickFieldAndWaitForOSK(page: Page, fieldLocator: Locator): Promise { + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await fieldLocator.click(); + await keyboardchangePromise; + return page.locator('#keymanweb_title_bar'); +} diff --git a/web/src/test/auto/e2e/guide-examples.tests.ts b/web/src/test/auto/e2e/guide-examples.tests.ts new file mode 100644 index 0000000000..19f2fb4bf8 --- /dev/null +++ b/web/src/test/auto/e2e/guide-examples.tests.ts @@ -0,0 +1,256 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ +import { test, expect, type Page } from '@playwright/test'; +import { clickFieldAndWaitForOSK, getAllKeyboardMenuText, getSelectedKeyboardMenuText, loadPage } from './e2eUtils'; + +async function setTimeoutAndLoadPage(page: Page, url: string): Promise { + test.setTimeout(5000); + await loadPage(page, url); +} + +test.describe('First example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__first-example.html'); + } + + test('Input field shows US keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + const oskTitleBar = await clickFieldAndWaitForOSK(page, page.getByPlaceholder('Hello World')); + + // Verify OSK shows US keyboard + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(oskTitleBar).toContainText('US'); + + await expect(await getSelectedKeyboardMenuText(page)).toBe('English - US'); + }); + + test('Keyman menu has expected keyboards', async ({ page }) => { + // Setup + await beforeEach(page); + await clickFieldAndWaitForOSK(page, page.getByPlaceholder('Hello World')); + + // Verify OSK menu has expected entries + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + await expect(await getAllKeyboardMenuText(page)).toEqual(['(System keyboard)', 'English - US', 'Thai - Thai Kedmanee Basic']) + }); +}); + +test.describe('Auto-control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__auto-control.html'); + } + + test('Input field shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('multilingual' ).click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + }); + + test('Textarea shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('textarea').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + }); +}); + +test.describe('Control-by-control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__control-by-control.html'); + } + + test('address field does not have KeymanWeb enabled', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = address').click(); + + // Verify OSK is not shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeFalsy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).not.toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); + }); + + // TODO: #16080 + test.skip('subject field does not show keyboard and defaults to system keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = subject').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); + + await expect(await getSelectedKeyboardMenuText(page)).toBe('(System keyboard)'); + }); + + test('message body field shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = text').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + + // Verify Lao (Phonetic) keyboard is active + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + // Verify "Lao - Lao (Phonetic)" is selected (bold) in the menu + await expect(await getSelectedKeyboardMenuText(page)).toBe('Lao - Lao (Phonetic)'); + }); +}); + +test.describe('Full manual control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__full-manual-control.html'); + } + + test('Shows English and no OSK after loading page', async ({ page }) => { + // Setup + await beforeEach(page); + + // Verify 'English' selected (which has the value '') and no OSK showing + await expect(page.getByLabel('Keyboard')).toHaveValue(''); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Selecting English keyboard shows no OSK', async ({ page }) => { + // Setup + await beforeEach(page); + // first switch to Hebrew + let keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Hebrew'); + await keyboardchangePromise; + + // then back to English + keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('English'); + await keyboardchangePromise; + + // Verify no OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Selecting Devanagari keyboard shows Devanagari OSK', async ({ page }) => { + // Setup + await beforeEach(page); + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Devanagari (INSCRIPT)'); + await keyboardchangePromise; + + // Verify Devanagari OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Devanagari (INSCRIPT)'); + }); + + test('Selecting Hebrew shows Hebrew OSK', async ({ page }) => { + // Setup + await beforeEach(page); + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Hebrew'); + await keyboardchangePromise; + + // Verify Hebrew OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Hebrew'); + }); +}); + +test.describe('Manual control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__manual-control.html'); + } + + test('Does not show OSK after loading', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('multilingual').click(); + + // Verify no OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).not.toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Shows Lao OSK after clicking button', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByAltText('KeymanWeb').click(); + await page.getByTestId('multilingual').click(); + + // Verify Lao OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao'); + }); + + test('Hides Lao OSK after clicking button', async ({ page }) => { + // Setup + await beforeEach(page); + + // click button + await page.getByAltText('KeymanWeb').click(); + + // Verify Lao OSK showing + await page.getByTestId('multilingual').click(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao'); + + // Click button again to hide OSK + await page.getByAltText('KeymanWeb').click(); + + // Verify Lao OSK not showing + await page.getByTestId('multilingual').click(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).not.toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + + }); +}); + From 095ee327ae625c3f305bcf760b2be886399138f8 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 22 Jun 2026 08:36:47 -0500 Subject: [PATCH 041/148] docs(web): adjust comments per review --- .../worker-thread/src/main/predict-helpers.ts | 4 +--- .../predict-from-correction-sequence.tests.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index c61f6346ca..a417dadba8 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -663,7 +663,7 @@ export function shouldStopSearchingEarly( * Given a generated set of corrections from the correction-search process, this * function searches the lexical model for valid predictions rooted on each. * - * While doing so, it also associates each prediction with metadata used for to + * While doing so, it also associates each prediction with metadata used to * "rank" and select the best predictions once the search is complete. This is * performed at later stages. * @param lexicalModel @@ -689,8 +689,6 @@ export function predictFromCorrectionSequence( for(let i = 0; i < corrections.length; i++) { const correction = corrections[i].sample; - - // Step 2: predict based on the final token. const predictions = lexicalModel.predict(correction, currentContext); // Failsafe: if there are no matching predictions, create a fake prediction diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-correction-sequence.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-correction-sequence.tests.ts index 64cea5b230..6446087009 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-correction-sequence.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-correction-sequence.tests.ts @@ -1,3 +1,12 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-04-13 + * + * This file unit tests against the `predictFromCorrectionSequence` + * prediction-helper function, validating construction of predictions based on + * their root correction sequences. + */ import { assert } from 'chai'; @@ -41,7 +50,7 @@ const applyCasing: CasingFunction = (casing, text) => { // Capitalizes the first code unit of the string, leaving the rest intact. return text.substring(0, headUnitLength).toUpperCase() // head - uppercased - .concat(text.substring(headUnitLength)); // tail - lowercased + .concat(text.substring(headUnitLength)); // tail - unchanged } }; From b614411045fb2aa62e882ce3e719c5cf3e46c032 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 22 Jun 2026 15:48:19 +0200 Subject: [PATCH 042/148] fix(developer): map shift key nextlayer property when importing OSK Fixes: #14227 Test-bot: skip --- ...tem.VisualKeyboardToTouchLayoutConverter.pas | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas b/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas index 5f19d98a2a..4f3b9d4135 100644 --- a/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas +++ b/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas @@ -319,6 +319,23 @@ function TVisualKeyboardToTouchLayoutConverter.SetupModifierKeysForImportedLayou for l in p.Layers do begin + // Find the Shift key and assign the next layer + k := l.FindKeyById('K_SHIFT'); + if Assigned(k) then + begin + if l.id = 'default' then + begin + k.NextLayer := 'shift'; + end + else + begin + // All layers other than default will return to default layer + // when shift is pressed, because we do not currently map + // shift+other mod layers with use of the Shift key in the import + k.NextLayer := 'default'; + end; + end; + // Find the Ctrl key for the layer k := l.FindKeyById('K_LCONTROL'); if not Assigned(k) then From ed15bf89c6f5fe631c3defc0252439d497b2ee11 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 22 Jun 2026 10:14:14 -0500 Subject: [PATCH 043/148] docs(web): document TokenizationCorrector fields, rearrange into proper groups --- .../src/main/correction/tokenization-corrector.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-corrector.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-corrector.ts index 835f9eef38..c1e3173d52 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-corrector.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-corrector.ts @@ -47,8 +47,16 @@ export type TokenResult = { * range. */ export class TokenizationCorrector implements CorrectionSearchable, TokenizationResultMapping> { + /** + * The root ContextTokenization all generated corrections are based upon. + */ public readonly tokenization: ContextTokenization; - private readonly tailCorrectionLength: number; + + /** + * Indicates whether or not the correction range of this TokenizationCorrector + * started with any tokens considered "correctable". + */ + public readonly modelsCorrectables: boolean; // public read-only via properties private readonly _uncorrectables: QuotientNodeFinalizer[]; @@ -58,13 +66,13 @@ export class TokenizationCorrector implements CorrectionSearchable; private tokenCostMap: Map; private tokenLookupMap: Map; private lastTotalCost: number; private handleHasBeenCalled: boolean = false; private predictableMatchFound: boolean = false; + private readonly tailCorrectionLength: number; get currentCost(): number { const correctable = this.selectionQueue.peek(); From 647a9ebc98fb3da1276ff338f427256706d59132 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 22 Jun 2026 17:17:18 +0200 Subject: [PATCH 044/148] fix(developer): prevent clone of legacy keyboards with no source If a Keyman Cloud keyboard is legacy and has no source available, make this more obvious to the author when they attempt to clone it. Add also messages to help the author complete the other required steps to clone a keyboard. Fixes: #15606 Test-bot: skip --- .../delphi/general/Upload_Settings.pas | 9 + ....UfrmCloneKeymanCloudProjectParameters.dfm | 246 +++++++++--------- ....UfrmCloneKeymanCloudProjectParameters.pas | 134 +++++++++- 3 files changed, 269 insertions(+), 120 deletions(-) diff --git a/common/windows/delphi/general/Upload_Settings.pas b/common/windows/delphi/general/Upload_Settings.pas index 8811ec6f10..2763a042be 100644 --- a/common/windows/delphi/general/Upload_Settings.pas +++ b/common/windows/delphi/general/Upload_Settings.pas @@ -86,6 +86,8 @@ function API_UserAgent: string; // = 'Keyman for Windows/...' function API_UserAgent_Developer: string; // = 'Keyman Developer/...' function API_UserAgent_Diagnostics: string; +function API_Path_Keyboard(const id: string): string; + function KeymanCom_Protocol_Server: string; // = 'https://keyman.com'; function MakeAPIURL(path: string): string; @@ -121,6 +123,8 @@ const S_KeymanCom_Staging = 'https://keyman.com'; // #7227 disabling: 'https://keyman-staging.com'; S_APIServer_Staging = 'api.keyman.com'; // #7227 disabling: 'api.keyman-staging.com'; + S_API_Path_Keyboard = '/keyboard/%0:s'; + const URLPath_PackageDownload_Format = '/go/package/download/%0:s?platform=windows&tier=%1:s&bcp47=%2:s&update=%3:d'; URL_KeymanDeveloper_HelpKmcMessage_Format = S_Host_KmnSh+'/%0:s'; @@ -187,4 +191,9 @@ begin Result := Format(URL_KeymanDeveloper_HelpKmcMessage_Format, [id.ToLower]); end; +function API_Path_Keyboard(const id: string): string; +begin + Result := Format(S_API_Path_Keyboard, [id]); +end; + end. diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm index fb286bccc3..e06066a2e1 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm @@ -1,119 +1,127 @@ -inherited frmCloneKeymanCloudProjectParameters: TfrmCloneKeymanCloudProjectParameters - BorderIcons = [biSystemMenu, biMaximize] - Caption = 'Clone Project from Keyman Cloud' - ClientHeight = 506 - ClientWidth = 840 - Position = poScreenCenter - ExplicitWidth = 856 - ExplicitHeight = 545 - PixelsPerInch = 96 - TextHeight = 13 - object cmdOK: TButton - Left = 680 - Top = 473 - Width = 73 - Height = 25 - Anchors = [akRight, akBottom] - Caption = 'OK' - Default = True - TabOrder = 2 - OnClick = cmdOKClick - end - object cmdCancel: TButton - Left = 759 - Top = 473 - Width = 73 - Height = 25 - Anchors = [akRight, akBottom] - Cancel = True - Caption = 'Cancel' - ModalResult = 2 - TabOrder = 3 - end - object panWebHost: TPanel - Left = 0 - Top = 0 - Width = 842 - Height = 334 - Anchors = [akLeft, akTop, akRight, akBottom] - BevelOuter = bvNone - TabOrder = 0 - end - object gbNewProjectDetails: TGroupBox - Left = 8 - Top = 340 - Width = 823 - Height = 129 - Anchors = [akLeft, akRight, akBottom] - Caption = 'New Project Details' - TabOrder = 1 - object lblFileName: TLabel - Left = 16 - Top = 24 - Width = 76 - Height = 13 - Caption = '&New project ID:' - FocusControl = editKeyboardID - end - object lblProjectFilename: TLabel - Left = 16 - Top = 102 - Width = 101 - Height = 13 - Caption = 'New project &filename' - FocusControl = editProjectFilename - end - object lblPath: TLabel - Left = 16 - Top = 75 - Width = 83 - Height = 13 - Caption = 'Destination &path:' - FocusControl = editPath - end - object editKeyboardID: TEdit - Left = 148 - Top = 21 - Width = 205 - Height = 21 - TabOrder = 0 - OnChange = editKeyboardIDChange - end - object editProjectFilename: TEdit - Left = 148 - Top = 99 - Width = 669 - Height = 21 - TabStop = False - ParentColor = True - ReadOnly = True - TabOrder = 4 - OnChange = editKeyboardIDChange - end - object cmdBrowse: TButton - Left = 744 - Top = 72 - Width = 73 - Height = 21 - Caption = '&Browse...' - TabOrder = 3 - OnClick = cmdBrowseClick - end - object editPath: TEdit - Left = 148 - Top = 72 - Width = 590 - Height = 21 - TabOrder = 2 - OnChange = editPathChange - end - object chkRelocateExternal: TCheckBox - Left = 148 - Top = 48 - Width = 257 - Height = 17 - Caption = 'Relocate &external files into new project folder' - TabOrder = 1 - end - end -end +inherited frmCloneKeymanCloudProjectParameters: TfrmCloneKeymanCloudProjectParameters + BorderIcons = [biSystemMenu, biMaximize] + Caption = 'Clone Project from Keyman Cloud' + ClientHeight = 506 + ClientWidth = 840 + Position = poScreenCenter + ExplicitWidth = 856 + ExplicitHeight = 545 + PixelsPerInch = 96 + TextHeight = 13 + object lblMessage: TLabel + Left = 8 + Top = 478 + Width = 314 + Height = 13 + Caption = 'The keyboard %0:s has no source available. It cannot be cloned.' + FocusControl = editPath + end + object cmdOK: TButton + Left = 680 + Top = 473 + Width = 73 + Height = 25 + Anchors = [akRight, akBottom] + Caption = 'OK' + Default = True + TabOrder = 2 + OnClick = cmdOKClick + end + object cmdCancel: TButton + Left = 759 + Top = 473 + Width = 73 + Height = 25 + Anchors = [akRight, akBottom] + Cancel = True + Caption = 'Cancel' + ModalResult = 2 + TabOrder = 3 + end + object panWebHost: TPanel + Left = 0 + Top = 0 + Width = 842 + Height = 334 + Anchors = [akLeft, akTop, akRight, akBottom] + BevelOuter = bvNone + TabOrder = 0 + end + object gbNewProjectDetails: TGroupBox + Left = 8 + Top = 340 + Width = 823 + Height = 129 + Anchors = [akLeft, akRight, akBottom] + Caption = 'New Project Details' + TabOrder = 1 + object lblFileName: TLabel + Left = 16 + Top = 24 + Width = 76 + Height = 13 + Caption = '&New project ID:' + FocusControl = editKeyboardID + end + object lblProjectFilename: TLabel + Left = 16 + Top = 102 + Width = 101 + Height = 13 + Caption = 'New project &filename' + FocusControl = editProjectFilename + end + object lblPath: TLabel + Left = 16 + Top = 75 + Width = 83 + Height = 13 + Caption = 'Destination &path:' + FocusControl = editPath + end + object editKeyboardID: TEdit + Left = 148 + Top = 21 + Width = 205 + Height = 21 + TabOrder = 0 + OnChange = editKeyboardIDChange + end + object editProjectFilename: TEdit + Left = 148 + Top = 99 + Width = 669 + Height = 21 + TabStop = False + ParentColor = True + ReadOnly = True + TabOrder = 4 + OnChange = editKeyboardIDChange + end + object cmdBrowse: TButton + Left = 744 + Top = 72 + Width = 73 + Height = 21 + Caption = '&Browse...' + TabOrder = 3 + OnClick = cmdBrowseClick + end + object editPath: TEdit + Left = 148 + Top = 72 + Width = 590 + Height = 21 + TabOrder = 2 + OnChange = editPathChange + end + object chkRelocateExternal: TCheckBox + Left = 148 + Top = 48 + Width = 257 + Height = 17 + Caption = 'Relocate &external files into new project folder' + TabOrder = 1 + end + end +end diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas index c3f09a6406..36014446b0 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas @@ -46,6 +46,7 @@ type cmdBrowse: TButton; editPath: TEdit; chkRelocateExternal: TCheckBox; + lblMessage: TLabel; procedure cmdOKClick(Sender: TObject); procedure editSourceProjectFilenameChange(Sender: TObject); procedure FormCreate(Sender: TObject); @@ -57,6 +58,7 @@ type cef: TframeCEFHost; dlgBrowse: TBrowse4Folder; FKeymanID: string; + FSourceAvailable: Boolean; frmDownloadProgress: TfrmDownloadProgress; function GetBasePath: string; function GetKeyboardID: string; @@ -64,6 +66,7 @@ type procedure EnableControls; procedure SetKeyboardID(const Value: string); procedure UpdateProjectFilename; + procedure UpdateMessage; function GetProjectFilename: string; function GetSourceProjectFilename: string; function GetRelocateExternal: Boolean; @@ -71,6 +74,7 @@ type procedure cefLoadEnd(Sender: TObject); procedure DownloadCallback(Owner: TfrmDownloadProgress; var Result: Boolean); procedure DownloadWrapperCallback(var Cancelled: Boolean); + function IsKeyboardSourceAvailable(const id: string): Boolean; protected function GetHelpTopic: string; override; public @@ -86,9 +90,11 @@ function ShowCloneKeymanCloudProjectParameters(Owner: TComponent): Boolean; implementation uses + System.JSON, System.Net.UrlClient, Vcl.ComCtrls, + HttpUploader, KeymanDeveloperOptions, Keyman.Developer.System.KmcWrapper, Keyman.Developer.System.HelpTopics, @@ -182,6 +188,7 @@ begin dlgBrowse.Root := Desktop; dlgBrowse.Title := 'Select folder to save project to'; + UpdateMessage; EnableControls; end; @@ -201,9 +208,97 @@ begin not u.Path.StartsWith(URLSubPath_KeymanDeveloper_Clone_Keyboards_Custom) then FKeymanID := u.Path.Substring(URLSubPath_KeymanDeveloper_Clone_Keyboards.Length) else FKeymanID := ''; + + FSourceAvailable := IsKeyboardSourceAvailable(FKeymanID); + UpdateMessage; EnableControls; end; +function TfrmCloneKeymanCloudProjectParameters.IsKeyboardSourceAvailable(const id: string): Boolean; + + function GetKeyboardDataFromApiServer(const id: string): string; + var + http: THTTPUploader; + begin + http := THTTPUploader.Create(nil); + try + http.Request.HostName := API_Server; + http.Request.Protocol := API_Protocol; + http.Request.UrlPath := API_Path_Keyboard(id); + try + http.Upload; + except + // Silently swallow network errors + on E:Exception do Exit(''); + end; + + if (http.Response.StatusCode < 200) or (http.Response.StatusCode > 299) then + begin + // Keyboard not found or invalid response + Exit(''); + end; + + Result := UTF8ToString(PAnsiChar(http.Response.MessageBodyAsString)); + finally + FreeAndNil(http); + end; + end; + + function GetSourcePathFromBody(const body: string): string; + var + val: TJSONValue; + obj: TJSONObject; + begin + try + val := TJSONObject.ParseJSONValue(body); + except + // Not a valid response + Exit(''); + end; + + if not (val is TJSONObject) then + begin + // Not a valid response + Exit(''); + end; + + obj := val as TJSONObject; + val := obj.Values['sourcePath']; + if not Assigned(val) or not (val is TJSONString) then + begin + // no sourcePath property + Exit(''); + end; + + Result := (val as TJSONString).Value; + end; + +var + body, sourcePath: string; +begin + if id = '' then + begin + Exit(False); + end; + + body := GetKeyboardDataFromApiServer(id); + if body = '' then + begin + Exit(False); + end; + + sourcePath := GetSourcePathFromBody(body); + if sourcePath = '' then + begin + Exit(False); + end; + + // Keyboards in legacy/ do not have source available. Keyboards in + // release/ and experimental/ have source, and other new categories will + // also have source in future. + Result := not sourcePath.startsWith('legacy'); +end; + procedure TfrmCloneKeymanCloudProjectParameters.cmdBrowseClick(Sender: TObject); begin dlgBrowse.InitialDir := editPath.Text; @@ -221,22 +316,26 @@ end; procedure TfrmCloneKeymanCloudProjectParameters.editKeyboardIDChange(Sender: TObject); begin UpdateProjectFilename; + UpdateMessage; EnableControls; end; procedure TfrmCloneKeymanCloudProjectParameters.editSourceProjectFilenameChange(Sender: TObject); begin + UpdateMessage; EnableControls; end; procedure TfrmCloneKeymanCloudProjectParameters.editPathChange(Sender: TObject); begin UpdateProjectFilename; + UpdateMessage; EnableControls; end; procedure TfrmCloneKeymanCloudProjectParameters.editVersionChange(Sender: TObject); begin + UpdateMessage; EnableControls; end; @@ -248,7 +347,8 @@ begin (FKeymanID <> '') and (Trim(editPath.Text) <> '') and (Trim(editKeyboardID.Text) <> '') and - TKeyboardUtils.IsValidKeyboardID(Trim(editKeyboardID.Text), True); + TKeyboardUtils.IsValidKeyboardID(Trim(editKeyboardID.Text), True) and + FSourceAvailable; cmdOK.Enabled := e; end; @@ -292,9 +392,41 @@ end; procedure TfrmCloneKeymanCloudProjectParameters.SetKeyboardID(const Value: string); begin editKeyboardID.Text := Value; + UpdateMessage; EnableControls; end; +procedure TfrmCloneKeymanCloudProjectParameters.UpdateMessage; +var + msg: string; +begin + if FKeymanID = '' then + begin + msg := 'Please choose a keyboard from the search form above.'; + end + else if not FSourceAvailable then + begin + msg := Format('The keyboard %0:s has no source available. It cannot be cloned.', [FKeymanID]); + end + else if Trim(editPath.Text) = '' then + begin + msg := 'A valid destination path must be selected.'; + end + else if Trim(editKeyboardID.Text) = '' then + begin + msg := 'Please enter a valid new project identifier.'; + end + else if not TKeyboardUtils.IsValidKeyboardID(Trim(editKeyboardID.Text), True) then + begin + msg := 'Please enter a valid new project identifier.'; + end + else + begin + msg := ''; + end; + lblMessage.Caption := msg; +end; + procedure TfrmCloneKeymanCloudProjectParameters.UpdateProjectFilename; begin editProjectFilename.Text := From ed422810be7165f10c0c2716e5d289679ff34516 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 22 Jun 2026 10:26:13 -0500 Subject: [PATCH 045/148] change(web): apply suggestions from PR review - A few let -> const - Adds and fixes doc-comments --- .../worker-thread/src/main/predict-helpers.ts | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 6ef5411673..17a6c7fa5a 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -17,7 +17,6 @@ import { TokenResult } from './correction/tokenization-corrector.js'; import { TokenizationCorrector } from './correction/tokenization-corrector.js'; import { TokenizationResultMapping } from './correction/tokenization-result-mapping.js'; -import CasingForm = LexicalModelTypes.CasingForm; import Context = LexicalModelTypes.Context; import Distribution = LexicalModelTypes.Distribution; import Keep = LexicalModelTypes.Keep; @@ -795,12 +794,7 @@ export function shouldStopSearchingEarly( * "rank" and select the best predictions once the search is complete. This is * performed at later stages. * @param lexicalModel - * @param corrections Each `correction` should insert a full token's text to be - * appended to the context resulting from all preceding corrections. - * @param rootContext This context should represent all portions of the - * post-context not represented by the entries of the `corrections` array. - * @param transitionId Indicates the unique ID of the transition that triggered - * prediction generation. + * @param predictionPrep * @returns */ export function predictFromCorrectionSequence( @@ -921,33 +915,28 @@ export function predictFromCorrectionSequence( /** * Applies the specified casing-form to generated suggestions, leveraging the model's * defined casing behaviors to do so. - * @param suggestion - * @param baseWord + * @param predictionToken A tuple representing a single context token's prediction and base correction * @param lexicalModel - * @param casingForm */ export function applySuggestionCasing(predictionToken: TokenizedPredictionData, lexicalModel: LexicalModel) { - const suggestion = predictionToken.prediction; - // Step 0: our pattern for generating predictions and corrections already - // enforces them to encompass the whole word. - - // Step 1: detect the original token's casing - let casingForm: CasingForm; + // enforces that they encompass the whole word. + const suggestion = predictionToken.prediction; // If we are using the context-tracking engine (when traversals are enabled), // we just leverage the context token's exampleInput to determine casing. // // If it's not available, the correction entry reflects a word-broken piece of // the original context, with its original casing - so we use that instead. - let casingRoot = predictionToken.casingRoot ? predictionToken.casingRoot : predictionToken.correction; + const casingRoot = predictionToken.casingRoot ? predictionToken.casingRoot : predictionToken.correction; if(!casingRoot) { // There's no text in place to verify casing expectations; just leave it // unchanged. return; } - casingForm = detectCurrentCasing(lexicalModel, { + // Step 1: detect the original token's casing + const casingForm = detectCurrentCasing(lexicalModel, { left: casingRoot, startOfBuffer: true, endOfBuffer: true @@ -960,6 +949,13 @@ export function applySuggestionCasing(predictionToken: TokenizedPredictionData, } } +/** + * Composites a set of `IntermediateTokenizedPrediction`s, merging the tokenized + * data into corresponding `IntermediateCompositedPrediction`s representing the + * full range of affected context. + * @param predictions + * @returns + */ export function compositeIntermediatePredictions(predictions: IntermediateTokenizedPrediction[]): IntermediateCompositedPrediction[] { return predictions.map((predictionData) => { const components = predictionData.components; From bdc824a9c10633a52098954c41d8c8f20755fafc Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 22 Jun 2026 10:48:26 -0500 Subject: [PATCH 046/148] change(web): rename compositeIntermdiatePredictions to composeIntermediatePredictions --- .../worker-thread/src/main/model-compositor.ts | 4 ++-- .../predictive-text/worker-thread/src/main/predict-helpers.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts index bffa6a73e9..25863a5937 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts @@ -1,7 +1,7 @@ import * as models from '@keymanapp/models-templates'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { applySuggestionCasing, compositeIntermediatePredictions, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js'; +import { applySuggestionCasing, composeIntermediatePredictions, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js'; import { determineModelTokenizer, determineModelWordbreaker, determinePunctuationFromModel } from './model-helpers.js'; import { ContextTracker } from './correction/context-tracker.js'; @@ -151,7 +151,7 @@ export class ModelCompositor { // We want to dedupe before trimming the list so that we can present a full set // of viable distinct suggestions if available. - const deduplicatedSuggestionTuples = dedupeSuggestions(this.lexicalModel, compositeIntermediatePredictions(rawPredictions), context); + const deduplicatedSuggestionTuples = dedupeSuggestions(this.lexicalModel, composeIntermediatePredictions(rawPredictions), context); // Needs "casing" to be applied first. const postContext = postContextState?.context ?? models.applyTransform(inputTransform, context); diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 17a6c7fa5a..f6fdb5b730 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -950,13 +950,13 @@ export function applySuggestionCasing(predictionToken: TokenizedPredictionData, } /** - * Composites a set of `IntermediateTokenizedPrediction`s, merging the tokenized + * Composes a set of `IntermediateTokenizedPrediction`s, merging the tokenized * data into corresponding `IntermediateCompositedPrediction`s representing the * full range of affected context. * @param predictions * @returns */ -export function compositeIntermediatePredictions(predictions: IntermediateTokenizedPrediction[]): IntermediateCompositedPrediction[] { +export function composeIntermediatePredictions(predictions: IntermediateTokenizedPrediction[]): IntermediateCompositedPrediction[] { return predictions.map((predictionData) => { const components = predictionData.components; From b029b1993b66588c6cacdf27fd7c79a48e9290b6 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 22 Jun 2026 13:52:41 -0500 Subject: [PATCH 047/148] fix(web): do not auto-select model-matching keep suggestions Fixes: #12312 Do, however, prevent auto-selection of any other suggestion when a model-matching 'keep' is available. Build-bot: skip build:web build:android build:ios --- .../worker-thread/src/main/predict-helpers.ts | 8 +++++--- .../engine/src/interfaces/prediction/predictionContext.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 9739adba85..2f480e7535 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -965,9 +965,11 @@ export function predictionAutoSelect(suggestionDistribution: CorrectionPredictio const keepOption = suggestionDistribution[0].prediction.sample as Outcome; if(keepOption.tag == 'keep' && keepOption.matchesModel) { - // Auto-select it for auto-acceptance; we don't correct away from perfectly-valid - // lexical entries, even if they are comparatively low-frequency. - keepOption.autoAccept = true; + // Do not auto-select 'keep' suggestions'; there's no need to apply them. + // + // Do, however, block auto-selection of any other suggestions if we would + // have auto-selected the 'keep'; even if it is comparatively unlikely / + // low-frequency, we 'keep' the current context. return; } else if(suggestionDistribution.length == 1) { return; diff --git a/web/src/engine/src/interfaces/prediction/predictionContext.ts b/web/src/engine/src/interfaces/prediction/predictionContext.ts index 97182a9bf1..b374f504fb 100644 --- a/web/src/engine/src/interfaces/prediction/predictionContext.ts +++ b/web/src/engine/src/interfaces/prediction/predictionContext.ts @@ -266,7 +266,7 @@ export class PredictionContext extends EventEmitter { this._revertSuggestion = s as Reversion; } - if (this.langProcessor.mayAutoCorrect && s.autoAccept && !this.selected) { + if (this.langProcessor.mayAutoCorrect && s.autoAccept && !this.selected && s.tag != 'keep') { this.selected = s; } } From 229e535592de66fe5f7e5775ed00590e1c79e570 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 22 Jun 2026 15:30:45 -0500 Subject: [PATCH 048/148] docs(web): add doc-comments for context-tokenization.ts params, types Build-bot: skip build:web Test-bot: skip --- .../main/correction/context-tokenization.ts | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts index f52a174138..dcd7b0a295 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts @@ -27,18 +27,65 @@ import { LegacyQuotientRoot } from './legacy-quotient-root.js'; const MIN_TOKENS_TO_RECONSIDER_FOR_TOKENIZATION = 3; const MIN_CHARS_TO_RECONSIDER_FOR_TOKENIZATION = 8; +/** + * This type is used to indicate properties of tokens affected by merges and + * splits during a context transition. + */ interface EditTokenMap { + /** + * The index of the affected token. + */ index: number, + /** + * The token's most likely represented text. + */ text: string }; +/** + * This type represents mappings for tokens affected by merge edit operations. + */ interface TokenMergeMap { + /** + * Entries here represent source-context tokens that are combined as part of + * the merge edit operation. + * + * Entries will appear in the same ordering as the codepoints they represent + * appear in the underlying context. + */ inputs: EditTokenMap[], + + /** + * This entry represents the post-transition context token produced from the + * merge edit operation. + * + * Note that it is possible for extra codepoints to exist here that were not + * represented in the original source-context tokens. + */ match: EditTokenMap }; + +/** + * This type represents mappings for tokens affected by split edit operations. + */ export interface TokenSplitMap { + /** + * This entry represents the source-context token that was split as part of + * the split edit operation. + * + * Note that it is possible for codepoints to exist here but not be + * represented in the post-transition context tokens resulting from the split. + */ input: EditTokenMap, + + /** + * Entries here represent post-transition tokens that represent pieces of the + * original source-context token affected by the split edit operation. + * + * Entries will appear in the same ordering as the codepoints they represent + * appear in the underlying context. + */ matches: (EditTokenMap & { textOffset: number })[] }; @@ -956,8 +1003,8 @@ export function traceInsertEdits(tokens: string[], transform: Transform): { * Given context tokenizations before and after applying an incoming transform, * this method analyzes the tokenizations in order to make adjustments for any * 'merge' or 'split' edits needed for the transition. - * @param priorTokenization - * @param resultTokenization + * @param priorTokenization The raw-text form of the pre-transition context tokenization + * @param resultTokenization The raw-text form of the post-transition context tokenization * @returns */ export function analyzePathMergesAndSplits(priorTokenization: string[], resultTokenization: string[]): { @@ -1166,7 +1213,9 @@ export function assembleTransforms(stackedInserts: string[], stackedDeletes: num * Used to construct and represent the part of the incoming transform that does * not land as part of the final token in the resulting context. This component * should be preserved by any suggestions that get applied. - * @param tokenizationAnalysis + * @param tokenizationAnalysis Precomputed metadata about a potential transition + * from a pre-transition context tokenization to a post-transition context + * tokenization * @returns */ export function determineTaillessTrueKeystroke(tokenizationAnalysis: TransitionEdge) { From 370928c8e5aa7f638cd64ab50e72ace3e2ea3fd3 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 22 Jun 2026 15:55:03 -0500 Subject: [PATCH 049/148] change(web): clean ContextState unit tests Addresses these comments: - https://github.com/keymanapp/keyman/issues/15265#issuecomment-3636890802 - https://github.com/keymanapp/keyman/issues/15265#issuecomment-3636901217 Build-bot: skip build:web Test-bot: skip --- .../context/context-state.tests.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-state.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-state.tests.ts index 5e57900477..d1346e8214 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-state.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-state.tests.ts @@ -98,14 +98,6 @@ describe('ContextState', () => { let newContextMatch = baseState.analyzeTransition(newContext, toWrapperDistribution(transform)); assert.isNotNull(newContextMatch?.final); assert.deepEqual(newContextMatch.final.tokenization.tokens.map(token => token.exampleInput), rawTokens); - - // // Phrased this way to facilitate TS type-inference; assert.isTrue() does - // // NOT do this for us! - // if(!newContextMatch.final.tokenization.alignment.canAlign) { - // assert.fail("context alignment failed"); - // } - // assert.equal(newContextMatch.final.tokenization.alignment.leadTokenShift, 0); - // assert.equal(newContextMatch.final.tokenization.alignment.tailTokenShift, 0); }); it("properly matches and aligns when no context changes occur (after whitespace)", function() { @@ -125,14 +117,6 @@ describe('ContextState', () => { let newContextMatch = baseState.analyzeTransition(newContext, toWrapperDistribution(transform)); assert.isNotNull(newContextMatch?.final); assert.deepEqual(newContextMatch.final.tokenization.tokens.map(token => token.exampleInput), rawTokens); - - // // Phrased this way to facilitate TS type-inference; assert.isTrue() does - // // NOT do this for us! - // if(!newContextMatch.final.tokenization.alignment.canAlign) { - // assert.fail("context alignment failed"); - // } - // assert.equal(newContextMatch.final.tokenization.alignment.leadTokenShift, 0); - // assert.equal(newContextMatch.final.tokenization.alignment.tailTokenShift, 0); }); it("properly matches and aligns when lead token is removed (end of word)", function() { @@ -437,7 +421,7 @@ describe('ContextState', () => { }); }); -describe('determineContextSlideDeltas', () => { +describe('determineContextSlideTransform', () => { it('finds prefixed text for backward sliding context window', () => { const before: Context = { left: 'ples and bananas', From acf64f1897b5d0c07b415d0e77ec5725fdbecc3c Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:07:38 +1000 Subject: [PATCH 050/148] fix(windows): add update property to remote check This just adds the update property to the HTTP request in the RemoteUpdateCheck call. Fixes: #15433 --- .../src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas | 1 + 1 file changed, 1 insertion(+) diff --git a/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas index dc41ae59bc..0a28e1b828 100644 --- a/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas +++ b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas @@ -158,6 +158,7 @@ begin try http.Fields.Add('version', ansistring(CKeymanVersionInfo.Version)); http.Fields.Add('tier', ansistring(CKeymanVersionInfo.Tier)); + http.Fields.Add('update', '1'); // This is checking for an update if FForce then http.Fields.Add('manual', '1') else From 25a40ab903e04458796be87f12774555d075aee8 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 23 Jun 2026 10:41:40 +0200 Subject: [PATCH 051/148] fix(developer): resync model and keyboard after device switch in Server When a new OSK is attached, we need to set its active keyboard and lexical model. These are undocumented API contracts between Keyman Engine for Web and Keyman Developer Server, so this appears to have changed in v.19 for keyboards (although the sync is also necessary for v.18). Fixes: #15217 Test-bot: skip --- developer/src/server/src/site/test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/developer/src/server/src/site/test.js b/developer/src/server/src/site/test.js index eeefd21fce..000a200632 100644 --- a/developer/src/server/src/site/test.js +++ b/developer/src/server/src/site/test.js @@ -262,6 +262,11 @@ function setupKeyman() { newOSK.setSize(targetDevice.dimensions[0]+'px', targetDevice.dimensions[1]+'px'); } document.getElementById('osk-host').appendChild(newOSK.element); + + // After attaching a new OSK, we also need to set the OSK's active keyboard + // and model + newOSK.activeKeyboard = keyman.contextManager.activeKeyboard; // Note: undocumented KeymanWeb API refs on both sides + selectModel(keyman.core.activeModel?.id); } setOSK(); From 3e345274af26c48da4c617afca0cb2f09f3d1909 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 23 Jun 2026 11:57:15 +0200 Subject: [PATCH 052/148] fix(developer): fixup references to layer after deleting in Touch Layout Editor Also address fixup after renaming a layer so that it does not touch the key `layer` property, because that refers actually to "modifier", not "layer" (these terms were conflated in the original design). Fixes: #15340 Test-bot: skip --- .../tike/xml/layoutbuilder/layer-controls.js | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/developer/src/tike/xml/layoutbuilder/layer-controls.js b/developer/src/tike/xml/layoutbuilder/layer-controls.js index 8fa04d369e..0f1797d413 100644 --- a/developer/src/tike/xml/layoutbuilder/layer-controls.js +++ b/developer/src/tike/xml/layoutbuilder/layer-controls.js @@ -31,7 +31,8 @@ $(function() { $('#btnDelLayer').click(function () { if ($('#selLayer option').length == 1) return; builder.saveUndo(); - KVKL[builder.lastPlatform].layer.splice(builder.lastLayerIndex, 1); + const deletedLayer = KVKL[builder.lastPlatform].layer.splice(builder.lastLayerIndex, 1)[0]; + updateNextLayerReferences(KVKL[builder.lastPlatform], deletedLayer.id, ''); builder.selectPlatform(); builder.generate(false,true); }); @@ -45,29 +46,8 @@ $(function() { // Layer dialogs // - function submitLayerProperties() { - var newLayerName = $('#layerName').val(); - if (!newLayerName.match(/^[a-zA-Z0-9_-]+$/)) { - alert('Layer name must contain only alphanumerics, underscore and hyphen.'); - return false; - } - for(var i = 0; i < KVKL[builder.lastPlatform].layer.length; i++) { - if(i != builder.lastLayerIndex && KVKL[builder.lastPlatform].layer[i].id == newLayerName) { - alert('Layer name must not already be in use for the current platform.'); - return false; - } - } - - builder.saveUndo(); - builder.generate(); - - var platform = KVKL[builder.lastPlatform]; - var oldLayerName = platform.layer[builder.lastLayerIndex].id; - - let fixup = function(key) { - if (key.layer == oldLayerName) { - key.layer = newLayerName; - } + function updateNextLayerReferences(platform, oldLayerName, newLayerName) { + const fixup = function(key) { if (key.nextlayer == oldLayerName) { key.nextlayer = newLayerName; } @@ -94,6 +74,28 @@ $(function() { }); } }); + } + + function submitLayerProperties() { + const newLayerName = $('#layerName').val(); + if (!newLayerName.match(/^[a-zA-Z0-9_-]+$/)) { + alert('Layer name must contain only alphanumerics, underscore and hyphen.'); + return false; + } + for(let i = 0; i < KVKL[builder.lastPlatform].layer.length; i++) { + if(i != builder.lastLayerIndex && KVKL[builder.lastPlatform].layer[i].id == newLayerName) { + alert('Layer name must not already be in use for the current platform.'); + return false; + } + } + + builder.saveUndo(); + builder.generate(); + + const platform = KVKL[builder.lastPlatform]; + const oldLayerName = platform.layer[builder.lastLayerIndex].id; + + updateNextLayerReferences(platform, oldLayerName, newLayerName); platform.layer[builder.lastLayerIndex].id = newLayerName; builder.prepareLayers(); From a7371ea3ec9cf5c74ccc6157a0254deb28831bd2 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 23 Jun 2026 12:22:43 -0500 Subject: [PATCH 053/148] feat(ios): add per-language autocorrect toggle Does not actually pass the toggle's setting to KMW yet. Build-bot: skip build:ios --- .../KMEI/KeymanEngine/Classes/Constants.swift | 1 + .../Extension/UserDefaults+Types.swift | 29 ++++ .../Keyboard/KeymanWebViewController.swift | 5 +- .../LanguageSettingsViewController.swift | 132 +++++++++++------- .../KeymanEngine/en.lproj/Localizable.strings | 3 + .../Contents/Resources/ios-host.js | 3 +- .../src/main/headless/languageProcessor.ts | 15 ++ 7 files changed, 138 insertions(+), 50 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift index 01d2862838..2d820d710e 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift @@ -33,6 +33,7 @@ public enum Key { /// Dictionary of prediction/correction toggle settings keyed by language id in UserDefaults static let userPredictSettings = "UserPredictionEnablementSettings" static let userCorrectSettings = "UserCorrectionEnablementSettings" + static let userAutocorrectSettings = "UserAutocorrectionEnablementSettings" // Internal user defaults keys static let engineVersion = "KeymanEngineVersion" diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift b/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift index 8991660dae..4158dc51f2 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift @@ -331,6 +331,17 @@ public extension UserDefaults { set(prefs, forKey: Key.userCorrectSettings) } } + + // stores a dictionary of autocorrection-enablement settings keyed to language ids, i.e., [langID: Bool] + var autocorrectionEnablements: [String: Bool]? { + get { + return dictionary(forKey: Key.userAutocorrectSettings) as? [String : Bool] + } + + set(prefs) { + set(prefs, forKey: Key.userAutocorrectSettings) + } + } var portraitKeyboardHeight: Double { get { @@ -387,4 +398,22 @@ public extension UserDefaults { prefs?[forLanguageID] = correctSetting correctionEnablements = prefs } + + func autocorrectSettingForLanguage(languageID: String) -> Bool { + if let dict = autocorrectionEnablements { + return dict[languageID] ?? true + } else { + return true + } + } + + func set(autocorrectSetting: Bool, forLanguageID: String) { + var prefs: [String: Bool]? + prefs = autocorrectionEnablements + if prefs == nil { + prefs = [String: Bool]() + } + prefs?[forLanguageID] = autocorrectSetting + autocorrectionEnablements = prefs + } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 4bd5375bc9..53e4e327ea 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -182,7 +182,7 @@ extension KeymanWebViewController { } view = nil } - + func languageMenuPosition(_ completion: @escaping (CGRect) -> Void) { webView!.evaluateJavaScript("langMenuPos();") { result, _ in guard let result = result as? String, !result.isEmpty else { @@ -422,10 +422,11 @@ extension KeymanWebViewController { let predict = userDefaults.predictSettingForLanguage(languageID: lexicalModel.languageID) let correct = userDefaults.correctSettingForLanguage(languageID: lexicalModel.languageID) + let autocorrect = userDefaults.autocorrectSettingForLanguage(languageID: lexicalModel.languageID) // Pass these off to KMW! // We do these first so that they're automatically set for the to-be-registered model in advance. - webView!.evaluateJavaScript("enableSuggestions(\(stubString), \(predict), \(correct))") + webView!.evaluateJavaScript("enableSuggestions(\(stubString), \(predict), \(correct), \(autocorrect))") self.activeModel = predict } else { // We're registering a model in the background - don't change settings. webView!.evaluateJavaScript("keyman.addModel(\(stubString));", completionHandler: nil) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Settings/LanguageSettingsViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Settings/LanguageSettingsViewController.swift index 0c98a1cacc..816a7aea2c 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Settings/LanguageSettingsViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Settings/LanguageSettingsViewController.swift @@ -18,8 +18,11 @@ class LanguageSettingsViewController: UITableViewController { private var doPredictionsSwitch: UISwitch? private var doCorrectionsSwitch: UISwitch? + private var doAutocorrectionsSwitch: UISwitch? private var doCorrectionsLabel: UILabel? + private var doAutocorrectionsLabel: UILabel? private var correctionsCell: UITableViewCell? + private var autocorrectionsCell: UITableViewCell? public init(_ inLanguage: Language) { language = inLanguage @@ -87,17 +90,8 @@ class LanguageSettingsViewController: UITableViewController { return switchFrame } - @objc - func predictionSwitchValueChanged(source: UISwitch) { - let value = source.isOn; + func refreshModelIfNeeded() { let userDefaults = Storage.active.userDefaults - userDefaults.set(predictSetting: value, forLanguageID: self.language.id) - - // Reactively set the corrections switch interactivity state. - self.doCorrectionsSwitch?.isHidden = !value - self.doCorrectionsLabel?.isEnabled = value - self.correctionsCell?.isUserInteractionEnabled = value - if let lm = Manager.shared.preferredLexicalModel(userDefaults, forLanguage: self.language.id) { if Manager.shared.currentKeyboardID?.languageID == self.language.id { // re-register the model - that'll enact the settings. @@ -111,23 +105,59 @@ class LanguageSettingsViewController: UITableViewController { } } + @objc + func predictionSwitchValueChanged(source: UISwitch) { + let value = source.isOn; + let userDefaults = Storage.active.userDefaults + userDefaults.set(predictSetting: value, forLanguageID: self.language.id) + + // Reactively set the corrections switch interactivity state. + self.doCorrectionsSwitch?.isHidden = !value + self.doCorrectionsLabel?.isEnabled = value + + let mayCorrect = userDefaults.autocorrectSettingForLanguage(languageID: self.language.id) + self.doAutocorrectionsSwitch?.isHidden = !(value && mayCorrect) + self.doAutocorrectionsLabel?.isEnabled = value && mayCorrect + self.correctionsCell?.isUserInteractionEnabled = value + } + @objc func correctionSwitchValueChanged(source: UISwitch) { let value = source.isOn; let userDefaults = Storage.active.userDefaults userDefaults.set(correctSetting: value, forLanguageID: self.language.id) - if let lm = Manager.shared.preferredLexicalModel(userDefaults, forLanguage: self.language.id) { - if Manager.shared.currentKeyboardID?.languageID == self.language.id { - // re-register the model - that'll enact the settings. - _ = Manager.shared.registerLexicalModel(lm) - } - // Based on how Manager chooses the model in setKeyboard. - } else if let lm = userDefaults.userLexicalModels?.first(where: { $0.languageID == self.language.id }) { - if Manager.shared.currentKeyboardID?.languageID == self.language.id { - _ = Manager.shared.registerLexicalModel(lm) - } - } + // This may only be triggered if the predict toggle is on, + // so we can rely on just the input value. + self.doAutocorrectionsSwitch?.isHidden = !value + self.doAutocorrectionsLabel?.isEnabled = value + + refreshModelIfNeeded() + } + + @objc + func autocorrectionSwitchValueChanged(source: UISwitch) { + let value = source.isOn; + let userDefaults = Storage.active.userDefaults + userDefaults.set(autocorrectSetting: value, forLanguageID: self.language.id) + + refreshModelIfNeeded() + } + + func addSwitchToTableCell(_ toggle: UISwitch, cell: UITableViewCell, isOn: Bool, selector: Selector) { + cell.accessoryType = .none + toggle.translatesAutoresizingMaskIntoConstraints = false + + let switchFrame = frameAtRightOfCell(cell: cell.frame, controlSize: toggle.frame.size) + toggle.frame = switchFrame + + toggle.isOn = isOn + toggle.addTarget(self, action: selector, for: .valueChanged) + cell.addSubview(toggle) + cell.contentView.isUserInteractionEnabled = false + + toggle.rightAnchor.constraint(equalTo: cell.layoutMarginsGuide.rightAnchor).isActive = true + toggle.centerYAnchor.constraint(equalTo: cell.layoutMarginsGuide.centerYAnchor).isActive = true } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -143,40 +173,44 @@ class LanguageSettingsViewController: UITableViewController { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) if 1 == indexPath.section { if 0 == indexPath.row { - cell.accessoryType = .none doPredictionsSwitch = UISwitch() - doPredictionsSwitch!.translatesAutoresizingMaskIntoConstraints = false - let switchFrame = frameAtRightOfCell(cell: cell.frame, controlSize: doPredictionsSwitch!.frame.size) - doPredictionsSwitch!.frame = switchFrame - - doPredictionsSwitch!.isOn = userDefaults.predictSettingForLanguage(languageID: self.language.id) - doPredictionsSwitch!.addTarget(self, action: #selector(self.predictionSwitchValueChanged), for: .valueChanged) - cell.addSubview(doPredictionsSwitch!) - cell.contentView.isUserInteractionEnabled = false - - doPredictionsSwitch!.rightAnchor.constraint(equalTo: cell.layoutMarginsGuide.rightAnchor).isActive = true - doPredictionsSwitch!.centerYAnchor.constraint(equalTo: cell.layoutMarginsGuide.centerYAnchor).isActive = true + self.addSwitchToTableCell( + doPredictionsSwitch!, + cell: cell, + isOn: userDefaults.predictSettingForLanguage(languageID: self.language.id), + selector: #selector(self.predictionSwitchValueChanged) + ) } else if 1 == indexPath.row { correctionsCell = cell - cell.accessoryType = .none doCorrectionsSwitch = UISwitch() - doCorrectionsSwitch!.translatesAutoresizingMaskIntoConstraints = false - let switchFrame = frameAtRightOfCell(cell: cell.frame, controlSize: doCorrectionsSwitch!.frame.size) - doCorrectionsSwitch!.frame = switchFrame - - doCorrectionsSwitch!.isOn = userDefaults.correctSettingForLanguage(languageID: self.language.id) - doCorrectionsSwitch!.addTarget(self, action: #selector(self.correctionSwitchValueChanged), for: .valueChanged) - cell.addSubview(doCorrectionsSwitch!) - cell.contentView.isUserInteractionEnabled = false - - doCorrectionsSwitch!.rightAnchor.constraint(equalTo: cell.layoutMarginsGuide.rightAnchor).isActive = true - doCorrectionsSwitch!.centerYAnchor.constraint(equalTo: cell.layoutMarginsGuide.centerYAnchor).isActive = true + self.addSwitchToTableCell( + doCorrectionsSwitch!, + cell: cell, + isOn: userDefaults.correctSettingForLanguage(languageID: self.language.id), + selector: #selector(self.correctionSwitchValueChanged) + ) // Disable interactivity if the prediction toggle is set to 'off'. doCorrectionsSwitch!.isHidden = !userDefaults.predictSettingForLanguage(languageID: self.language.id) cell.isUserInteractionEnabled = userDefaults.predictSettingForLanguage(languageID: self.language.id) + } else if 2 == indexPath.row { + autocorrectionsCell = cell + doAutocorrectionsSwitch = UISwitch() + + self.addSwitchToTableCell( + doAutocorrectionsSwitch!, + cell: cell, + isOn: userDefaults.autocorrectSettingForLanguage(languageID: self.language.id), + selector: #selector(self.autocorrectionSwitchValueChanged) + ) + + // Disable interactivity if the prediction or correction toggle is set to 'off'. + let mayPredict = userDefaults.predictSettingForLanguage(languageID: self.language.id) + let mayCorrect = userDefaults.correctSettingForLanguage(languageID: self.language.id) + doAutocorrectionsSwitch!.isHidden = !(mayPredict && mayCorrect) + cell.isUserInteractionEnabled = mayPredict && mayCorrect } else { // rows 3 and 4 cell.accessoryType = .disclosureIndicator } @@ -245,6 +279,10 @@ class LanguageSettingsViewController: UITableViewController { cell.textLabel?.text = NSLocalizedString("menu-langsettings-toggle-correct", bundle: engineBundle, comment: "") cell.textLabel?.isEnabled = !(doCorrectionsSwitch?.isHidden ?? false) case 2: + doAutocorrectionsLabel = cell.textLabel + cell.textLabel?.text = NSLocalizedString("menu-langsettings-toggle-autocorrect", bundle: engineBundle, comment: "") + cell.textLabel?.isEnabled = !(doAutocorrectionsSwitch?.isHidden ?? false) + case 3: cell.textLabel?.text = NSLocalizedString("menu-langsettings-label-lexical-models", bundle: engineBundle, comment: "") cell.accessoryType = .disclosureIndicator let modelCt = language.lexicalModels?.count ?? 0 @@ -332,8 +370,8 @@ class LanguageSettingsViewController: UITableViewController { showKeyboardInfoView(kb: (language.keyboards?[safe: indexPath.row])!) case 1: switch indexPath.row { - // case 0, 1: the toggles - but a general 'click' not on the toggle itself. - case 2: + // case 0, 1, 2: the toggles - but a general 'click' not on the toggle itself. + case 3: showLexicalModelsView() default: break diff --git a/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings b/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings index 05a1723ebb..0e5b70769b 100644 --- a/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings +++ b/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings @@ -154,6 +154,9 @@ /* Label for the toggle that enables corrections that is displayed within a language-specific settings menu */ "menu-langsettings-toggle-correct" = "Enable corrections"; +/* Label for the toggle that enables auto-corrections that is displayed within a language-specific settings menu */ +"menu-langsettings-toggle-autocorrect" = "Enable autocorrections"; + /* Label for the toggle that enables predictions that is displayed within a language-specific settings menu */ "menu-langsettings-toggle-predict" = "Enable predictions"; diff --git a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js index 25af94b5bd..afb561b176 100644 --- a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js +++ b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js @@ -326,11 +326,12 @@ function toHex(theString) { return hexString.substr(0, hexString.length-1); } -function enableSuggestions(model, mayPredict, mayCorrect) { +function enableSuggestions(model, mayPredict, mayCorrect, mayAutocorrect) { // Set the options first so that KMW's ModelCache can properly handle model enablement states // the moment we actually register the new model. keyman.core.languageProcessor.mayPredict = mayPredict; keyman.core.languageProcessor.mayCorrect = mayCorrect; + keyman.core.languageProcessor.mayAutocorrect = mayAutocorrect; keyman.addModel(model); } diff --git a/web/src/engine/src/main/headless/languageProcessor.ts b/web/src/engine/src/main/headless/languageProcessor.ts index b98d4246df..c5d08c5ca9 100644 --- a/web/src/engine/src/main/headless/languageProcessor.ts +++ b/web/src/engine/src/main/headless/languageProcessor.ts @@ -166,6 +166,12 @@ export class LanguageProcessor extends EventEmitter { return !!this.recentTranscriptions.get(transitionId); } + /** + * When called, this requests the worker to generate predictions for the transcribed + * context transition. + * @param transcription + * @param layerId + */ public predict(transcription: Transcription, layerId: string): Promise { if(!this.isActive) { return null; @@ -373,6 +379,9 @@ export class LanguageProcessor extends EventEmitter { this.lmEngine.resetContext(context, transcription.token); } + // The "may correct" setting is enforced here, in the main Web engine - not + // in the worker. As the desired setting may vary language-to-language, + // we'd rather do this than reconfigure the worker constantly. let alternates = transcription.alternates; if(!this.mayCorrect || !alternates || alternates.length == 0) { alternates = [{ @@ -386,6 +395,12 @@ export class LanguageProcessor extends EventEmitter { return promise.then((suggestions: Suggestion[]) => { if(promise == this.currentPromise) { + if(!this.mayAutoCorrect) { + // We disable the auto-accept flag here, rather than in the worker - + // that way, we don't need to reconfigure the worker when settings + // change. + suggestions.forEach((s) => delete s.autoAccept); + } const result = new ReadySuggestions(suggestions, transform.id); this.emit("suggestionsready", result); this.currentPromise = null; From cfe2e8ef06099e21742b0245eabca8d633d57689 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 24 Jun 2026 06:27:10 +0200 Subject: [PATCH 054/148] fix(developer): consolidate user options in TypeScript code * Remove Server's 'config.json' redundant user options handling * Refactor options.ts in Server and kmc to share common cross-platform code * Move option defaults into common code and sync with .pas defaults * Rename config.ts to standardPaths.ts to better represent the remaining purpose of the module. Fixes: #13458 --- developer/src/common/web/utils/src/index.ts | 2 + .../web/utils/src/keyman-developer-options.ts | 128 ++++++++++++++++++ .../src/commands/buildClasses/BuildProject.ts | 2 +- developer/src/kmc/src/util/KeymanSentry.ts | 2 +- developer/src/kmc/src/util/options.ts | 85 ++++-------- developer/src/server/src/KeymanSentry.ts | 2 +- developer/src/server/src/config.ts | 48 ------- developer/src/server/src/data.ts | 6 +- .../src/handlers/api/debugobject/register.ts | 4 +- developer/src/server/src/index.ts | 37 ++--- developer/src/server/src/options.ts | 86 ++++-------- developer/src/server/src/routes.ts | 5 +- developer/src/server/src/standardPaths.ts | 33 +++++ ....Developer.System.KeymanDeveloperPaths.pas | 1 - .../src/tike/main/KeymanDeveloperOptions.pas | 20 --- 15 files changed, 241 insertions(+), 220 deletions(-) create mode 100644 developer/src/common/web/utils/src/keyman-developer-options.ts delete mode 100644 developer/src/server/src/config.ts create mode 100644 developer/src/server/src/standardPaths.ts diff --git a/developer/src/common/web/utils/src/index.ts b/developer/src/common/web/utils/src/index.ts index a414139584..5371620d67 100644 --- a/developer/src/common/web/utils/src/index.ts +++ b/developer/src/common/web/utils/src/index.ts @@ -78,3 +78,5 @@ export { getFontFamily, getFontFamilySync } from './font-family.js'; export * as ValidIds from './valid-ids.js'; export * as ProjectLoader from './project-loader.js'; + +export { optionsManager, KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath } from './keyman-developer-options.js'; diff --git a/developer/src/common/web/utils/src/keyman-developer-options.ts b/developer/src/common/web/utils/src/keyman-developer-options.ts new file mode 100644 index 0000000000..f1e831e684 --- /dev/null +++ b/developer/src/common/web/utils/src/keyman-developer-options.ts @@ -0,0 +1,128 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * User options for Keyman Developer. These are stored in options.json in the + * user profile; the location varies by operating system or may be stored in + * browser storage on web sites. + * + * The node-based loader is implemented in both kmc and Keyman Developer Server, + * in order to keep node dependencies out of the developer-utils module. + */ + +/** + * The standard path under the user profile where options.json is stored; use + * `path.join(os.homedir(), ...KeymanDeveloperOptionsPath)` or similar + */ +export const KeymanDeveloperOptionsPath = [/* '~', */ '.keymandeveloper', 'options.json']; + +/** + * The set of standard user options for Keyman Developer. Corresponds to + * TKeymanDeveloperOptions in the Keyman Developer TIKE source. + */ +export interface KeymanDeveloperOptions { + "use tab char": boolean; + "link font sizes": boolean; + "indent size": number; + "use old debugger": boolean; + "editor theme": string; + "debugger break when exiting line": boolean; + "debugger single step after break": boolean; + "debugger show store offset": boolean; + "debugger recompile with debug info": boolean; + "debugger auto reset before compilng": boolean; + "auto save before compiling": boolean; + "osk auto save before importing": boolean; + "web host port": number; + "server keep alive": boolean; + "server use local addresses": boolean; + "server ngrok token": string; + "server ngrok region": string; + "server use ngrok": boolean; + "server show console window": boolean; + "char map disable database lookups": boolean; + "char map auto lookup": boolean; + "open keyboard files in source view": boolean; + "display theme": string; + "external editor path": string; + "smtp server": string; + "test email addresses": string; + "web ladder length": number; + "default project path": string; + "automatically report errors": boolean; + "automatically report usage": boolean; + "toolbar visible": boolean; + "active project": string; + "prompt to upgrade projects": boolean; +}; + +/** + * A single Keyman Developer user option. + */ +export type KeymanDeveloperOption = keyof KeymanDeveloperOptions; + +const DEFAULT_OPTIONS: KeymanDeveloperOptions = { + // Corresponds to KeymanDeveloperOptions.pas, TKeymanDeveloperOptions.Read + "use tab char": false, + "link font sizes": true, + "indent size": 4, + "use old debugger": false, + "editor theme": '', + "debugger break when exiting line": true, + "debugger single step after break": false, + "debugger show store offset": false, + "debugger recompile with debug info": false, + "debugger auto reset before compilng": false, + "auto save before compiling": false, + "osk auto save before importing": false, + "web host port": 8008, + "server keep alive": false, + "server use local addresses": true, + "server ngrok token": '', + "server ngrok region": '', + "server use ngrok": false, + "server show console window": false, + "char map disable database lookups": false, + "char map auto lookup": true, + "open keyboard files in source view": false, + "display theme": 'Windows10', + "external editor path": '', + "smtp server": '', + "test email addresses": '', + "web ladder length": 100, + "default project path": '', // Note: this diverges from Delphi code, which uses CSIDL_PERSONAL on Windows, but it is not used in Server + "automatically report errors": true, + "automatically report usage": true, + "toolbar visible": true, + "active project": '', + "prompt to upgrade projects": true, +} + + +class KeymanDeveloperOptionsManager { + private options: KeymanDeveloperOptions = {...DEFAULT_OPTIONS}; + constructor() {} + + public load(blob: Uint8Array | null) { + this.options = {...DEFAULT_OPTIONS}; + if(blob !== null && blob !== undefined) { + const data = JSON.parse(new TextDecoder('utf-8').decode(blob)); + if(typeof data == 'object') { + // TODO: verify fields in options + this.options = {...DEFAULT_OPTIONS, ...data}; + return true; + } + } + return false; + } + + public get(valueName: T): KeymanDeveloperOptions[T] { + return this.options[valueName]; + } + + public clear() { + this.options = {...DEFAULT_OPTIONS}; + } +} + +export const optionsManager = new KeymanDeveloperOptionsManager(); + diff --git a/developer/src/kmc/src/commands/buildClasses/BuildProject.ts b/developer/src/kmc/src/commands/buildClasses/BuildProject.ts index 76fdfe1d04..6d51a3914a 100644 --- a/developer/src/kmc/src/commands/buildClasses/BuildProject.ts +++ b/developer/src/kmc/src/commands/buildClasses/BuildProject.ts @@ -46,7 +46,7 @@ class ProjectBuilder { // Give a hint if the project is v1.0 if(this.project.options.version != '2.0') { - if(getOption("prompt to upgrade projects", true)) { + if(getOption("prompt to upgrade projects")) { this.callbacks.reportMessage(InfrastructureMessages.Hint_ProjectIsVersion10()); } } diff --git a/developer/src/kmc/src/util/KeymanSentry.ts b/developer/src/kmc/src/util/KeymanSentry.ts index 9ee05ecb7f..658f750d37 100644 --- a/developer/src/kmc/src/util/KeymanSentry.ts +++ b/developer/src/kmc/src/util/KeymanSentry.ts @@ -23,7 +23,7 @@ export class KeymanSentry { return true; } - return getOption('automatically report errors', true); + return getOption('automatically report errors'); } static init(options?: SentryNodeOptions) { diff --git a/developer/src/kmc/src/util/options.ts b/developer/src/kmc/src/util/options.ts index 9d1a93895e..51217628cf 100644 --- a/developer/src/kmc/src/util/options.ts +++ b/developer/src/kmc/src/util/options.ts @@ -1,69 +1,32 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Load Keyman Developer's options from the standard Options location. This + * small loader is duplicated in Keyman Developer Server, because we do not have + * a shared node-aware module at this time. + */ + import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath, optionsManager } from '@keymanapp/developer-utils'; -export interface KeymanDeveloperOptions { - "use tab char"?: boolean; - "link font sizes"?: boolean; - "indent size"?: number; - "use old debugger"?: boolean; - "editor theme"?: string; - "debugger break when exiting line"?: boolean; - "debugger single step after break"?: boolean; - "debugger show store offset"?: boolean; - "debugger recompile with debug info"?: boolean; - "debugger auto reset before compilng"?: boolean; - "auto save before compiling"?: boolean; - "osk auto save before importing"?: boolean; - "web host port"?: number; - "server keep alive"?: boolean; - "server use local addresses"?: boolean; - "server ngrok token"?: string; - "server ngrok region"?: string; - "server use ngrok"?: boolean; - "server show console window"?: boolean; - "char map disable database lookups"?: boolean; - "char map auto lookup"?: boolean; - "open keyboard files in source view"?: boolean; - "display theme"?: string; - "external editor path"?: string; - "smtp server"?: string; - "test email addresses"?: string; - "web ladder length"?: number; - "default project path"?: string; - "automatically report errors"?: boolean; - "automatically report usage"?: boolean; - "toolbar visible"?: boolean; - "active project"?: string; - "prompt to upgrade projects"?: boolean; -}; +let optionsLoaded: boolean = false; -type KeymanDeveloperOption = keyof KeymanDeveloperOptions; - -// Default has no options set, and unit tests will use the defaults (won't call -// `loadOptions()`) -let options: KeymanDeveloperOptions = {}; - -// We only load the options from disk once on first use -let optionsLoaded = false; - -export async function loadOptions(): Promise { +export async function loadOptions(): Promise { if(optionsLoaded) { - return options; + return true; } + optionsLoaded = true; - options = {}; try { - const optionsFile = path.join(os.homedir(), '.keymandeveloper', 'options.json'); + const optionsFile = path.join(os.homedir(), ...KeymanDeveloperOptionsPath); if(fs.existsSync(optionsFile)) { for(let i = 0; i < 5; i++) { try { - const data = JSON.parse(fs.readFileSync(optionsFile, 'utf-8')); - if(typeof data == 'object') { - options = data; - } - break; - } catch(e) { + const data = fs.readFileSync(optionsFile) as Uint8Array; + return optionsManager.load(data); + } catch(e: any) { if(e?.code == 'EBUSY') { await new Promise(resolve => setTimeout(resolve, 500)); } else { @@ -76,20 +39,20 @@ export async function loadOptions(): Promise { } catch(e) { // Nothing to report here, sadly -- because we cannot rely on Sentry at this // low level. - options = {}; } - optionsLoaded = true; - return options; + + optionsManager.clear(); + return false; } -export function getOption(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] { - return options[valueName] ?? defaultValue; +export function getOption(valueName: T): KeymanDeveloperOptions[T] { + return optionsManager.get(valueName); } /** * unit tests will clear options before running, for consistency */ export function clearOptions() { - options = {}; - optionsLoaded = true; + optionsLoaded = false; + return optionsManager.clear(); } \ No newline at end of file diff --git a/developer/src/server/src/KeymanSentry.ts b/developer/src/server/src/KeymanSentry.ts index 9dba30a0d4..b531e80235 100644 --- a/developer/src/server/src/KeymanSentry.ts +++ b/developer/src/server/src/KeymanSentry.ts @@ -23,7 +23,7 @@ export class KeymanSentry { return true; } - return getOption('automatically report errors', true); + return getOption('automatically report errors'); } static init(options?: SentryNodeOptions) { diff --git a/developer/src/server/src/config.ts b/developer/src/server/src/config.ts deleted file mode 100644 index e60ec6d0a0..0000000000 --- a/developer/src/server/src/config.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { mkdirSync } from 'fs'; -import { loadJsonFile } from './load-json-file.js'; - -export class Configuration { - public readonly appDataPath: string; - public readonly cachePath: string; - public readonly cacheStateFilename: string; - public readonly lockFilename: string; - public readonly pidFilename: string; - public readonly configFilename: string; - - /* Configuration values - set in config.json by TIKE */ - - public readonly port: number; - - /* ngrok Configuration */ - - public readonly useNgrok: boolean; - public readonly ngrokToken: string; - public readonly ngrokVisible: boolean; - - public ngrokEndpoint: string = ''; - - constructor() { - - this.appDataPath = (process.env.APPDATA || - (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share")) + - '/Keyman/Keyman Developer/Server/'; - this.cachePath = this.appDataPath + 'cache/'; - this.cacheStateFilename = this.appDataPath + 'cache.json'; - this.lockFilename = this.appDataPath + 'lock.json'; - this.pidFilename = this.appDataPath + 'pid.json'; - this.configFilename = this.appDataPath + 'config.json'; - - mkdirSync(this.cachePath, { recursive: true}); - - const cfg = loadJsonFile(this.configFilename); - - this.port = cfg?.port ?? 8008; - - // ngrok configuration - this.useNgrok = cfg?.useNgrok ?? false; - this.ngrokToken = cfg?.ngrokToken ?? ''; - this.ngrokVisible = cfg?.ngrokVisible ?? false; - } -}; - -export const configuration = new Configuration(); \ No newline at end of file diff --git a/developer/src/server/src/data.ts b/developer/src/server/src/data.ts index 6600d57b4d..8d2753ea21 100644 --- a/developer/src/server/src/data.ts +++ b/developer/src/server/src/data.ts @@ -1,5 +1,5 @@ import { writeFileSync } from 'fs'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import { loadJsonFile } from './load-json-file.js'; export interface DebugObject { @@ -97,7 +97,7 @@ export class SiteData { } private loadState() { - const state = loadJsonFile(configuration.cacheStateFilename); + const state = loadJsonFile(standardPaths.cacheStateFilename); this.loadDebugObject(DebugKeyboard, state?.keyboards, this.keyboards); this.loadDebugObject(DebugModel, state?.models, this.models); this.loadDebugObject(DebugFont, state?.fonts, this.fonts); @@ -106,7 +106,7 @@ export class SiteData { } public saveState() { - writeFileSync(configuration.cacheStateFilename, JSON.stringify(this, null, 2), 'utf-8'); + writeFileSync(standardPaths.cacheStateFilename, JSON.stringify(this, null, 2), 'utf-8'); } }; diff --git a/developer/src/server/src/handlers/api/debugobject/register.ts b/developer/src/server/src/handlers/api/debugobject/register.ts index a1f7a72cd4..bb28c87a58 100644 --- a/developer/src/server/src/handlers/api/debugobject/register.ts +++ b/developer/src/server/src/handlers/api/debugobject/register.ts @@ -2,7 +2,7 @@ import * as express from 'express'; import { DebugObject, isValidId, simplifyId } from "../../../data.js"; import * as fs from 'fs'; import * as crypto from 'crypto'; -import { configuration } from '../../../config.js'; +import { standardPaths } from '../../../standardPaths.js'; import chalk from 'chalk'; // We allow only 12 objects of each type in the cache @@ -41,7 +41,7 @@ export function apiRegisterFile (intf: new () => O, root: o.lastUse = new Date(); o.id = id; - o.filename = configuration.cachePath + o.filenameFromId(id); + o.filename = standardPaths.cachePath + o.filenameFromId(id); fs.writeFileSync(o.filename, file); o.sha256 = crypto.createHash('sha256').update(file).digest('hex'); diff --git a/developer/src/server/src/index.ts b/developer/src/server/src/index.ts index 36fd8431d8..e248b19625 100644 --- a/developer/src/server/src/index.ts +++ b/developer/src/server/src/index.ts @@ -5,12 +5,12 @@ import express from 'express'; import multer from 'multer'; import * as ws from 'ws'; import { KeymanSentry } from './KeymanSentry.js'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import { environment } from './environment.js'; import setupRoutes from './routes.js'; import { shutdown } from './shutdown.js'; import { initTray } from './tray.js'; -import { loadOptions } from './options.js'; +import { getOption, loadOptions } from './options.js'; const options = { ngrokLog: false, // Set this to true if you need to see ngrok logs in the console @@ -18,13 +18,13 @@ const options = { /* Lock file - report on PID and prevent multiple instances cleanly */ -console.log(`Starting Keyman Developer Server ${environment.versionWithTag}, listening on port ${configuration.port}.`); - // We need to load the Keyman Developer options before attempting to initialize // Sentry. `loadOptions` silently suppresses exceptions and returns a default // set of options if an error occurs. await loadOptions(); +console.log(`Starting Keyman Developer Server ${environment.versionWithTag}, listening on port ${getOption('web host port')}.`); + KeymanSentry.init(); try { await run(); @@ -80,10 +80,11 @@ export async function run() { let server = null; try { - server = app.listen(configuration.port); + server = app.listen(getOption("web host port")); } catch(err) { console.error(err); // TODO handle and cleanup EADDRINUSE, throw anything else + return; } /* Attach the web socket server */ @@ -96,14 +97,14 @@ export async function run() { /* Launch ngrok if enabled */ - configuration.ngrokEndpoint = ''; - if(configuration.useNgrok) { + standardPaths.ngrokEndpoint = ''; + if(getOption("server use ngrok")) { await startNGrok(); } /* Load the tray icon */ - tray.start(configuration.port, configuration.ngrokEndpoint); + tray.start(getOption("web host port"), standardPaths.ngrokEndpoint); } async function loadNGrok() { @@ -129,8 +130,8 @@ async function startNGrok() { let started = false; const listener = await ngrok.forward({ proto: 'http', - addr: configuration.port, - authtoken: configuration.ngrokToken, + addr: getOption("web host port"), + authtoken: getOption("server ngrok token"), onLogEvent: (msg: string) => { if(options.ngrokLog) { console.log(chalk.cyan(('\n'+msg).split('\n').join('\n[ngrok] ').trim())); @@ -139,19 +140,19 @@ async function startNGrok() { onStatusChange: (state: string) => { if(state == 'connected' && started) { // We only announce reconnection after initial start - configuration.ngrokEndpoint = listener.url() ?? ''; - console.log(chalk.blueBright('ngrok tunnel reconnected at %s'), configuration.ngrokEndpoint); + standardPaths.ngrokEndpoint = listener.url() ?? ''; + console.log(chalk.blueBright('ngrok tunnel reconnected at %s'), standardPaths.ngrokEndpoint); } else if(state == 'closed') { - configuration.ngrokEndpoint = ''; + standardPaths.ngrokEndpoint = ''; console.log(chalk.blueBright('ngrok tunnel closed')); } } }); started = true; - configuration.ngrokEndpoint = listener.url(); - console.log(chalk.blueBright('ngrok tunnel established at %s'), configuration.ngrokEndpoint); + standardPaths.ngrokEndpoint = listener.url() ?? ""; + console.log(chalk.blueBright('ngrok tunnel established at %s'), standardPaths.ngrokEndpoint); } catch(e) { - configuration.ngrokEndpoint = ''; + standardPaths.ngrokEndpoint = ''; console.error(chalk.red('ngrok tunnel failed to connect with an error: %s'), e); return false; } @@ -168,8 +169,8 @@ function getRunningInstancePid(pidFilename: string) { } function writeLockFile() { - const lockFilename = configuration.lockFilename.replaceAll(/[\\\/]/g, path.sep); - const pidFilename = configuration.pidFilename.replaceAll(/[\\\/]/g, path.sep); + const lockFilename = standardPaths.lockFilename.replaceAll(/[\\\/]/g, path.sep); + const pidFilename = standardPaths.pidFilename.replaceAll(/[\\\/]/g, path.sep); // console.debug(`Testing existence of ${lockFilename}`); if(fs.existsSync(lockFilename)) { diff --git a/developer/src/server/src/options.ts b/developer/src/server/src/options.ts index 45799ba5f2..a911283be3 100644 --- a/developer/src/server/src/options.ts +++ b/developer/src/server/src/options.ts @@ -1,70 +1,32 @@ -// TODO: this is duplicated in kmc +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Load Keyman Developer's options from the standard Options location. This + * small loader is duplicated in kmc, because we do not have a shared node-aware + * module at this time. + */ + import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath, optionsManager } from '@keymanapp/developer-utils'; -export interface KeymanDeveloperOptions { - "use tab char"?: boolean; - "link font sizes"?: boolean; - "indent size"?: number; - "use old debugger"?: boolean; - "editor theme"?: string; - "debugger break when exiting line"?: boolean; - "debugger single step after break"?: boolean; - "debugger show store offset"?: boolean; - "debugger recompile with debug info"?: boolean; - "debugger auto reset before compilng"?: boolean; - "auto save before compiling"?: boolean; - "osk auto save before importing"?: boolean; - "web host port"?: number; - "server keep alive"?: boolean; - "server use local addresses"?: boolean; - "server ngrok token"?: string; - "server ngrok region"?: string; - "server use ngrok"?: boolean; - "server show console window"?: boolean; - "char map disable database lookups"?: boolean; - "char map auto lookup"?: boolean; - "open keyboard files in source view"?: boolean; - "display theme"?: string; - "external editor path"?: string; - "smtp server"?: string; - "test email addresses"?: string; - "web ladder length"?: number; - "default project path"?: string; - "automatically report errors"?: boolean; - "automatically report usage"?: boolean; - "toolbar visible"?: boolean; - "active project"?: string; - "prompt to upgrade projects"?: boolean; -}; +let optionsLoaded: boolean = false; -type KeymanDeveloperOption = keyof KeymanDeveloperOptions; - -// Default has no options set, and unit tests will use the defaults (won't call -// `loadOptions()`) -let options: KeymanDeveloperOptions = {}; - -// We only load the options from disk once on first use -let optionsLoaded = false; - -export async function loadOptions(): Promise { +export async function loadOptions(): Promise { if(optionsLoaded) { - return options; + return true; } + optionsLoaded = true; - options = {}; try { - const optionsFile = path.join(os.homedir(), '.keymandeveloper', 'options.json'); + const optionsFile = path.join(os.homedir(), ...KeymanDeveloperOptionsPath); if(fs.existsSync(optionsFile)) { for(let i = 0; i < 5; i++) { try { - const data = JSON.parse(fs.readFileSync(optionsFile, 'utf-8')); - if(typeof data == 'object') { - options = data; - } - break; - } catch(e) { + const data = fs.readFileSync(optionsFile) as Uint8Array; + return optionsManager.load(data); + } catch(e: any) { if(e?.code == 'EBUSY') { await new Promise(resolve => setTimeout(resolve, 500)); } else { @@ -77,20 +39,20 @@ export async function loadOptions(): Promise { } catch(e) { // Nothing to report here, sadly -- because we cannot rely on Sentry at this // low level. - options = {}; } - optionsLoaded = true; - return options; + + optionsManager.clear(); + return false; } -export function getOption(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] { - return options[valueName] ?? defaultValue; +export function getOption(valueName: T): KeymanDeveloperOptions[T] { + return optionsManager.get(valueName); } /** * unit tests will clear options before running, for consistency */ export function clearOptions() { - options = {}; - optionsLoaded = true; + optionsLoaded = false; + return optionsManager.clear(); } \ No newline at end of file diff --git a/developer/src/server/src/routes.ts b/developer/src/server/src/routes.ts index 3876bf0d7a..acccf23402 100644 --- a/developer/src/server/src/routes.ts +++ b/developer/src/server/src/routes.ts @@ -12,9 +12,10 @@ import handleIncPackagesJson from './handlers/inc/packages-json.js'; import apiPackageRegister from './handlers/api/package/register.js'; import handleIncKeyboardsCss from './handlers/inc/keyboards-css.js'; import { Environment } from './version-data.js'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import chalk from 'chalk'; import { shutdown } from './shutdown.js'; +import { getOption } from './options.js'; export default function setupRoutes(app: express.Express, upload: multer.Multer, wsServer: ws.WebSocketServer, environment: Environment ) { @@ -163,7 +164,7 @@ export default function setupRoutes(app: express.Express, upload: multer.Multer, /* ngrok data */ app.get('/api/status', (_req,res,next) => { - const response = { ngrokEnabled: configuration.useNgrok, ngrokEndpoint: configuration.ngrokEndpoint }; + const response = { ngrokEnabled: getOption("server use ngrok"), ngrokEndpoint: standardPaths.ngrokEndpoint }; res.send(response); next(); }); diff --git a/developer/src/server/src/standardPaths.ts b/developer/src/server/src/standardPaths.ts new file mode 100644 index 0000000000..bc84104d9e --- /dev/null +++ b/developer/src/server/src/standardPaths.ts @@ -0,0 +1,33 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Path and URL constants (in some cases calculated) + */ +import { mkdirSync } from 'node:fs'; + +class StandardPaths { + public readonly appDataPath: string; + public readonly cachePath: string; + public readonly cacheStateFilename: string; + public readonly lockFilename: string; + public readonly pidFilename: string; + + /* ngrok Configuration */ + + public ngrokEndpoint: string = ''; + + constructor() { + + this.appDataPath = (process.env.APPDATA || + (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share")) + + '/Keyman/Keyman Developer/Server/'; + this.cachePath = this.appDataPath + 'cache/'; + this.cacheStateFilename = this.appDataPath + 'cache.json'; + this.lockFilename = this.appDataPath + 'lock.json'; + this.pidFilename = this.appDataPath + 'pid.json'; + + mkdirSync(this.cachePath, {recursive: true}); + } +}; + +export const standardPaths = new StandardPaths(); diff --git a/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas b/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas index 6beeafe62e..1322010798 100644 --- a/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas +++ b/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas @@ -20,7 +20,6 @@ type const S_Kmc = 'kmc.cmd'; class function KmcPath: string; static; - const S_ServerConfigJson = 'config.json'; class function ServerDataPath: string; static; class function ServerPath: string; static; diff --git a/developer/src/tike/main/KeymanDeveloperOptions.pas b/developer/src/tike/main/KeymanDeveloperOptions.pas index 22109f58ff..09c6afcd9c 100644 --- a/developer/src/tike/main/KeymanDeveloperOptions.pas +++ b/developer/src/tike/main/KeymanDeveloperOptions.pas @@ -81,7 +81,6 @@ type procedure optWriteString(const nm, value: string); procedure optWriteBool(const nm: string; value: Boolean); procedure optWriteInt(const nm: string; value: Integer); - procedure WriteServerConfigurationJson; class function Get_Initial_DefaultProjectPath: string; static; function BackOffAndSaveJson(const Filename: string; const JSON: TJSONObject): Boolean; public @@ -476,8 +475,6 @@ begin finally FreeAndNil(json); end; - - WriteServerConfigurationJson; end; function TKeymanDeveloperOptions.BackOffAndSaveJson(const Filename: string; const JSON: TJSONObject): Boolean; @@ -513,23 +510,6 @@ begin Result := False; end; -procedure TKeymanDeveloperOptions.WriteServerConfigurationJson; -var - o: TJSONObject; -begin - o := TJSONObject.Create; - try - o.AddPair('port', TJSONNumber.Create(FServerDefaultPort)); - o.AddPair('ngrokToken', FServerNgrokToken); - o.AddPair('useNgrok', TJSONBool.Create(FServerUseNgrok)); - o.AddPair('ngrokVisible', TJSONBool.Create(FServerServerShowConsoleWindow)); - ForceDirectories(TKeymanDeveloperPaths.ServerDataPath); - BackOffAndSaveJSON(TKeymanDeveloperPaths.ServerDataPath + TKeymanDeveloperPaths.S_ServerConfigJson, o); - finally - o.Free; - end; -end; - procedure TKeymanDeveloperOptions.optWriteBool(const nm: string; value: Boolean); begin json.AddPair(nm, TJSONBool.Create(value)); From d86604902e4c95e1ce8674bfac9cd588cf153082 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 24 Jun 2026 14:19:36 +0200 Subject: [PATCH 055/148] chore(developer): consolidate api-extractor usage in Developer * Centralize the api-extractor.json files, update usage * Support @since * Tidy up a few warnings relating to API documentation content Fixes: #14838 Test-bot: skip --- .gitignore | 4 ++ common/tools/api-extractor/README.md | 35 +++++++++++++ .../api-extractor}/api-extractor.base.json | 6 +-- .../api-extractor/api-extractor.template.json | 15 ++++++ .../tools/api-extractor/tsdoc.template.json | 15 ++++++ developer/docs/api/etc/kmc-copy.api.md | 2 +- developer/docs/api/etc/kmc-kmn.api.md | 12 ++--- developer/src/kmc-analyze/build.sh | 2 +- .../src/kmc-analyze/config/api-extractor.json | 12 ----- .../src/osk-character-use/index.ts | 4 +- developer/src/kmc-copy/build.sh | 2 +- .../src/kmc-copy/config/api-extractor.json | 12 ----- .../src/kmc-copy/src/KeymanProjectCopier.ts | 22 +++++---- developer/src/kmc-generate/build.sh | 2 +- .../kmc-generate/config/api-extractor.json | 12 ----- developer/src/kmc-keyboard-info/build.sh | 2 +- .../config/api-extractor.json | 12 ----- developer/src/kmc-kmn/build.sh | 2 +- .../src/kmc-kmn/config/api-extractor.json | 12 ----- developer/src/kmc-kmn/src/compiler/osk.ts | 36 +++++++++++++- developer/src/kmc-ldml/build.sh | 2 +- .../src/kmc-ldml/config/api-extractor.json | 12 ----- developer/src/kmc-model-info/build.sh | 2 +- .../kmc-model-info/config/api-extractor.json | 12 ----- developer/src/kmc-model/build.sh | 2 +- .../src/kmc-model/config/api-extractor.json | 12 ----- developer/src/kmc-package/build.sh | 2 +- .../src/kmc-package/config/api-extractor.json | 12 ----- resources/build/typescript.inc.sh | 49 +++++++++++++++++++ 29 files changed, 187 insertions(+), 139 deletions(-) create mode 100644 common/tools/api-extractor/README.md rename {developer/config => common/tools/api-extractor}/api-extractor.base.json (99%) create mode 100644 common/tools/api-extractor/api-extractor.template.json create mode 100644 common/tools/api-extractor/tsdoc.template.json delete mode 100644 developer/src/kmc-analyze/config/api-extractor.json delete mode 100644 developer/src/kmc-copy/config/api-extractor.json delete mode 100644 developer/src/kmc-generate/config/api-extractor.json delete mode 100644 developer/src/kmc-keyboard-info/config/api-extractor.json delete mode 100644 developer/src/kmc-kmn/config/api-extractor.json delete mode 100644 developer/src/kmc-ldml/config/api-extractor.json delete mode 100644 developer/src/kmc-model-info/config/api-extractor.json delete mode 100644 developer/src/kmc-model/config/api-extractor.json delete mode 100644 developer/src/kmc-package/config/api-extractor.json diff --git a/.gitignore b/.gitignore index 7b87219588..eaf8f5f3eb 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,7 @@ lcov.info # flag file for build script .configured + +# see common/tools/api-extractor/README.md +tsdoc.json +api-extractor.json diff --git a/common/tools/api-extractor/README.md b/common/tools/api-extractor/README.md new file mode 100644 index 0000000000..e936730929 --- /dev/null +++ b/common/tools/api-extractor/README.md @@ -0,0 +1,35 @@ +# api-extractor.template.json + +* Reference: https://api-extractor.com + +api-extractor.template.json contains a template for api-extractor; these parameters +cannot be passed in to the tool, so we modify this template as needed with the following +parameters: + +* `$keyman_root`: the `$KEYMAN_ROOT` variable, with backslash \ translated to forward slash / +* `$index_d_ts`: the filename `index.d.ts` or the corresponding filename for the + entry point of the project +* `$project_path`: the path for the module, relative to the base of the repo +* `$report_temp`: a temporary path for output files for api-extractor +* `$report_folder`: target folder for completed api-extractor API documentation + +# tsdoc.template.json + +* Reference: https://tsdoc.org/pages/packages/tsdoc-config/ + +tsdoc.template.json is copied (unmodified) from this folder into tsdoc.json in +project folders (alongside tsconfig.json) before running api-extractor and +removed again afterwards; there is no way to specify an alternate location for +the file. + +tsdoc.template.json includes a definition for "@since" which has been proposed in +https://github.com/microsoft/tsdoc/issues/136. + +# Notes + +These files are used by `typescript_run_api_extractor()` in typescript.inc.sh. + +This is setup only for Developer projects at this time as outputs go into +developer/docs and developer/build; future generalization requires changing only +`$report_temp` and `$report_folder` parameters in +`typescript_run_api_extractor()`. diff --git a/developer/config/api-extractor.base.json b/common/tools/api-extractor/api-extractor.base.json similarity index 99% rename from developer/config/api-extractor.base.json rename to common/tools/api-extractor/api-extractor.base.json index 2c11f6402d..4ba43a6451 100644 --- a/developer/config/api-extractor.base.json +++ b/common/tools/api-extractor/api-extractor.base.json @@ -161,7 +161,7 @@ * SUPPORTED TOKENS: , , * DEFAULT VALUE: "/etc/" */ - "reportFolder": "../docs/api/etc/", + // "reportFolder": "../docs/api/etc/", /** * Specifies the folder where the temporary report file is written. The file name portion is determined by @@ -176,7 +176,7 @@ * SUPPORTED TOKENS: , , * DEFAULT VALUE: "/temp/" */ - "reportTempFolder": "../build/api/", + // "reportTempFolder": "../build/api/", /** * Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations @@ -206,7 +206,7 @@ * SUPPORTED TOKENS: , , * DEFAULT VALUE: "/build/temp/.api.json" */ - "apiJsonFilePath": "../build/api/.api.json", + // "apiJsonFilePath": "../build/api/.api.json", /** * Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations diff --git a/common/tools/api-extractor/api-extractor.template.json b/common/tools/api-extractor/api-extractor.template.json new file mode 100644 index 0000000000..ccc76623cd --- /dev/null +++ b/common/tools/api-extractor/api-extractor.template.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "$keyman_root/common/tools/api-extractor/api-extractor.base.json", + "mainEntryPointFilePath": "/build/src/$index_d_ts", + "docModel": { + "enabled": true, + "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/$project_path", + "apiJsonFilePath": "$report_temp/.api.json" + }, + "apiReport": { + "enabled": true, + "reportFolder": "$report_folder/", + "reportTempFolder": "$report_temp/" + } +} diff --git a/common/tools/api-extractor/tsdoc.template.json b/common/tools/api-extractor/tsdoc.template.json new file mode 100644 index 0000000000..2a5a8d130b --- /dev/null +++ b/common/tools/api-extractor/tsdoc.template.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@since", + "syntaxKind": "block", + "allowMultiple": false + } + ], + + "supportForTags": { + "@since": true + } +} \ No newline at end of file diff --git a/developer/docs/api/etc/kmc-copy.api.md b/developer/docs/api/etc/kmc-copy.api.md index f61dd59e10..3ec2789a6c 100644 --- a/developer/docs/api/etc/kmc-copy.api.md +++ b/developer/docs/api/etc/kmc-copy.api.md @@ -214,7 +214,7 @@ export interface CopierOptions extends CompilerBaseOptions { relocateExternalFiles?: boolean; } -// @public (undocumented) +// @public export class KeymanProjectCopier implements KeymanCompiler { // Warning: (ae-forgotten-export) The symbol "CopierAsyncCallbacks" needs to be exported by the entry point main.d.ts // diff --git a/developer/docs/api/etc/kmc-kmn.api.md b/developer/docs/api/etc/kmc-kmn.api.md index 0805075e56..b4372a99c2 100644 --- a/developer/docs/api/etc/kmc-kmn.api.md +++ b/developer/docs/api/etc/kmc-kmn.api.md @@ -933,21 +933,21 @@ declare namespace Osk { } export { Osk } -// @public (undocumented) +// @public function parseMapping(mapping: any): PuaMap; -// @public (undocumented) +// @public type PuaMap = { [index: string]: string; }; -// @public (undocumented) +// @public function remapTouchLayout(source: TouchLayout.TouchLayoutFile, map: PuaMap): boolean; -// @public (undocumented) +// @public function remapVisualKeyboard(vk: VisualKeyboard.VisualKeyboard, map: PuaMap): boolean; -// @public (undocumented) +// @public interface StringRef { // (undocumented) str: string; @@ -955,7 +955,7 @@ interface StringRef { usages: StringRefUsage[]; } -// @public (undocumented) +// @public interface StringRefUsage { // (undocumented) count: number; diff --git a/developer/src/kmc-analyze/build.sh b/developer/src/kmc-analyze/build.sh index f28c822b5a..1bfde71585 100755 --- a/developer/src/kmc-analyze/build.sh +++ b/developer/src/kmc-analyze/build.sh @@ -27,5 +27,5 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-analyze index.d.ts builder_run_action test typescript_run_eslint_mocha_tests 75 diff --git a/developer/src/kmc-analyze/config/api-extractor.json b/developer/src/kmc-analyze/config/api-extractor.json deleted file mode 100644 index 855dc1f447..0000000000 --- a/developer/src/kmc-analyze/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/index.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-analyze" - } -} diff --git a/developer/src/kmc-analyze/src/osk-character-use/index.ts b/developer/src/kmc-analyze/src/osk-character-use/index.ts index 7058ad6776..4343a3b8c8 100644 --- a/developer/src/kmc-analyze/src/osk-character-use/index.ts +++ b/developer/src/kmc-analyze/src/osk-character-use/index.ts @@ -304,7 +304,7 @@ export class AnalyzeOskCharacterUse { * * - .json: returns the final aggregated data as an array of strings, which * can be joined to form a JSON blob of an object with a single member, - * `map`, which is an array of {@link Osk.StringResult} objects. + * `map`, which is an array of {@link @keymanapp/kmc-kmn#Osk.StringResult} objects. * * @param format - file format to return - can be '.txt', '.md', or '.json' * @returns an array of strings, formatted according to the `format` @@ -324,7 +324,7 @@ export class AnalyzeOskCharacterUse { /** * Load a JSON-format result file to merge from - * @param filename + * @param filename - the full path to the JSON result file to load * @returns */ private loadPreviousMap(filename: string): Osk.StringResult[] { diff --git a/developer/src/kmc-copy/build.sh b/developer/src/kmc-copy/build.sh index fe9ffc1086..f3bc50c85a 100755 --- a/developer/src/kmc-copy/build.sh +++ b/developer/src/kmc-copy/build.sh @@ -33,7 +33,7 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts # note: `export TEST_SAVE_ARTIFACTS=1` to save a copy of artifacts to temp path # note: `export TEST_SAVE_FIXTURES=1` to get a copy of cloud-based fixtures saved to online/ diff --git a/developer/src/kmc-copy/config/api-extractor.json b/developer/src/kmc-copy/config/api-extractor.json deleted file mode 100644 index 12a3371924..0000000000 --- a/developer/src/kmc-copy/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/main.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-copy" - } -} diff --git a/developer/src/kmc-copy/src/KeymanProjectCopier.ts b/developer/src/kmc-copy/src/KeymanProjectCopier.ts index 4218cc6e94..d9bd33ae91 100644 --- a/developer/src/kmc-copy/src/KeymanProjectCopier.ts +++ b/developer/src/kmc-copy/src/KeymanProjectCopier.ts @@ -81,6 +81,10 @@ export interface CopierResult extends KeymanCompilerResult { artifacts: CopierArtifacts; }; +/** + * @public + * Copy a project and rename internal references + */ export class KeymanProjectCopier implements KeymanCompiler { options: CopierOptions; callbacks: CompilerCallbacks; @@ -111,8 +115,8 @@ export class KeymanProjectCopier implements KeymanCompiler { * artifacts on success. The files are passed in by name, and the compiler * will use callbacks as passed to the {@link KeymanProjectCopier.init} * function to read any input files by disk. - * @param source Source file or folder to copy. Can be a local file or folder, https://github.com/.../repo[/path], or cloud:id - * @returns Binary artifacts on success, null on failure. + * @param source - Source file or folder to copy. Can be a local file or folder, https://github.com/.../repo[/path], or cloud:id + * @returns Binary artifacts on success, null on failure. */ public async run(source: string): Promise { @@ -174,7 +178,7 @@ export class KeymanProjectCopier implements KeymanCompiler { /** * Resolve the source project file to either a local filesystem file, * or a reference on GitHub - * @param source + * @param source - URI to a project file * @returns path to .kpj (either local or remote) */ private async getSourceProject(source: string): Promise { @@ -196,7 +200,7 @@ export class KeymanProjectCopier implements KeymanCompiler { /** * Resolve source path to the contained project file; the project * file must have the same basename as the folder in this case - * @param source + * @param source - local file path to a .kpj project file * @returns */ private getLocalFolderProject(source: string): string { @@ -212,7 +216,7 @@ export class KeymanProjectCopier implements KeymanCompiler { /** * Resolve source path to the input .kpj filename, folder name * is not relevant when .kpj filename is passed in - * @param source + * @param source - local file path to a .kpj project file * @returns */ private getLocalFileProject(source: string): string { @@ -224,7 +228,7 @@ export class KeymanProjectCopier implements KeymanCompiler { * `[https://]github.com/owner/repo/branch/path/to/kpj` * The path must be fully qualified, referencing the .kpj file; it * cannot just be the folder where the .kpj is found - * @param source + * @param source - URL to a .kpj project file on GitHub * @returns a promise: GitHub reference to the source for the keyboard, or null on failure */ private async getGitHubSourceProject(source: string): Promise { @@ -273,7 +277,7 @@ export class KeymanProjectCopier implements KeymanCompiler { * The `keyboard_id` parameter should be a valid id (a-z0-9_), as found at * https://keyman.com/keyboards; alternatively if it is a model_id, it should * have the format author.bcp47.uniq - * @param source + * @param source - a reference to a keyboard or model project on Keyman Cloud * @returns a promise: GitHub reference to the source for the keyboard, or null on failure */ private async getCloudSourceProject(source: string): Promise { @@ -613,8 +617,8 @@ export class KeymanProjectCopier implements KeymanCompiler { /** * renames matching filename to the output filename pattern, and prepends the * outputPath - * @param filename - * @param outputPath + * @param filename - input filename to rename + * @param outputPath - target filename path * @returns */ private generateNewFilename(filename: string, outputPath: string): string { diff --git a/developer/src/kmc-generate/build.sh b/developer/src/kmc-generate/build.sh index d67cbaf21a..fd4c71de6e 100755 --- a/developer/src/kmc-generate/build.sh +++ b/developer/src/kmc-generate/build.sh @@ -39,5 +39,5 @@ do_build() { builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build do_build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts builder_run_action test typescript_run_eslint_mocha_tests diff --git a/developer/src/kmc-generate/config/api-extractor.json b/developer/src/kmc-generate/config/api-extractor.json deleted file mode 100644 index 5ab7b50e16..0000000000 --- a/developer/src/kmc-generate/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/main.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-generate" - } -} diff --git a/developer/src/kmc-keyboard-info/build.sh b/developer/src/kmc-keyboard-info/build.sh index fe8ecfac58..58b415b47f 100755 --- a/developer/src/kmc-keyboard-info/build.sh +++ b/developer/src/kmc-keyboard-info/build.sh @@ -32,7 +32,7 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy index.d.ts builder_run_action test typescript_run_eslint_mocha_tests #------------------------------------------------------------------------------------------------------------------- diff --git a/developer/src/kmc-keyboard-info/config/api-extractor.json b/developer/src/kmc-keyboard-info/config/api-extractor.json deleted file mode 100644 index b98feb9963..0000000000 --- a/developer/src/kmc-keyboard-info/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/index.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-keyboard-info" - } -} diff --git a/developer/src/kmc-kmn/build.sh b/developer/src/kmc-kmn/build.sh index 27fb958486..bb64f5c48d 100755 --- a/developer/src/kmc-kmn/build.sh +++ b/developer/src/kmc-kmn/build.sh @@ -65,5 +65,5 @@ function do_test() { } builder_run_action build do_build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts builder_run_action test do_test diff --git a/developer/src/kmc-kmn/config/api-extractor.json b/developer/src/kmc-kmn/config/api-extractor.json deleted file mode 100644 index 5df197da3e..0000000000 --- a/developer/src/kmc-kmn/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/main.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-kmn" - } -} diff --git a/developer/src/kmc-kmn/src/compiler/osk.ts b/developer/src/kmc-kmn/src/compiler/osk.ts index e26c124b15..f9194c1642 100644 --- a/developer/src/kmc-kmn/src/compiler/osk.ts +++ b/developer/src/kmc-kmn/src/compiler/osk.ts @@ -2,17 +2,27 @@ import { TouchLayout } from "@keymanapp/common-types"; import { VisualKeyboard } from "@keymanapp/common-types"; import { SchemaValidators } from "@keymanapp/common-types"; +/** + * @public + * Records the number of references to an OSK key cap string for a specific + * file + */ export interface StringRefUsage { filename: string; count: number; }; +/** + * @public + * Tracks usage of a single OSK key cap string across multiple files + */ export interface StringRef { str: string; usages: StringRefUsage[]; }; /** + * @public * Represents a single key cap found by `AnalyzeOskCharacterUse` */ export interface StringResult { @@ -23,13 +33,23 @@ export interface StringResult { /** hexadecimal single character in PUA range, without 'U+' prefix, e.g. 'F100' */ pua: string; /** files in which the string is referenced; will be an array of - * {@link StringRefUsage} if includeCounts is true, otherwise will be an array + * {@link @keymanapp/kmc-kmn#Osk.StringRefUsage} if includeCounts is true, otherwise will be an array * of strings listing files in which the key cap may be found */ usages: StringRefUsage[] | string[]; }; +/** + * @public + * Maps a source OSK key cap string to a PUA character + */ export type PuaMap = {[index:string]: string}; +/** + * @public + * Parse a map object loaded from a displaymap file into a PuaMap + * @param mapping - source object to parse, must be in displayMap JSON format + * @returns + */ export function parseMapping(mapping: any) { if(!SchemaValidators.default.displayMap(mapping)) /* c8 ignore next 3 */ @@ -60,6 +80,13 @@ function remap(text: string, map: PuaMap) { return text; } +/** + * @public + * Remap key caps in the `vk` visual keyboard object to use PUA characters from `map` + * @param vk - source visual keyboard object to remap, updated in place + * @param map - PUA string mapping to apply + * @returns + */ export function remapVisualKeyboard(vk: VisualKeyboard.VisualKeyboard, map: PuaMap): boolean { let dirty = false; for(const key of vk.keys) { @@ -73,6 +100,13 @@ export function remapVisualKeyboard(vk: VisualKeyboard.VisualKeyboard, map: PuaM return dirty; } +/** + * @public + * Remap key caps in the `source` touch layout object to use PUA characters from `map` + * @param source - source touch layout object to remap, updated in place + * @param map - PUA string mapping to apply + * @returns + */ export function remapTouchLayout(source: TouchLayout.TouchLayoutFile, map: PuaMap) { let dirty = false; const scanKey = (key: TouchLayout.TouchLayoutKey | TouchLayout.TouchLayoutSubKey) => { diff --git a/developer/src/kmc-ldml/build.sh b/developer/src/kmc-ldml/build.sh index 708a4c9b3d..627286551c 100755 --- a/developer/src/kmc-ldml/build.sh +++ b/developer/src/kmc-ldml/build.sh @@ -85,5 +85,5 @@ builder_run_action clean do_clean builder_run_action configure do_configure builder_run_action build do_build builder_run_action build-fixtures do_build_fixtures -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts builder_run_action test typescript_run_eslint_mocha_tests 90 diff --git a/developer/src/kmc-ldml/config/api-extractor.json b/developer/src/kmc-ldml/config/api-extractor.json deleted file mode 100644 index 9022460bf8..0000000000 --- a/developer/src/kmc-ldml/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/main.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-ldml" - } -} diff --git a/developer/src/kmc-model-info/build.sh b/developer/src/kmc-model-info/build.sh index fb4743700f..deb29bcd01 100755 --- a/developer/src/kmc-model-info/build.sh +++ b/developer/src/kmc-model-info/build.sh @@ -29,5 +29,5 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy index.d.ts builder_run_action test typescript_run_eslint_mocha_tests 55 diff --git a/developer/src/kmc-model-info/config/api-extractor.json b/developer/src/kmc-model-info/config/api-extractor.json deleted file mode 100644 index 73bd279892..0000000000 --- a/developer/src/kmc-model-info/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/index.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-model-info" - } -} diff --git a/developer/src/kmc-model/build.sh b/developer/src/kmc-model/build.sh index 4b5f15cc30..ab32fbd153 100755 --- a/developer/src/kmc-model/build.sh +++ b/developer/src/kmc-model/build.sh @@ -36,5 +36,5 @@ function do_build() { builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build do_build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts builder_run_action test typescript_run_eslint_mocha_tests diff --git a/developer/src/kmc-model/config/api-extractor.json b/developer/src/kmc-model/config/api-extractor.json deleted file mode 100644 index de898e137d..0000000000 --- a/developer/src/kmc-model/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/main.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-model" - } -} diff --git a/developer/src/kmc-package/build.sh b/developer/src/kmc-package/build.sh index f1d6ba8dcc..d435bb5919 100755 --- a/developer/src/kmc-package/build.sh +++ b/developer/src/kmc-package/build.sh @@ -34,5 +34,5 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api api-extractor run --local --verbose +builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts builder_run_action test typescript_run_eslint_mocha_tests diff --git a/developer/src/kmc-package/config/api-extractor.json b/developer/src/kmc-package/config/api-extractor.json deleted file mode 100644 index 6ba3ee01fd..0000000000 --- a/developer/src/kmc-package/config/api-extractor.json +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Config file for API Extractor. For more info, please visit: https://api-extractor.com - */ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "../../../config/api-extractor.base.json", - "mainEntryPointFilePath": "/build/src/main.d.ts", - "docModel": { - "enabled": true, - "projectFolderUrl": "http://github.com/keymanapp/keyman/tree/master/developer/src/kmc-package" - } -} diff --git a/resources/build/typescript.inc.sh b/resources/build/typescript.inc.sh index ef6f58d68a..fe39d870d1 100644 --- a/resources/build/typescript.inc.sh +++ b/resources/build/typescript.inc.sh @@ -58,3 +58,52 @@ typescript_run_eslint_mocha_tests() { echo "##teamcity[flowFinished flowId='unit_tests']" fi } + +# +# Run api-extractor, preparing config files for each folder. As the config files +# are largely identical for each project, we copy them in rather than +# duplicating them across the repo (as that is hard to maintain over time) +# +# NOTE: this is setup only for Developer projects at this time as outputs go +# into developer/docs and developer/build; future generalization requires +# changing only report_temp and report_folder parameters. +# +# See also: /common/tools/api-extractor/README.md +# +typescript_run_api_extractor() { + project_path="$1" + index_d_ts="$2" + + # tsdoc config file must be in same folder as tsconfig.json + cp "${KEYMAN_ROOT}/common/tools/api-extractor/tsdoc.template.json" "${THIS_SCRIPT_PATH}/tsdoc.json" + + # api-extractor configuration must be stored in a file, so patch the + # file with the relevant parameters + + if builder_is_windows; then + # replace \ with / on Windows in KEYMAN_ROOT path, so we don't end up with + # silly unescaped strings in JSON + keyman_root="$(echo "$KEYMAN_ROOT" | sed "s=\\\=/=g")" + else + keyman_root="${KEYMAN_ROOT}" + fi + + # For now, these two variables are Developer-specific + report_temp="$keyman_root/developer/build/api" + report_folder="$keyman_root/developer/docs/api/etc" + export project_path index_d_ts keyman_root report_temp report_folder + + envsubst "\$keyman_root,\$project_path,\$index_d_ts,\$report_temp,\$report_folder" \ + < "${KEYMAN_ROOT}/common/tools/api-extractor/api-extractor.template.json" \ + > "${THIS_SCRIPT_PATH}/api-extractor.json" + + export -n project_path index_d_ts keyman_root report_temp report_folder + + api-extractor run \ + --local \ + --verbose \ + --config "${THIS_SCRIPT_PATH}/api-extractor.json" + + rm "${THIS_SCRIPT_PATH}/tsdoc.json" + rm "${THIS_SCRIPT_PATH}/api-extractor.json" +} \ No newline at end of file From 77ad99ee256ce8bd71c5e627f1a80849269f2b85 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 25 Jun 2026 11:02:46 +0200 Subject: [PATCH 056/148] feat(android): add keyman-version to package-version check For Keyman for Android, add the new `keyman-version` parameter to the api.keyman.com/package-version call so that updates to packages that are not supported on the current version of Keyman will not be offered. This supports the scenario where an updated keyboard or lexical model depends on a newer version of Keyman. Note that an older version of the keyboard or lexical model package will not be offered (the user can still download and install an older version manually, but generally, the recommended solution is to upgrade Keyman; there would be significant cost to add support for querying and installation of older version keyboards on the server side, for limited benefit). Relates-to: keymanapp/api.keyman.com#325 --- .../src/main/java/com/keyman/engine/data/CloudRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java index 4676d59c77..5bd4d8d684 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java @@ -45,7 +45,7 @@ public class CloudRepository { public static final String API_STAGING_HOST = "api.keyman.com"; // #7227 disabling: "api.keyman-staging.com"; public static final String API_MODEL_LANGUAGE_FORMATSTR = "https://%s/model?q=bcp47:%s"; - public static final String API_PACKAGE_VERSION_FORMATSTR = "https://%s/package-version?platform=android%s%s"; + public static final String API_PACKAGE_VERSION_FORMATSTR = "https://%s/package-version?platform=android&keyman-version=%s%s%s"; private Dataset memCachedDataset; private Calendar lastLoad; // To be used for Dataset caching. @@ -205,7 +205,7 @@ public class CloudRepository { } } - String queryURL = String.format(API_PACKAGE_VERSION_FORMATSTR, getHost(), keyboardQuery, lexicalModelQuery); + String queryURL = String.format(API_PACKAGE_VERSION_FORMATSTR, getHost(), KMManager.getVersion(), keyboardQuery, lexicalModelQuery); return new CloudApiTypes.CloudApiParam( CloudApiTypes.ApiTarget.PackageVersion, queryURL).setType(CloudApiTypes.JSONType.Object); } From bbeb799ac2a686b27ae28a43c2d3eef874ce1d67 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 25 Jun 2026 10:53:31 +0200 Subject: [PATCH 057/148] feat(ios): add keyman-version to package-version check For Keyman for iOS, add the new `keyman-version` parameter to the api.keyman.com/package-version call so that updates to packages that are not supported on the current version of Keyman will not be offered. This supports the scenario where an updated keyboard or lexical model depends on a newer version of Keyman. Note that an older version of the keyboard or lexical model package will not be offered (the user can still download and install an older version manually, but generally, the recommended solution is to upgrade Keyman; there would be significant cost to add support for querying and installation of older version keyboards on the server side, for limited benefit). Also change order of handling for response so that error responses are recognized even if non-error fields are present. (The current api endpoint, before keymanapp/api.keyman.com#325 lands, can return `kmp` and `version` fields even when an `error` field was present, and the iOS code would see that as a valid response for upgrade, when it shouldn't. The keymanapp/api.keyman.com#325 change ensures that the additional fields are not set if there is an `error` field.) Relates-to: keymanapp/api.keyman.com#325 --- .../Classes/Queries/Queries+PackageVersion.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift index 69ca1e15a9..769089fdbc 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift @@ -52,10 +52,10 @@ extension Queries { func dictionaryReducer(container: KeyedDecodingContainer, category: String) -> ([String: ResultComponent], Queries.IDCodingKey) throws -> [String: ResultComponent] { return { (dict, id) -> [String: ResultComponent] in var dict = dict - if let entry = try? container.decode(ResultEntry.self, forKey: id) { - dict[id.stringValue] = entry - } else if let error = try? container.decode(ResultError.self, forKey: id) { + if let error = try? container.decode(ResultError.self, forKey: id) { dict[id.stringValue] = error + } else if let entry = try? container.decode(ResultEntry.self, forKey: id) { + dict[id.stringValue] = entry } else { throw FetchError.decodingError(category, id.stringValue) } @@ -116,7 +116,10 @@ extension Queries { return URLQueryItem(name: resourceField, value: key.id) } - urlComponents.queryItems = queryItems + [URLQueryItem(name: "platform", value: "ios")] + urlComponents.queryItems = queryItems + [ + URLQueryItem(name: "platform", value: "ios"), + URLQueryItem(name: "keyman-version", value: Version.current.plainString) + ] let message = "Querying package versions through API endpoint: \(urlComponents.url!)" os_log("%{public}s", log:KeymanEngineLogger.resources, type: .info, message) SentryManager.breadcrumb(message) From e387588503c628b9267c18ea4cb884e4777fa02d Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 25 Jun 2026 12:27:00 +0200 Subject: [PATCH 058/148] fix(developer): warn only on race when destroying TAppSourceHttpResponder I uncovered one race, documented in the source. Not trying to resolve that race at this time (it is not consequential). There is a second condition which is unclear -- and may have other consequences. So report a warning to Sentry when this arises, but do not crash out on the user. Fixes: #11916 Test-bot: skip --- ....Developer.System.HttpServer.AppSource.pas | 19 +++++++++++++++++-- developer/src/tike/main/UframeTextEditor.pas | 7 ++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas index 904d389a08..5c125aa4cd 100644 --- a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas +++ b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas @@ -79,8 +79,23 @@ begin // Note: Unlike regular functions, Assert has short-circuit evaluation // intrinsics on the first param which makes it safe to dereference T[0] in // the second parameter. - Assert(T.Count = 0, 'TAppSourceHttpResponder.Sources should be empty at destruction '+ - '(T.Count='+IntToStr(T.Count)+', T[0].Filename='+T[0].Filename+')'); + + // There is a race where RegisterSource is called on the server side + // where a request is started in the form but the server does not respond + // before the form is destroyed: + // 1. http request starts on form + // 2. Form destroyed, calls UnregisterSource + // 3. http request received in TAppSourceHttpResponder, + // RespondTouchEditorState calls RegisterSource + // 4. Ooops + + // There is another race somewhere with unsaved text editors, or else a + // resource leak. For now, we'll report this as a message rather than crash. + if T.Count > 0 then + begin + TKeymanSentryClient.Instance.ReportMessage('TAppSourceHttpResponder.Sources should be empty at destruction '+ + '(T.Count='+IntToStr(T.Count)+', T[0].Filename='+T[0].Filename+')', True); + end; finally FSources.UnlockList; end; diff --git a/developer/src/tike/main/UframeTextEditor.pas b/developer/src/tike/main/UframeTextEditor.pas index de6e293774..b9f5f8ce22 100644 --- a/developer/src/tike/main/UframeTextEditor.pas +++ b/developer/src/tike/main/UframeTextEditor.pas @@ -486,7 +486,12 @@ procedure TframeTextEditor.LoadFileInBrowser(const AData: string); function GenerateNewFilename: string; begin Inc(FInitialFilenameIndex); - Result := '*texteditor*'+IntToStr(FInitialFilenameIndex); + Result := '*texteditor'; + if Owner <> nil then + Result := Result + '*' + Owner.ClassName; + if Parent <> nil then + Result := Result + '*' + Parent.Name; + Result := Result + '*'+IntToStr(FInitialFilenameIndex); end; function EncodeFont(const prefix: string; f: TFont): string; begin From b14fe90894bffe61af158f98c2268a93f457ee12 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 26 Jun 2026 09:01:16 -0500 Subject: [PATCH 059/148] change(web): polish documentation + uses of transition ID in predictive text - Drops the `Suggestion.transformId` field in favor of just reusing `.transform.id` - Updates documentation on .transform.id as a unique identifier of the transition, not specific to a single Transform. Does not modify the `.transform` or `.id` properties because we've been publishing lexical model types, and that would change the public model API. Build-bot: skip build:web Test-bot: skip --- common/web/types/src/lexical-model-types.ts | 18 +++++----- .../predictive-text/templates/src/common.ts | 4 --- .../src/main/correction/context-state.ts | 2 +- .../src/main/correction/context-token.ts | 10 +++--- .../main/correction/context-tokenization.ts | 4 +-- .../src/main/correction/context-transition.ts | 12 +++---- .../src/main/model-compositor.ts | 31 +++++++++-------- .../src/main/models/dummy-model.ts | 3 +- .../worker-thread/src/main/predict-helpers.ts | 15 +++------ .../prediction/predictionContext.ts | 2 +- .../src/main/headless/inputProcessor.ts | 2 +- .../src/main/headless/languageProcessor.ts | 6 ++-- .../engine/main/inputProcessor.tests.ts | 3 +- .../predictive-text/templates/common.tests.ts | 10 +++--- .../context/context-tokenization.tests.ts | 32 +++++++++--------- .../context/context-tracker.tests.ts | 2 +- .../context/context-transition.tests.ts | 32 +++++++----------- .../base-context-state.tests.ts | 2 -- ...ine-suggestion-context-transition.tests.ts | 21 +++++------- .../predict-from-corrections.tests.ts | 1 - .../worker-model-compositor.tests.ts | 33 ++++++++----------- 21 files changed, 107 insertions(+), 138 deletions(-) diff --git a/common/web/types/src/lexical-model-types.ts b/common/web/types/src/lexical-model-types.ts index 37a761608d..a3c8df1c98 100644 --- a/common/web/types/src/lexical-model-types.ts +++ b/common/web/types/src/lexical-model-types.ts @@ -253,9 +253,14 @@ export interface LexicalModel { */ export interface Transform { /** - * Facilitates use of unique identifiers for tracking the Transform and - * any related data from its original source, as the reference cannot be - * preserved across WebWorker boundaries. + * Facilitates use of unique identifiers for tracking data about the context + * transition to which the Transform belongs. More than one Transform may + * hold the same `id` if they are alternate interpretations of the same + * transition event - say, the resulting effects of neighbor keys that may + * have been missed due to "fat fingering". + * + * Also note that the Transform reference cannot be preserved across WebWorker + * boundaries, but this ID may. * * This is *separate* from any LMLayer-internal identification values. */ @@ -287,13 +292,6 @@ export interface Transform { * A concrete suggestion */ export interface Suggestion { - /** - * Indicates the externally-supplied id of the Transform that prompted - * the Suggestion. Automatically handled by the LMLayer; models should - * not handle this field. - */ - transformId?: number; - /** * A unique identifier for the Suggestion itself, not shared with any others - * even for Suggestions sourced from the same Transform. diff --git a/web/src/engine/predictive-text/templates/src/common.ts b/web/src/engine/predictive-text/templates/src/common.ts index ec6e8a09e5..d141f1f9ae 100644 --- a/web/src/engine/predictive-text/templates/src/common.ts +++ b/web/src/engine/predictive-text/templates/src/common.ts @@ -132,10 +132,6 @@ export function transformToSuggestion(transform: Transform, p?: number): Outcome displayAs: transform.insert }; - if(transform.id !== undefined) { - suggestion.transformId = transform.id; - } - if(p === 0 || p) { suggestion.p = p; } diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts index 673acf5493..bc9b412bf3 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts @@ -89,7 +89,7 @@ export class ContextState { return undefined; } - return this.suggestions.find(s => s.id == this.appliedSuggestionId)?.transformId; + return this.suggestions.find(s => s.id == this.appliedSuggestionId)?.transform.id; } /** diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts index 942773fa57..0a772514f3 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts @@ -27,12 +27,12 @@ import Transform = LexicalModelTypes.Transform; * any prior cached data or for rewriting its probabilities after * receiving backspace input. * @param text - * @param transformId + * @param transitionId * @returns */ -function textToCharTransforms(text: string, transformId?: number): Transform[] { - return transformId ? - [...text].map(insert => ({insert, deleteLeft: 0, id: transformId})) : +function textToCharTransforms(text: string, transitionId?: number): Transform[] { + return transitionId ? + [...text].map(insert => ({insert, deleteLeft: 0, id: transitionId})) : [...text].map(insert => ({insert, deleteLeft: 0})); } @@ -100,7 +100,7 @@ export class ContextToken { static fromRawText(model: LexicalModel, rawText: string, isPartial?: boolean) { rawText ||= ''; - // Supports the old pathway for: updateWithBackspace(tokenText: string, transformId: number) + // Supports the old pathway for: updateWithBackspace(tokenText: string, transitionId: number) // Build a token that represents the current text with no ambiguity - probability at max (1.0) let searchModule: SearchQuotientNode = new LegacyQuotientRoot(model); const BASE_PROBABILITY = 1; diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts index dcd7b0a295..ee8b22e53f 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts @@ -613,8 +613,8 @@ export class ContextTokenization { * @param transitionEdge Batched results from one or more * `precomputeTokenizationAfterInput` calls on this instance, all with the * same alignment values. - * @param transitionId The id of the Transform associated with the keystroke - * triggering the transition. + * @param transitionId The id of the context transition associated with the + * Transition * @param bestProbFromSet The probability of the single most likely input * transform in the overall transformDistribution associated with the * keystroke triggering the transition. It need not be represented by the diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts index a75245908e..c65f348996 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts @@ -36,7 +36,7 @@ export class ContextTransition { */ inputDistribution?: Distribution; - // The transform ID in play. + // The transition ID in play. private _transitionId?: number; /** @@ -177,7 +177,7 @@ export class ContextTransition { // We won't try to partially revert a multi-word suggestion; reversions // are only supported at the end of the last word of the main suggestion // body and after any appended whitespace. - resultingTokenization.tail.appliedTransitionId = suggestion.transformId; + resultingTokenization.tail.appliedTransitionId = suggestion.transform.id; const resultingState = new ContextState(applyTransform(transformToApply, baseState.context), lexicalModel); resultingState.tokenization = resultingTokenization; // [resultingTokenization].concat(preservedVariations); @@ -185,12 +185,12 @@ export class ContextTransition { resultingState.appliedSuggestionId = suggestion.id; resultingState.suggestions = this.final.suggestions; - // Use the transform's ID for the transition. Note that when applying the + // Use the transition ID tracked on the Transform. Note that when applying the // `appendedTransform` component of a suggestion, this will differ from - // suggestion.transformId. + // suggestion.transitionId. const resultingTransition = new ContextTransition(baseState, transformToApply.id); resultingTransition.finalize(resultingState, inputDistribution); - resultingTransition.revertableTransitionId = suggestion.transformId; + resultingTransition.revertableTransitionId = suggestion.transform.id; // .finalize unsets _.transitionId; re-assign it. resultingTransition._transitionId = transformToApply.id; @@ -232,7 +232,7 @@ export class ContextTransition { const baseTokenizationLength = results.transition.final.displayTokenization.tokens.length; const appliedTokenization = appendingTransition.final.displayTokenization; for(let i = baseTokenizationLength; i < appliedTokenization.tokens.length; i++) { - appliedTokenization.tokens[i].appliedTransitionId = suggestion.transformId; + appliedTokenization.tokens[i].appliedTransitionId = suggestion.transform.id; } return { diff --git a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts index 5361c198e0..64d6ae4e20 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts @@ -81,12 +81,12 @@ export class ModelCompositor { this.configuration = config; } - initContextTracker(context: Context, transformId: number) { + initContextTracker(context: Context, transitionId: number) { if(this.contextTracker || !this.lexicalModel.traverseFromRoot) { return; } - this._contextTracker = new ContextTracker(this.lexicalModel, context, transformId, this.configuration); + this._contextTracker = new ContextTracker(this.lexicalModel, context, transitionId, this.configuration); } async predict(transformDistribution: Transform | Distribution, context: Context): Promise[]> { @@ -122,8 +122,8 @@ export class ModelCompositor { // Only allow new-word suggestions if space was the most likely keypress. // const allowSpace = TransformUtils.isWhitespace(inputTransform); const inputTransform = transformDistribution[0].sample; - const transformId = inputTransform.id; - this.initContextTracker(context, transformId); + const transitionId = inputTransform.id; + this.initContextTracker(context, transitionId); const allowBksp = TransformUtils.isBackspace(inputTransform); const allowWhitespace = TransformUtils.isWhitespace(inputTransform); @@ -240,6 +240,8 @@ export class ModelCompositor { // The Web engine will restore the original state of the context before accepting // and before reverting; all we need to do is put the original keystroke back in place. let reversionTransform: Transform = originalInput ?? { insert: '', deleteLeft: 0 }; + // clone the transform to prevent aliasing + reversionTransform = {...reversionTransform}; // Step 2: building the proper 'displayAs' string for the Reversion const postContext = originalInput ? models.applyTransform(originalInput, context) : context; @@ -263,8 +265,8 @@ export class ModelCompositor { // Since we're outside of the standard `predict` control path, we'll need to // set the Reversion's ID directly. let reversion = toAnnotatedSuggestion(this.lexicalModel, firstConversion, 'revert'); - if(suggestion.transformId != null) { - reversion.transformId = -suggestion.transformId; + if(suggestion.transform.id != null) { + reversion.transform.id = -suggestion.transform.id; } if(suggestion.id != null) { // Since a reversion inverts its source suggestion, we set its ID to be the @@ -287,11 +289,11 @@ export class ModelCompositor { } } else { let originalTransition = this.contextTracker.latest; - if(originalTransition.transitionId != suggestion.transformId) { - originalTransition = this.contextTracker.findAndRevert(suggestion.transformId); + if(originalTransition.transitionId != suggestion.transform.id) { + originalTransition = this.contextTracker.findAndRevert(suggestion.transform.id); } if(!originalTransition) { - this.contextTracker.reset(context, suggestion.transformId); + this.contextTracker.reset(context, suggestion.transform.id); originalTransition = this.contextTracker.latest; } @@ -333,7 +335,7 @@ export class ModelCompositor { suggestions.forEach(function(suggestion) { // A reversion's transform ID is the additive inverse of its original suggestion; // we revert to the state of said original suggestion. - suggestion.transformId = -reversion.transformId; + suggestion.transform.id = -reversion.transform.id; // Prevent auto-selection of any suggestion immediately after a reversion. // It's fine after at least one keystroke, but not before. suggestion.autoAccept = false; @@ -347,18 +349,19 @@ export class ModelCompositor { } // When the context is tracked, we prefer the tracked information. - // Note that the base reversion's .transformId will predate the appendedTransform id + // Note that the base reversion's .transform.id will predate the appendedTransform id // used to add whitespace (if one existed), so reverting to the base ID's associated // context also reverts the appendedTransform. - let originalTransition = this.contextTracker.findAndRevert(-reversion.transformId); + const baseTransitionId = -reversion.transform.id; + let originalTransition = this.contextTracker.findAndRevert(baseTransitionId); if(appendedOnly) { this.contextTracker.latest = originalTransition; return Promise.resolve([]); } - if(!originalTransition) { - this.contextTracker.reset(context, -reversion.transformId); + if(!originalTransition || originalTransition.transitionId != baseTransitionId) { + this.contextTracker.reset(context, baseTransitionId); originalTransition = this.contextTracker.latest; suggestions = fallbackSuggestions(); diff --git a/web/src/engine/predictive-text/worker-thread/src/main/models/dummy-model.ts b/web/src/engine/predictive-text/worker-thread/src/main/models/dummy-model.ts index 3b5854ef58..e1651557c4 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/models/dummy-model.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/models/dummy-model.ts @@ -98,12 +98,11 @@ export class DummyModel implements LexicalModel { predict(transform: Transform, context: Context, injectedSuggestions?: Outcome[]): Distribution { let makeUniformDistribution = function(suggestions: Outcome[]): Distribution { let distribution: Distribution = []; + const transitionId = transform.id; for(let s of suggestions) { - const transitionId = transform.id; if(transitionId !== undefined) { // Set the transform ID to match the incoming transform ID if one exists. - s.transformId = transitionId; if(s.transform) { s.transform.id = transitionId; } diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 9739adba85..7df559e93e 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -697,7 +697,7 @@ export function predictFromCorrections( // Let's not rely on the model to copy transform IDs. // Only bother is there IS an ID to copy. if(correctionTransform.id !== undefined) { - pair.sample.transformId = correctionTransform.id; + pair.sample.transform.id = correctionTransform.id; } let tuple: CorrectionPredictionTuple = { @@ -829,7 +829,7 @@ export function processSimilarity( for(let tuple of suggestionDistribution) { // Don't set it unnecessarily; this can have side-effects in some automated tests. if(inputTransform.id !== undefined) { - tuple.prediction.sample.transformId = inputTransform.id; + tuple.prediction.sample.transform.id = inputTransform.id; } const predictedWord = wordbreak(models.applyTransform(tuple.prediction.sample.transform, context)); @@ -907,7 +907,7 @@ export function createDefaultKeep( let keepOption = toAnnotatedSuggestion(lexicalModel, keepSuggestion, 'keep'); if(inputTransform.id !== undefined) { - keepOption.transformId = inputTransform.id; + keepOption.transform.id = inputTransform.id; } keepOption.matchesModel = false; @@ -1084,11 +1084,6 @@ export function finalizeSuggestions( mutableSuggestion.transform = mergedTransform; } - // Is sometimes not set during unit tests. - if(prediction.sample.transformId !== undefined) { - prediction.sample.transform.id = prediction.sample.transformId; - } - if(!verbose) { return { ...prediction.sample, @@ -1189,8 +1184,8 @@ export function toAnnotatedSuggestion( result.appendedTransform = suggestion.appendedTransform; } - if(suggestion.transformId !== undefined) { - result.transformId = suggestion.transformId; + if(suggestion.transform.id !== undefined) { + result.transform.id = suggestion.transform.id; } return result; diff --git a/web/src/engine/src/interfaces/prediction/predictionContext.ts b/web/src/engine/src/interfaces/prediction/predictionContext.ts index 97182a9bf1..2ec526fb37 100644 --- a/web/src/engine/src/interfaces/prediction/predictionContext.ts +++ b/web/src/engine/src/interfaces/prediction/predictionContext.ts @@ -273,7 +273,7 @@ export class PredictionContext extends EventEmitter { // Verify that the transition IDs are still valid and remove special entries. this._currentSuggestions = suggestions.filter(s => { - return this.langProcessor.hasState(Math.abs(s.transformId)) && + return this.langProcessor.hasState(Math.abs(s.transform.id)) && s != this._keepSuggestion && s != this._revertSuggestion }); diff --git a/web/src/engine/src/main/headless/inputProcessor.ts b/web/src/engine/src/main/headless/inputProcessor.ts index 1099b09145..5018e4de02 100644 --- a/web/src/engine/src/main/headless/inputProcessor.ts +++ b/web/src/engine/src/main/headless/inputProcessor.ts @@ -386,7 +386,7 @@ export class InputProcessor { // If so, since it behaves the same in either case, and it's a known word-breaking mark, // let's apply the selected suggestion automatically. if(postApplyTransform.insert == ruleTransform.insert && transformMatchesPattern(postApplyTransform, breakingMarks)) { - const baseTransition = this.contextCache.get(predictionContext.selected.transformId); + const baseTransition = this.contextCache.get(predictionContext.selected.transform.id); // Somehow, the base state is out of context - abort! if(!baseTransition) { diff --git a/web/src/engine/src/main/headless/languageProcessor.ts b/web/src/engine/src/main/headless/languageProcessor.ts index b98d4246df..3130fcaf27 100644 --- a/web/src/engine/src/main/headless/languageProcessor.ts +++ b/web/src/engine/src/main/headless/languageProcessor.ts @@ -218,13 +218,13 @@ export class LanguageProcessor extends EventEmitter { // Find the state of the context at the time the suggestion was generated. // This may refer to the context before an input keystroke or before application // of a predictive suggestion. - const original = this.getPredictionState(suggestion.transformId); + const original = this.getPredictionState(suggestion.transform.id); if(!original) { console.warn("Could not apply the Suggestion!"); return null; } - this.recentTranscriptions.rewindTo(suggestion.transformId); + this.recentTranscriptions.rewindTo(suggestion.transform.id); // Apply the Suggestion! @@ -309,7 +309,7 @@ export class LanguageProcessor extends EventEmitter { // // Reversions use the additive inverse of the id token of the Transcription being // reverted to. - const reversionId = appendedOnly ? reversion.appendedTransform.id : -reversion.transformId; + const reversionId = appendedOnly ? reversion.appendedTransform.id : -reversion.transform.id; const original = this.getPredictionState(reversionId); if(!original) { console.warn("Could not apply the Suggestion!"); diff --git a/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts b/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts index 1d63669537..bb469c723e 100644 --- a/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts +++ b/web/src/test/auto/headless/engine/main/inputProcessor.tests.ts @@ -283,9 +283,8 @@ describe('InputProcessor', function() { [], [ { - transform: { insert: 'testing', deleteLeft: 4 }, + transform: { insert: 'testing', deleteLeft: 4, id: 0 }, appendedTransform: { insert: ' ', deleteLeft: 0 }, - transformId: 0, // will be overwritten by the DummyModel to match the transition ID. id: 1, displayAs: 'testing' } diff --git a/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts b/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts index 5abd27cd75..0c7b010586 100644 --- a/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts @@ -191,7 +191,7 @@ describe('Common utility functions', function() { deleteLeft: 0, id: 0 }, - transformId: 0, + transitionId: 0, displayAs: 'hello' }; @@ -205,7 +205,7 @@ describe('Common utility functions', function() { deleteLeft: 0, id: 0 }, - transformId: 0, + transitionId: 0, displayAs: 'hello', p: 0 }; @@ -220,7 +220,7 @@ describe('Common utility functions', function() { deleteLeft: 0, id: 0 }, - transformId: 0, + transitionId: 0, displayAs: 'hello', p: 0.5 }; @@ -228,14 +228,14 @@ describe('Common utility functions', function() { assert.deepEqual(models.transformToSuggestion(suggestion.transform, 0.5), suggestion); }); - it('properly handles the transformId', function() { + it('properly handles the transitionId', function() { let suggestion = { transform: { insert: 'hello', deleteLeft: 0, id: 3 }, - transformId: 3, // Ensures there isn't a separate ID seed in use. + transitionId: 3, // Ensures there isn't a separate ID seed in use. displayAs: 'hello' }; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts index 3c8351f7d0..dcef6f8f61 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts @@ -45,8 +45,8 @@ function toToken(text: string) { } let TOKEN_TRANSFORM_SEED = 0; -function toTransformToken(text: string, transformId?: number) { - let idSeed = transformId === undefined ? TOKEN_TRANSFORM_SEED++ : transformId; +function toTransitionToken(text: string, transitionId?: number) { + let idSeed = transitionId === undefined ? TOKEN_TRANSFORM_SEED++ : transitionId; let isWhitespace = text == ' '; let token = ContextToken.fromRawText(plainModel, ''); const textAsTransform = { insert: text, deleteLeft: 0, id: idSeed }; @@ -104,7 +104,7 @@ describe('ContextTokenization', function() { it("constructs from a token array + alignment data", () => { const rawTextTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day']; - const tokens = rawTextTokens.map((text => toTransformToken(text))); + const tokens = rawTextTokens.map((text => toTransitionToken(text))); const emptyTransform = { insert: '', deleteLeft: 0, deleteRight: 0 }; // We _could_ flesh this out a bit more... but it's not really needed for this test. @@ -141,7 +141,7 @@ describe('ContextTokenization', function() { it('clones', () => { const rawTextTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day']; - const tokens = rawTextTokens.map((text => toTransformToken(text))); + const tokens = rawTextTokens.map((text => toTransitionToken(text))); const emptyTransform = { insert: '', deleteLeft: 0, deleteRight: 0 }; // We _could_ flesh this out a bit more... but it's not really needed for this test. @@ -830,7 +830,7 @@ describe('ContextTokenization', function() { it('handles empty contexts', () => { const baseTokens = ['']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const results = buildEdgeWindow(baseTokenization.tokens, { insert: '', deleteLeft: 0, deleteRight: 0 }, true, testEdgeWindowSpec); assert.deepEqual(results, { @@ -850,7 +850,7 @@ describe('ContextTokenization', function() { it('handles empty contexts and invalid Transforms', () => { const baseTokens = ['']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const results = buildEdgeWindow(baseTokenization.tokens, { insert: '', deleteLeft: 0, deleteRight: 2 }, true, testEdgeWindowSpec); assert.deepEqual(results, { @@ -870,7 +870,7 @@ describe('ContextTokenization', function() { it('builds edge windows for the start of context with no edits', () => { const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const results = buildEdgeWindow(baseTokenization.tokens, { insert: '', deleteLeft: 0, deleteRight: 0 }, true, testEdgeWindowSpec); assert.deepEqual(results, { @@ -910,7 +910,7 @@ describe('ContextTokenization', function() { it('builds edge windows for the start of context with deletion edits (1)', () => { const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const results = buildEdgeWindow(baseTokenization.tokens, { insert: '', deleteLeft: 0, deleteRight: 2 }, true, testEdgeWindowSpec); assert.deepEqual(results, { @@ -950,7 +950,7 @@ describe('ContextTokenization', function() { it('builds edge windows for the start of context with deletion edits (2)', () => { const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const results = buildEdgeWindow(baseTokenization.tokens, { insert: '', deleteLeft: 0, deleteRight: 4 }, true, testEdgeWindowSpec); assert.deepEqual(results, { @@ -970,7 +970,7 @@ describe('ContextTokenization', function() { it('builds edge windows for the end of context with no edits', () => { const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); baseTokenization.tail.isPartial = true; const results = buildEdgeWindow(baseTokenization.tokens, { insert: '', deleteLeft: 0, deleteRight: 0 }, false, testEdgeWindowSpec); @@ -991,7 +991,7 @@ describe('ContextTokenization', function() { it('builds edge windows for the end of context with no edits, trailing whitespace', () => { const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', '']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const results = buildEdgeWindow(baseTokenization.tokens, { insert: '', deleteLeft: 0, deleteRight: 0 }, false, testEdgeWindowSpec); assert.deepEqual(results, { @@ -1778,7 +1778,7 @@ describe('ContextTokenization', function() { it('returns the standard edge window for empty transform inputs', () => { const baseTokens = ['quick', ' ', 'brown', ' ', 'fox']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const editTransform = { insert: '', @@ -1812,7 +1812,7 @@ describe('ContextTokenization', function() { it('returns the standard edge window for empty transforms with context-final whitespace', () => { const baseTokens = ['quick', ' ', 'brown', ' ', 'fox', ' ']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const editTransform = { insert: '', @@ -1847,7 +1847,7 @@ describe('ContextTokenization', function() { it('returns the standard edge window for pure transform w insert inputs', () => { const baseTokens = ['quick', ' ', 'brown', ' ', 'fox']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const editTransform = { insert: ' jumped', @@ -1881,7 +1881,7 @@ describe('ContextTokenization', function() { it('returns the proper edge window for transforms w deleteLeft inputs (1)', () => { const baseTokens = ['quick', ' ', 'brown', ' ', 'fox']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const editTransform = { insert: 'rog', @@ -1916,7 +1916,7 @@ describe('ContextTokenization', function() { it('returns the proper edge window for transforms w deleteLeft inputs (2)', () => { const baseTokens = ['quick', ' ', 'brown', ' ', 'fox']; const idSeed = TOKEN_TRANSFORM_SEED; - const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransformToken(t))); + const baseTokenization = new ContextTokenization(baseTokens.map(t => toTransitionToken(t))); const editTransform = { insert: 'fox and brown fox', // => quick fox and brown fox diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tracker.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tracker.tests.ts index f27ebd72f7..050ab56c4a 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tracker.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tracker.tests.ts @@ -34,7 +34,7 @@ describe('ContextTracker', function() { deleteLeft: 0, id: 15 }, - transformId: 2, + transitionId: 2, id: 1, displayAs: 'world' }; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts index 263a24aa47..2477a80f18 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts @@ -118,9 +118,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 10, displayAs: 'world' }, { @@ -132,9 +131,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 11, displayAs: 'won' }]; @@ -155,7 +153,7 @@ describe('ContextTransition', () => { // 3 long, only last token was edited. appliedTransition.base.final.tokenization.tokens.forEach((token, index) => { if(index >= 2) { - assert.equal(token.appliedTransitionId, suggestions[0].transformId); + assert.equal(token.appliedTransitionId, suggestions[0].transform.id); } else { assert.isUndefined(token.appliedTransitionId); } @@ -163,7 +161,7 @@ describe('ContextTransition', () => { appliedTransition.appended.final.tokenization.tokens.forEach((token, index) => { if(index >= 2) { - assert.equal(token.appliedTransitionId, suggestions[0].transformId); + assert.equal(token.appliedTransitionId, suggestions[0].transform.id); } else { assert.isUndefined(token.appliedTransitionId); } @@ -202,9 +200,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 10, displayAs: 'the' }, { @@ -216,9 +213,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 11, displayAs: 'and' }]; @@ -239,7 +235,7 @@ describe('ContextTransition', () => { // 3 long, only last token was edited. appliedTransition.base.final.tokenization.tokens.forEach((token, index) => { if(index >= 4) { - assert.equal(token.appliedTransitionId, suggestions[0].transformId); + assert.equal(token.appliedTransitionId, suggestions[0].transform.id); } else { assert.isUndefined(token.appliedTransitionId); } @@ -247,7 +243,7 @@ describe('ContextTransition', () => { appliedTransition.appended.final.tokenization.tokens.forEach((token, index) => { if(index >= 4) { - assert.equal(token.appliedTransitionId, suggestions[0].transformId); + assert.equal(token.appliedTransitionId, suggestions[0].transform.id); } else { assert.isUndefined(token.appliedTransitionId); } @@ -287,9 +283,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 10, displayAs: 'world' }, { @@ -301,9 +296,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 11, displayAs: 'won' }]; @@ -337,9 +331,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 10, displayAs: 'world' }, { @@ -351,9 +344,8 @@ describe('ContextTransition', () => { appendedTransform: { insert: ' ', deleteLeft: 0, - id: 2 + id: 3 }, - transformId: 2, id: 11, displayAs: 'won' }]; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts index 6b36685093..a38b4e3012 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts @@ -168,7 +168,6 @@ describe('matchBaseContextState', () => { const suggestion: Suggestion= { transform: { insert: 'dramatically', deleteLeft: 0, id: 3 }, displayAs: 'dramatically', - transformId: 3, id: 5 } @@ -234,7 +233,6 @@ describe('matchBaseContextState', () => { const suggestion: Suggestion= { transform: { insert: '', deleteLeft: 'dramatically'.length, id: 3 }, displayAs: '""', - transformId: 3, id: 5 } diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts index 218f18313a..0be3b6525e 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts @@ -137,7 +137,6 @@ describe('determineContextTransition', () => { insert: ' ', deleteLeft: 0 }, - transformId: 0, displayAs: 'testing' }; baseTransition.final.suggestions = [pred_testing]; @@ -192,7 +191,7 @@ describe('determineContextTransition', () => { transform: { insert: 'testing', deleteLeft: 4, - id: 1 + id: 0 }, appendedTransform: { insert: ' ', @@ -200,7 +199,6 @@ describe('determineContextTransition', () => { id: 2 }, id: 4, - transformId: 0, displayAs: 'testing' }; baseTransition.final.suggestions = [pred_testing]; @@ -226,8 +224,8 @@ describe('determineContextTransition', () => { assert.notEqual(extendingTransition, baseTransition); // These values support delayed reversions. - assert.equal(extendingTransition.final.tokenization.tokens[6].appliedTransitionId, pred_testing.transformId); - assert.equal(extendingTransition.final.tokenization.tokens[7].appliedTransitionId, pred_testing.transformId); + assert.equal(extendingTransition.final.tokenization.tokens[6].appliedTransitionId, pred_testing.transform.id); + assert.equal(extendingTransition.final.tokenization.tokens[7].appliedTransitionId, pred_testing.transform.id); // We start a new token here, rather than continue (and/or replace) an old one; // this shouldn't be set here yet. @@ -253,14 +251,13 @@ describe('determineContextTransition', () => { transform: { insert: 'testing', deleteLeft: 4, - id: 1 + id: 0 }, appendedTransform: { insert: ' ', deleteLeft: 0, id: 2 }, - transformId: 0, displayAs: 'testing' }; baseTransition.final.suggestions = [pred_testing]; @@ -293,7 +290,7 @@ describe('determineContextTransition', () => { [{sample: { insert: '', deleteLeft: 1 }, p: 1}] ); - assert.equal(extensionDeletingTransition.revertableTransitionId, pred_testing.transformId); + assert.equal(extensionDeletingTransition.revertableTransitionId, pred_testing.transform.id); } finally { warningEmitterSpy.restore(); } @@ -315,14 +312,13 @@ describe('determineContextTransition', () => { transform: { insert: 'testing', deleteLeft: 4, - id: 1 + id: 0 }, appendedTransform: { insert: ' ', deleteLeft: 0, id: 2 }, - transformId: 0, displayAs: 'testing' }; baseTransition.final.suggestions = [pred_testing]; @@ -367,7 +363,7 @@ describe('determineContextTransition', () => { [{sample: { insert: '', deleteLeft: 1 }, p: 1}] ); - assert.equal(appendDeletingTransition.revertableTransitionId, pred_testing.transformId); + assert.equal(appendDeletingTransition.revertableTransitionId, pred_testing.transform.id); } finally { warningEmitterSpy.restore(); } @@ -389,14 +385,13 @@ describe('determineContextTransition', () => { transform: { insert: 'testing', deleteLeft: 4, - id: 1 + id: 0 }, appendedTransform: { insert: ' ', deleteLeft: 0, id: 2 }, - transformId: 0, displayAs: 'testing' }; baseTransition.final.suggestions = [pred_testing]; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-corrections.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-corrections.tests.ts index 2351ff9932..31f5063c15 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-corrections.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/predict-from-corrections.tests.ts @@ -172,7 +172,6 @@ describe('predictFromCorrections', () => { assert.sameOrderedMembers(predictions.map((entry) => entry.prediction.sample.displayAs), ["it's", "its"]); assert.sameDeepOrderedMembers(predictions.map((entry) => entry.prediction.sample), dummied_suggestions.map((entry) => { entry = deepCopy(entry); - entry.transformId = 314159; return entry; })); diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/worker-model-compositor.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/worker-model-compositor.tests.ts index 928b6e75c4..5343760d56 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/worker-model-compositor.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/worker-model-compositor.tests.ts @@ -588,7 +588,6 @@ describe('ModelCompositor', function() { deleteLeft: 0, id: 0 }, - transformId: 0, displayAs: 'hello' }; @@ -668,13 +667,12 @@ describe('ModelCompositor', function() { } it('first word of context, postTransform provided, .deleteLeft = 0', function() { - let baseSuggestion = { + let baseSuggestion: Suggestion = { transform: { insert: 'hello ', deleteLeft: 2, id: 0 }, - transformId: 0, id: 1, displayAs: 'hello' }; @@ -691,7 +689,7 @@ describe('ModelCompositor', function() { } let reversion = acceptanceTest(englishPunctuation, baseSuggestion, baseContext, postTransform); - assert.equal(reversion.transformId, baseSuggestion.transformId); + assert.equal(reversion.transform.id, baseSuggestion.transform.id); assert.equal(reversion.id, -baseSuggestion.id); // Check #1: Does the returned reversion properly revert the context to its pre-application state? @@ -711,13 +709,12 @@ describe('ModelCompositor', function() { }); it('second word of context, postTransform provided, .deleteLeft = 0', function() { - let baseSuggestion = { + let baseSuggestion: Suggestion = { transform: { insert: 'world ', deleteLeft: 3, id: 0 }, - transformId: 0, id: 0, displayAs: 'world' }; @@ -734,7 +731,7 @@ describe('ModelCompositor', function() { } let reversion = acceptanceTest(englishPunctuation, baseSuggestion, baseContext, postTransform); - assert.equal(reversion.transformId, baseSuggestion.transformId); + assert.equal(reversion.transform.id, baseSuggestion.transform.id); assert.equal(reversion.id, -baseSuggestion.id); // Check #1: Does the returned reversion properly revert the context to its pre-application state? @@ -754,13 +751,12 @@ describe('ModelCompositor', function() { }); it('second word of context, postTransform undefined', function() { - let baseSuggestion = { + let baseSuggestion: Suggestion = { transform: { insert: 'world ', deleteLeft: 3, id: 0 }, - transformId: 0, id: 0, displayAs: 'world' }; @@ -770,7 +766,7 @@ describe('ModelCompositor', function() { } let reversion = acceptanceTest(englishPunctuation, baseSuggestion, baseContext); - assert.equal(reversion.transformId, baseSuggestion.transformId); + assert.equal(reversion.transform.id, baseSuggestion.transform.id); assert.equal(reversion.id, -baseSuggestion.id); // Check #1: Does the returned reversion properly revert the context to its pre-application state? @@ -790,13 +786,12 @@ describe('ModelCompositor', function() { }); it('first word of context + postTransform provided, .deleteLeft > 0', function() { - let baseSuggestion = { + let baseSuggestion: Suggestion = { transform: { insert: 'hello ', deleteLeft: 2, id: 0 }, - transformId: 0, id: 0, displayAs: 'hello' }; @@ -813,7 +808,7 @@ describe('ModelCompositor', function() { } let reversion = acceptanceTest(englishPunctuation, baseSuggestion, baseContext, postTransform); - assert.equal(reversion.transformId, baseSuggestion.transformId); + assert.equal(reversion.transform.id, baseSuggestion.transform.id); assert.equal(reversion.id, -baseSuggestion.id); // Check #1: Does the returned reversion properly revert the context to its pre-application state? @@ -847,13 +842,12 @@ describe('ModelCompositor', function() { // it('first word of context + postTransform provided, .deleteLeft > 0') // seen earlier in the file. - let baseSuggestion = { + let baseSuggestion: Suggestion = { transform: { insert: 'hello ', deleteLeft: 2, id: 0 }, - transformId: 0, id: 0, displayAs: 'hello' }; @@ -873,7 +867,7 @@ describe('ModelCompositor', function() { let compositor = new ModelCompositor(model, true); let reversion = compositor.acceptSuggestion(baseSuggestion, baseContext, postTransform); - assert.equal(reversion.transformId, baseSuggestion.transformId); + assert.equal(reversion.transform.id, baseSuggestion.transform.id); assert.equal(reversion.id, -baseSuggestion.id); let appliedContext = models.applyTransform(baseSuggestion.transform, baseContext); @@ -887,7 +881,8 @@ describe('ModelCompositor', function() { let expectedTransform = { insert: 'hi', // Keeps current context the same, though it adds a wordbreak. - deleteLeft: 2 + deleteLeft: 2, + id: 0 } assert.deepEqual(suggestions[0].transform, expectedTransform); assert.deepEqual(suggestions[0].appendedTransform, { @@ -923,7 +918,7 @@ describe('ModelCompositor', function() { let baseSuggestion = initialSuggestions[1]; baseSuggestion.appendedTransform.id = 15; // set an id for the applied transform. let reversion = compositor.acceptSuggestion(baseSuggestion, baseContext, postTransform); - assert.equal(reversion.transformId, -baseSuggestion.transformId); + assert.equal(reversion.transform.id, -baseSuggestion.transform.id); assert.equal(reversion.id, -baseSuggestion.id); let appliedContext = models.applyTransform(baseSuggestion.transform, baseContext); @@ -970,7 +965,7 @@ describe('ModelCompositor', function() { assert.equal(compositor.contextTracker.unitTestEndPoints.cache().size, 2); let contextIds = compositor.contextTracker.unitTestEndPoints.cache().keys(); - assert.equal(reversion.transformId, -baseSuggestion.transformId); + assert.equal(reversion.transform.id, -baseSuggestion.transform.id); assert.equal(reversion.id, -baseSuggestion.id); const appliedContextState = compositor.contextTracker.unitTestEndPoints.cache().get(15); From 64abd5b690be92eefb257992685189f6a34b6512 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Thu, 25 Jun 2026 17:36:39 +0200 Subject: [PATCH 060/148] fix(android): fix loading of keyboards on Android This change moves to using `WebViewAssetLoader` for loading files from the device instead of using file:// URLs. This fixes the blank keyboard problem reported in #16096 for Android. Also make `KMKeyboard.getKeyboardRoot()` private, and rename public `Keyboard.getKeyboardPath()` to private `Keyboard.getKeyboardUrl()`. Part-of: #16096 --- android/KMAPro/kMAPro/build.gradle | 3 +- android/KMEA/app/build.gradle | 1 + .../KMEA/app/src/main/assets/keyboard.html | 2 +- .../java/com/keyman/engine/KMKeyboard.java | 40 +++++----- .../engine/KMKeyboardWebViewClient.java | 74 +++++++++++-------- .../java/com/keyman/engine/KMManager.java | 19 +++-- .../java/com/keyman/engine/data/Keyboard.java | 32 ++++---- .../com/keyman/engine/util/WebViewUtils.java | 8 ++ 8 files changed, 106 insertions(+), 73 deletions(-) diff --git a/android/KMAPro/kMAPro/build.gradle b/android/KMAPro/kMAPro/build.gradle index 77ab2fa736..ebdbe2a2f3 100644 --- a/android/KMAPro/kMAPro/build.gradle +++ b/android/KMAPro/kMAPro/build.gradle @@ -173,10 +173,11 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'com.google.android.material:material:1.12.0' implementation 'com.stepstone.stepper:material-stepper:4.3.1' + implementation 'androidx.webkit:webkit:1.14.0' implementation files('libs/keyman-engine.aar') implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' - implementation "com.android.installreferrer:installreferrer:2.2" + implementation 'com.android.installreferrer:installreferrer:2.2' // Add dependency for generating QR Codes // (Even though it's embedded in KMEA, because we're manually copying keyman-engine.aar, diff --git a/android/KMEA/app/build.gradle b/android/KMEA/app/build.gradle index c9ae92c1c2..4bb05106e3 100644 --- a/android/KMEA/app/build.gradle +++ b/android/KMEA/app/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation 'commons-io:commons-io:2.16.1' implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' // Robolectric testImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/android/KMEA/app/src/main/assets/keyboard.html b/android/KMEA/app/src/main/assets/keyboard.html index 38f5ad617e..a9b34fd486 100644 --- a/android/KMEA/app/src/main/assets/keyboard.html +++ b/android/KMEA/app/src/main/assets/keyboard.html @@ -1,7 +1,7 @@ diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 9152f84360..807014697d 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2017-2018 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -23,6 +23,7 @@ import com.keyman.engine.util.DependencyUtil.LibraryType; import com.keyman.engine.util.FileUtils; import com.keyman.engine.util.KMLog; import com.keyman.engine.util.KMString; +import com.keyman.engine.util.WebViewUtils; import android.annotation.SuppressLint; import android.content.Context; @@ -85,7 +86,7 @@ final class KMKeyboard extends WebView { private static String txtFont = ""; private static String oskFont = null; - private static String keyboardRoot = ""; + private String keyboardRoot = ""; private final String fontUndefined = "undefined"; private GestureDetector gestureDetector; private static ArrayList kbEventListeners = null; @@ -367,7 +368,9 @@ final class KMKeyboard extends WebView { KMManager.SystemKeyboardWebViewClient.setKeyboardLoaded(false); } - String htmlPath = "file://" + getContext().getDir("data", Context.MODE_PRIVATE) + "/" + KMManager.KMFilename_KeyboardHtml; + // Use the reserved magic domain for loading the keyboard from the local device. + // See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader + String htmlPath = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMFilename_KeyboardHtml; loadUrl(htmlPath); setBackgroundColor(0); } @@ -561,6 +564,7 @@ final class KMKeyboard extends WebView { return oskFont; } + // REVIEW: this method seems to be unused /** * Return the full path to the special OSK font, * which is with all the keyboard assets at the root app_data folder @@ -715,7 +719,7 @@ final class KMKeyboard extends WebView { String kbKey = KMString.format("%s_%s", languageID, keyboardID); - String keyboardPath = makeKeyboardPath(packageID, keyboardID, keyboardVersion); + String keyboardUrl = makeKeyboardUrl(packageID, keyboardID, keyboardVersion); JSONObject reg = new JSONObject(); try { @@ -723,7 +727,7 @@ final class KMKeyboard extends WebView { reg.put("KI", "Keyboard_" + keyboardID); reg.put("KLC", languageID); reg.put("KL", languageName); - reg.put("KF", keyboardPath); + reg.put("KF", keyboardUrl); reg.put("KP", packageID); if (jDisplayFont != null) reg.put("KFont", jDisplayFont); @@ -811,26 +815,24 @@ final class KMKeyboard extends WebView { // Set the base path of the keyboard depending on the package ID private void setKeyboardRoot(String packageID) { if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator + KMManager.KMDefault_UndefinedPackageID + File.separator); + this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_UndefinedPackageID + "/"; } else { - this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator); + this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } } - public String getKeyboardRoot() { + private String getKeyboardRoot() { return this.keyboardRoot; } - private String makeKeyboardPath(String packageID, String keyboardID, String keyboardVersion) { - String keyboardPath; + private String makeKeyboardUrl(String packageID, String keyboardID, String keyboardVersion) { + String keyboardUrl = getKeyboardRoot(); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - keyboardPath = getKeyboardRoot() + keyboardID + "-" + keyboardVersion + ".js"; + keyboardUrl += keyboardID + "-" + keyboardVersion + ".js"; } else { - keyboardPath = getKeyboardRoot() + keyboardID + ".js"; + keyboardUrl += keyboardID + ".js"; } - return keyboardPath; + return keyboardUrl; } private void sendKMWError(int lineNumber, String sourceId, String message) { @@ -1059,7 +1061,7 @@ final class KMKeyboard extends WebView { JSONObject jfont = new JSONObject(); jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length()-4)); JSONArray jfiles = new JSONArray(); - jfiles.put(keyboardRoot + font); + jfiles.put(getKeyboardRoot() + font); jfont.put(KMManager.KMKey_FontFiles, jfiles); return jfont; } @@ -1077,7 +1079,7 @@ final class KMKeyboard extends WebView { Object obj = fontObj.get(KMManager.KMKey_FontFiles); if (obj instanceof String) { fontFile = fontObj.getString(KMManager.KMKey_FontFiles); - fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + obj); + fontObj.put(KMManager.KMKey_FontFiles, getKeyboardRoot() + obj); return fontObj; } else if (obj instanceof JSONArray) { sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); @@ -1085,7 +1087,7 @@ final class KMKeyboard extends WebView { for (int i = 0; i < sourceArray.length(); i++) { fontFile = sourceArray.getString(i); if (FileUtils.hasFontExtension(fontFile)) { - fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + fontFile); + fontObj.put(KMManager.KMKey_FontFiles, getKeyboardRoot() + fontFile); fontObj.remove(KMManager.KMKey_FontSource); return fontObj; } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java index 853d997d7e..c4a2818e6c 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2023 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -9,9 +9,13 @@ import android.graphics.Bitmap; import android.graphics.RectF; import android.net.Uri; import android.util.Log; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.RelativeLayout; +import androidx.webkit.WebViewAssetLoader; +import androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler; import com.keyman.engine.KeyboardEventHandler.EventType; import com.keyman.engine.KMManager; @@ -31,11 +35,16 @@ public final class KMKeyboardWebViewClient extends WebViewClient { public Context context; private KeyboardType keyboardType; private boolean keyboardLoaded; + private WebViewAssetLoader assetLoader; KMKeyboardWebViewClient(Context context, KeyboardType keyboardType) { this.context = context; this.keyboardType = keyboardType; this.keyboardLoaded = false; + this.assetLoader = new WebViewAssetLoader.Builder() + .addPathHandler("/data/", new InternalStoragePathHandler(context, + context.getDir("data", Context.MODE_PRIVATE))) + .build(); if (keyboardType != KeyboardType.KEYBOARD_TYPE_INAPP && keyboardType != KeyboardType.KEYBOARD_TYPE_SYSTEM) { KMLog.LogError(TAG, String.format("Cannot initialize: Invalid keyboard type: %s", keyboardType.toString())); @@ -58,6 +67,11 @@ public final class KMKeyboardWebViewClient extends WebViewClient { public void onPageStarted(WebView view, String url, Bitmap favicon) { } + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + return this.assetLoader.shouldInterceptRequest(request.getUrl()); + } + @Override public void onPageFinished(WebView view, String url) { Log.d("KMEA", String.format("onPageFinished: [%s] %s", keyboardType.toString(), url)); @@ -74,46 +88,44 @@ public final class KMKeyboardWebViewClient extends WebViewClient { kmKeyboard.keyboardSet = false; KMManager.currentLexicalModel = null; - if (url.startsWith("file")) { // TODO: is this test necessary? - this.keyboardLoaded = true; + this.keyboardLoaded = true; - SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); - int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); - if (index < 0) { - index = 0; + SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); + int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); + if (index < 0) { + index = 0; + } + Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index); + String langId = null; + if (keyboardInfo != null) { + langId = keyboardInfo.getLanguageID(); + kmKeyboard.setKeyboard(keyboardInfo); + } else { + // Revert to default (index 0) or fallback keyboard + keyboardInfo = KMManager.getKeyboardInfo(context, 0); + if (keyboardInfo == null) { + // Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses + keyboardInfo = KMManager.getDefaultKeyboard(context); } - Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index); - String langId = null; if (keyboardInfo != null) { langId = keyboardInfo.getLanguageID(); kmKeyboard.setKeyboard(keyboardInfo); - } else { - // Revert to default (index 0) or fallback keyboard - keyboardInfo = KMManager.getKeyboardInfo(context, 0); - if (keyboardInfo == null) { - // Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses - keyboardInfo = KMManager.getDefaultKeyboard(context); - } - if (keyboardInfo != null) { - langId = keyboardInfo.getLanguageID(); - kmKeyboard.setKeyboard(keyboardInfo); - } } + } - KMManager.registerAssociatedLexicalModel(langId); + KMManager.registerAssociatedLexicalModel(langId); - kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time! + kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time! - kmKeyboard.callJavascriptAfterLoad(); - kmKeyboard.setSpacebarText(KMManager.getSpacebarText()); + kmKeyboard.callJavascriptAfterLoad(); + kmKeyboard.setSpacebarText(KMManager.getSpacebarText()); - KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null); + KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null); - // Special handling for in-app TextView context keymanapp/keyman#3809 - if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP && - KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) { - KMTextView.updateTextContext(); - } + // Special handling for in-app TextView context keymanapp/keyman#3809 + if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP && + KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) { + KMTextView.updateTextContext(); } } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index f87219f512..a4e054eb75 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2017 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -398,6 +398,10 @@ public final class KMManager { return appContext.getDir("data", Context.MODE_PRIVATE).toString() + File.separator; } + public static String getResourceUrl() { + return WebViewUtils.MAGIC_DEFAULT_DOMAIN +"/data/"; + } + public static String getPackagesDir() { return getResourceRoot() + KMDefault_AssetPackages + File.separator; } @@ -406,6 +410,10 @@ public final class KMManager { return getResourceRoot() + KMDefault_LexicalModelPackages + File.separator; } + public static String getLexicalModelsUrl() { + return getResourceUrl() + KMDefault_LexicalModelPackages + "/"; + } + public static String getCloudDir() { return getResourceRoot() + KMDefault_UndefinedPackageID + File.separator; } @@ -1650,8 +1658,9 @@ public final class KMManager { String modelID = lexicalModelInfo.get(KMKey_LexicalModelID); String languageID = lexicalModelInfo.get(KMKey_LanguageID); boolean modelFileExists = true; - File modelFile = new File(getLexicalModelsDir(), pkgID + File.separator + modelID + ".model.js"); - String path = "file://" + modelFile.getAbsolutePath(); + String modelFilename = pkgID + File.separator + modelID + ".model.js"; + File modelFile = new File(getLexicalModelsDir(), modelFilename); + String url = getLexicalModelsUrl() + modelFilename; // Disable sugestions if lexical-model file doesn't exist if (!modelFile.exists()) { @@ -1666,7 +1675,7 @@ public final class KMManager { modelObj.put("id", modelID); languageJSONArray.put(languageID); modelObj.put("languages", languageJSONArray); - modelObj.put("path", path); + modelObj.put("path", url); modelObj.put("CustomHelpLink", lexicalModelInfo.get(KMKey_CustomHelpLink)); } catch (JSONException e) { KMLog.LogException(TAG, "Invalid lexical model to register", e); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index 0dbfe5d023..a160d96d03 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2020 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine.data; @@ -15,6 +15,7 @@ import com.keyman.engine.util.BCP47; import com.keyman.engine.util.FileUtils; import com.keyman.engine.util.KMLog; import com.keyman.engine.util.KMString; +import com.keyman.engine.util.WebViewUtils; import org.json.JSONArray; import org.json.JSONException; @@ -173,24 +174,23 @@ public class Keyboard extends LanguageResource implements Serializable { return o; } - private String getKeyboardRoot(Context context) { - String keyboardRoot = context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator; - + private String getKeyboardRoot() { + String keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/"; if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return keyboardRoot + KMManager.KMDefault_UndefinedPackageID + File.separator; + keyboardRoot += KMManager.KMDefault_UndefinedPackageID + "/"; } else { - return keyboardRoot + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator; + keyboardRoot += KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } + return keyboardRoot; } - public String getKeyboardPath(Context context) { + private String getKeyboardUrl() { String keyboardID = this.getKeyboardID(); String keyboardVersion = this.getVersion(); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return getKeyboardRoot(context) + keyboardID + "-" + keyboardVersion + ".js"; + return getKeyboardRoot() + keyboardID + "-" + keyboardVersion + ".js"; } else { - return getKeyboardRoot(context) + keyboardID + ".js"; + return getKeyboardRoot() + keyboardID + ".js"; } } @@ -202,17 +202,17 @@ public class Keyboard extends LanguageResource implements Serializable { stubObj.put("KI", "Keyboard_" + this.getKeyboardID()); stubObj.put("KLC", this.getLanguageID()); stubObj.put("KL", this.getLanguageName()); - stubObj.put("KF", this.getKeyboardPath(context)); + stubObj.put("KF", this.getKeyboardUrl()); stubObj.put("KP", this.getPackageID()); String displayFont = this.getFont(); if(displayFont != null) { - stubObj.put("KFont", this.buildDisplayFontObject(displayFont, context)); + stubObj.put("KFont", this.buildDisplayFontObject(displayFont)); } String oskFont = this.getOSKFont(); if(oskFont != null) { - stubObj.put("KOskFont", this.buildDisplayFontObject(oskFont, context)); + stubObj.put("KOskFont", this.buildDisplayFontObject(oskFont)); } String displayName = this.getDisplayName(); @@ -234,12 +234,12 @@ public class Keyboard extends LanguageResource implements Serializable { * @param font String font JSON object as a string * @return JSONObject of modified font information with full paths. If font is invalid, return `null` */ - private JSONObject buildDisplayFontObject(String font, Context context) { + private JSONObject buildDisplayFontObject(String font) { if(font == null || font.equals("")) { return null; } - String keyboardRoot = this.getKeyboardRoot(context); + String keyboardRoot = this.getKeyboardRoot(); try { if (FileUtils.hasFontExtension(font)) { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index 0586ecbcc8..8a82556f02 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -42,6 +42,14 @@ public final class WebViewUtils { private static final String CHROME_INSTALL_PATTERN_FORMATSTR = "^.*Chrome/([\\d.]+).*$"; private static final Pattern installPattern = Pattern.compile(CHROME_INSTALL_PATTERN_FORMATSTR); + /** + * Reserved magic domain for loading files from the local device. At runtime + * the WebViewAssetLoader will replace the protocol and domain with the + * internal storage path. + * See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader + */ + public static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + /** * Get the Keyman Engine mode based on the Chrome version. * @param context - The context From 6d8ae47ab5cc6c5201827f3830710d960340731b Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 26 Jun 2026 14:27:15 -0500 Subject: [PATCH 061/148] fix(web): drop .transformId refs from engine:main unit tests --- .../engine/predictive-text/templates/common.tests.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts b/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts index 0c7b010586..95db19df8d 100644 --- a/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/templates/common.tests.ts @@ -191,7 +191,6 @@ describe('Common utility functions', function() { deleteLeft: 0, id: 0 }, - transitionId: 0, displayAs: 'hello' }; @@ -205,7 +204,6 @@ describe('Common utility functions', function() { deleteLeft: 0, id: 0 }, - transitionId: 0, displayAs: 'hello', p: 0 }; @@ -220,7 +218,6 @@ describe('Common utility functions', function() { deleteLeft: 0, id: 0 }, - transitionId: 0, displayAs: 'hello', p: 0.5 }; @@ -228,14 +225,13 @@ describe('Common utility functions', function() { assert.deepEqual(models.transformToSuggestion(suggestion.transform, 0.5), suggestion); }); - it('properly handles the transitionId', function() { + it('properly handles the transition ID', function() { let suggestion = { transform: { insert: 'hello', deleteLeft: 0, id: 3 }, - transitionId: 3, // Ensures there isn't a separate ID seed in use. displayAs: 'hello' }; From 13e154f3a49c8b2bb282a510de3798b832cf02be Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 25 Jun 2026 13:19:23 -0500 Subject: [PATCH 062/148] fix(ios): bundle in KMW's globe-hint.css Pretty much just what the title says; it's been silently missing this whole time. This doesn't fix iOS keyboard's display by itself, but it is a prerequisite for the full solution offered by #16136. Build-bot: skip build:ios Test-bot: skip --- ios/.gitignore | 1 + .../Classes/Keyboard/KeymanWebViewController.swift | 2 +- .../KeymanEngine/Classes/Resource Management/Storage.swift | 4 ++++ ios/engine/build.sh | 3 ++- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ios/.gitignore b/ios/.gitignore index 31d4700aec..556562d4b6 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -13,6 +13,7 @@ samples/KMSample2/KeymanEngine.xcframework samples/KMSample2/build engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanios.js engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanweb-osk.ttf +engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/globe-hint.css engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/kmwosk.css engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keyman.js.map engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanweb-webview.js diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 4bd5375bc9..10b530d6f8 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -182,7 +182,7 @@ extension KeymanWebViewController { } view = nil } - + func languageMenuPosition(_ completion: @escaping (CGRect) -> Void) { webView!.evaluateJavaScript("langMenuPos();") { result, _ in guard let result = result as? String, !result.isEmpty else { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift index 6775edb660..fd8b98e8a8 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift @@ -245,6 +245,10 @@ extension Storage { resourceName: "kmwosk.css", dstDir: baseDir, excludeFromBackup: true) + try Storage.copy(from: bundle, + resourceName: "globe-hint.css", + dstDir: baseDir, + excludeFromBackup: true) try Storage.copy(from: bundle, resourceName: "keymanweb-osk.ttf", dstDir: baseDir, diff --git a/ios/engine/build.sh b/ios/engine/build.sh index 9b98249b9b..50aafcac9f 100755 --- a/ios/engine/build.sh +++ b/ios/engine/build.sh @@ -102,7 +102,8 @@ function update_bundle ( ) { KMW_PRODUCT="$KEYMAN_ROOT/web/build/app/webview/$CONFIG" KMW_RESOURCES="$KEYMAN_ROOT/web/build/app/resources" - #Copy over the relevant resources! It's easiest to do if we navigate to the resulting folder. + # Copy over the relevant resources! It's easiest to do if we navigate to the resulting folder. + cp "$KMW_RESOURCES/osk/globe-hint.css" "$BUNDLE_PATH/globe-hint.css" cp "$KMW_RESOURCES/osk/kmwosk.css" "$BUNDLE_PATH/kmwosk.css" cp "$KMW_RESOURCES/osk/keymanweb-osk.ttf" "$BUNDLE_PATH/keymanweb-osk.ttf" cp "$KMW_PRODUCT/keymanweb-webview.js" "$BUNDLE_PATH/keymanweb-webview.js" From b3ee271aeedf98ec19f8f352fe3c533d25970471 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 24 Jun 2026 15:58:02 -0500 Subject: [PATCH 063/148] fix(ios): load host page, keyboards through a consistent WKURLSchemeHandler This bypasses any need to modify KMW keyboard loading (say, via .fetch), though it does require a number of collateral changes to be made in order for CORS, etc to be satisfied. Build-bot: skip build:ios --- .../KeymanEngine.xcodeproj/project.pbxproj | 4 + .../Keyboard/KeymanWebViewController.swift | 27 ++++-- .../Keyboard/WebViewSchemeHandler.swift | 95 +++++++++++++++++++ .../src/interfaces/pathConfiguration.ts | 30 +++--- web/src/engine/src/osk/views/oskView.ts | 5 +- web/src/engine/src/osk/visualKeyboard.ts | 2 +- 6 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index 94dea1d5c7..2365c073a5 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ CE8B0BBD248734240045EB2E /* KeymanPackageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */; }; CE8B0BBF248764ED0045EB2E /* KMPResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBE248764ED0045EB2E /* KMPResource.swift */; }; CE8B5BB22491DA540075CCB0 /* 13.0 Cloud to Package Migration.bundle in Resources */ = {isa = PBXBuildFile; fileRef = CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */; }; + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */; }; CE8EDEB123F53D1A009E1FF6 /* FileManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A079DD1223194B100581263 /* FileManagementTests.swift */; }; CE8EDEB323F53F96009E1FF6 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */; }; CE969BE8251AD8B500376D6A /* PackageWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */; }; @@ -459,6 +460,7 @@ CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymanPackageTests.swift; sourceTree = ""; }; CE8B0BBE248764ED0045EB2E /* KMPResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPResource.swift; sourceTree = ""; }; CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "13.0 Cloud to Package Migration.bundle"; sourceTree = ""; }; + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSchemeHandler.swift; sourceTree = ""; }; CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageWebViewController.swift; sourceTree = ""; }; CE96E42C24D1229A005B8E5A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -804,6 +806,7 @@ CE79B24823C711FF007E72AE /* KeyboardScaleMap.swift */, C0EF3E7A1F95B65300CE9BD4 /* KeymanWebDelegate.swift */, C0C16A881FA8146300F090BA /* KeymanWebViewController.swift */, + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */, C0A5FF361F6682EB00BE740C /* PopoverView.swift */, ); path = Keyboard; @@ -1522,6 +1525,7 @@ 9A079DCA222E050E00581263 /* LexicalModelKeymanPackage.swift in Sources */, 9A60764422893A4E003BCFBA /* SettingsViewController.swift in Sources */, C06085B41F9485E40057E5B9 /* UIButton+Helpers.swift in Sources */, + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */, C0959CD41F99C44E00B616BC /* Constants.swift in Sources */, C0452BAD1F9F21270064431A /* Keyboard.swift in Sources */, 29B30C232B564F9900C342A4 /* KeymanEngineLogger.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 10b530d6f8..02560e6929 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -38,7 +38,8 @@ class KeymanWebViewController: UIViewController { let storage: Storage weak var delegate: KeymanWebDelegate? private var useSpecialFont = false - private var userContentController = WKUserContentController() + private let userContentController = WKUserContentController() + private let schemeHandler: WebViewSchemeHandler private let keymanWebViewName: String = "keyman" // Views @@ -69,8 +70,9 @@ class KeymanWebViewController: UIViewController { init(storage: Storage) { self.storage = storage + self.schemeHandler = WebViewSchemeHandler(storage: storage) + super.init(nibName: nil, bundle: nil) - _ = view } @@ -122,6 +124,7 @@ class KeymanWebViewController: UIViewController { config.preferences = prefs config.suppressesIncrementalRendering = false config.userContentController = self.userContentController + config.setURLSchemeHandler(schemeHandler, forURLScheme: schemeHandler.scheme) webView = KeymanWebView(frame: CGRect(origin: .zero, size: keyboardSize), configuration: config) webView!.isOpaque = false @@ -279,20 +282,28 @@ extension KeymanWebViewController { // family does not have to match the name in the font file. It only has to be unique. return [ "family": "\(keyboard.id)__\(isOsk ? "osk" : "display")", - "files": font.source.map { storage.fontURL(forResource: keyboard, filename: $0)!.absoluteString } + "files": font.source.map { + schemeHandler.buildUrlForFile( + fileURL: storage.fontURL(forResource: keyboard, filename: $0)! + ).absoluteString + } ] } func setKeyboard(_ keyboard: InstallableKeyboard) throws { let fileURL = storage.keyboardURL(for: keyboard) + let loadingURL = schemeHandler.buildUrlForFile( + fileURL: storage.keyboardURL(for: keyboard) + ) + var stub: [String: Any] = [ "KI": "Keyboard_\(keyboard.id)", "KN": keyboard.name, "KLC": keyboard.languageID, "KL": keyboard.languageName, - "KF": fileURL.absoluteString + "KF": loadingURL.absoluteString ] - + if let packageID = keyboard.packageID { stub["KP"] = packageID } @@ -367,7 +378,7 @@ extension KeymanWebViewController { let stub: [String: Any] = [ "id": lexicalModel.id, "languages": [lexicalModel.languageID], // Change when InstallableLexicalModel is updated to store an array - "path": fileURL.absoluteString + "path": schemeHandler.buildUrlForFile(fileURL: fileURL).absoluteString ] guard FileManager.default.fileExists(atPath: fileURL.path) else { @@ -861,7 +872,9 @@ extension KeymanWebViewController { // MARK: - Show/hide views func reloadKeyboard() { - webView!.loadFileURL(Storage.active.kmwURL, allowingReadAccessTo: Storage.active.baseDir) + let hostPageFileUrl = URL(fileURLWithPath: Resources.kmwFilename, relativeTo: storage.baseDir) + let hostPageUrl = schemeHandler.buildUrlForFile(fileURL: hostPageFileUrl) + webView!.load(URLRequest(url: hostPageUrl)) isLoading = true updateSpacebarText() diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift new file mode 100644 index 0000000000..28c41adaeb --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -0,0 +1,95 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Joshua Horton on 6/24/26. + * + * WebViewKeyboardLoader implements a URLSchemeHandler that allows + * the hosted Keyman Engine for Web to access all files, consistently, + * via a http-like protocol, preventing CORS access issues for files + * loaded dynamically. + */ + +import WebKit +import UniformTypeIdentifiers +import os.log + +func getMimeType(forExtension ext: String) -> String { + // Find the UTType associated with the file extension + if let utType = UTType(filenameExtension: ext) { + // Return the preferred MIME type if it exists + return utType.preferredMIMEType ?? "application/octet-stream" + } + return "application/octet-stream" +} + +class WebViewSchemeHandler: NSObject, WKURLSchemeHandler { + let storage: Storage + let scheme = "keyman-engine" + + init(storage: Storage) { + self.storage = storage + } + + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + return + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + components.scheme = "file" + + let fileUrl = components.url + + let doError = { () -> Void in + let message = "Could not load url via WKURLSchemeHandler: \(url)" + let errorInfo = [ + NSLocalizedDescriptionKey: message + ] + let error = NSError(domain: "WebViewKeyboardLoader", code: 500, userInfo: errorInfo) + + os_log("%{public}s", log:KeymanEngineLogger.settings, type: .error, message) + SentryManager.capture(error, message: message) + + urlSchemeTask.didFailWithError(error) + } + + guard fileUrl != nil else { + doError() + return + } + + do { + let fileContents = try Data(contentsOf: fileUrl!) + let fileExtension = fileUrl!.pathExtension + + let mimeType: String = getMimeType(forExtension: fileExtension) + let charset: String = mimeType.hasPrefix("text/") ? "; charset=utf-8" : "" + + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "\(mimeType)\(charset)", + ] + )! + + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(fileContents) + urlSchemeTask.didFinish() + } catch { + doError() + return + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + } + + func buildUrlForFile(fileURL: URL) -> URL { + var loadingURLBuilder = URLComponents() + loadingURLBuilder.scheme = scheme + loadingURLBuilder.path = fileURL.path + return loadingURLBuilder.url! + } +} diff --git a/web/src/engine/src/interfaces/pathConfiguration.ts b/web/src/engine/src/interfaces/pathConfiguration.ts index 090dc7d4c4..4ab8432bcd 100644 --- a/web/src/engine/src/interfaces/pathConfiguration.ts +++ b/web/src/engine/src/interfaces/pathConfiguration.ts @@ -21,24 +21,12 @@ export class PathConfiguration implements OSKResourcePathConfiguration { private _fonts: string; readonly protocol: string; - /* - * Pre-modularization code corresponding to `sourcePath`: - ``` - // Determine path and protocol of executing script, setting them as - // construction defaults. - // - // This can only be done during load when the active script will be the - // last script loaded. Otherwise the script must be identified by name. - - var scripts = document.getElementsByTagName('script'); - var ss = scripts[scripts.length-1].src; - var sPath = ss.substr(0,ss.lastIndexOf('/')+1); - ``` - */ constructor(pathSpec: Required, sourcePath: string) { + const sourceURL = new URL(sourcePath); + sourcePath = addDelimiter(sourcePath); this.sourcePath = sourcePath; - this.protocol = sourcePath.replace(/(.{3,5}:)(.*)/,'$1'); + this.protocol = sourceURL.protocol; this.updateFromOptions(pathSpec); } @@ -75,8 +63,16 @@ export class PathConfiguration implements OSKResourcePathConfiguration { p = addDelimiter(p); - // Absolute - if((p.replace(/^(http)s?:.*/,'$1') == 'http') || (p.replace(/^(file):.*/,'$1') == 'file')) { + // Absolute - with protocol specified + const protocolList = [ + 'http:', + 'https:', + 'file:', + // If using a custom origin (say, hosted in an iOS WebView via WKURLSchemeHandler) + this.protocol + ]; + + if(protocolList.find((protocol) => p.startsWith(protocol))) { return p; } diff --git a/web/src/engine/src/osk/views/oskView.ts b/web/src/engine/src/osk/views/oskView.ts index 962d943557..ab8ab9e7b3 100644 --- a/web/src/engine/src/osk/views/oskView.ts +++ b/web/src/engine/src/osk/views/oskView.ts @@ -130,7 +130,7 @@ export function getResourcePath(config: ViewConfiguration) { if(config.isEmbedded) { resourcePathExt = ''; } - return `${config.pathConfig.resources}/${resourcePathExt}` + return `${config.pathConfig.resources}${resourcePathExt}` } export abstract class OSKView @@ -267,6 +267,7 @@ export abstract class OSKView for(const sheetFile of OSKView.STYLESHEET_FILES) { const sheetHref = `${resourcePath}${sheetFile}`; + this.uiStyleSheetManager.linkExternalSheet(sheetHref); } @@ -856,7 +857,7 @@ export abstract class OSKView isEmbedded: this.config.isEmbedded, specialFont: { family: 'SpecialOSK', - files: [`${resourcePath}/keymanweb-osk.ttf`], + files: [`${resourcePath}keymanweb-osk.ttf`], path: '' // Not actually used. }, gestureParams: this.config.gestureParams diff --git a/web/src/engine/src/osk/visualKeyboard.ts b/web/src/engine/src/osk/visualKeyboard.ts index 3faa214a4b..f5e2c9b59f 100644 --- a/web/src/engine/src/osk/visualKeyboard.ts +++ b/web/src/engine/src/osk/visualKeyboard.ts @@ -1398,7 +1398,7 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi styleSheetManager: null, specialFont: { family: 'SpecialOSK', - files: [`${pathConfig.resources}/osk/keymanweb-osk.ttf`], + files: [`${pathConfig.resources}keymanweb-osk.ttf`], path: '' // Not actually used. } }); From 7155e9d7223ee21d249df1d3698bc00f8ffb5fab Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 09:50:51 +0200 Subject: [PATCH 064/148] fix(android): refactor `KMLog` - simplification and added resilience * Clean up `KMLog` -- simpler code paths, DRY out common validation, remove redundant re-entrancy checks. * Use Sentry `setTag` API and scopes instead of `setExtra`. * Wrap all potential failure points in exception handlers for extra resilience -- do our best to make sure errors are reported in as many cases as possible. Fixes: #16122 Test-bot: skip --- .../java/com/keyman/engine/util/KMLog.java | 185 +++++++++--------- 1 file changed, 92 insertions(+), 93 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java index 1ce55bf0ce..e6eb05a96e 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java @@ -24,16 +24,11 @@ import io.sentry.SentryLevel; public final class KMLog { private static final String TAG = "KMLog"; - private static final String KEYBOARD_TAG = "keyboardId"; - private static final String KEYBOARD_COUNT_TAG = "installedKeyboardCount"; - private static final String MODEL_TAG = "modelId"; - private static final String LANGCODE_TAG = "languageCode"; - - // Some of the methods used to generate debug logging information can, themselves, - // trigger errors that can also trigger the same logging. We must not get - // caught in an infinite loop / stack-overflow; this field helps us avoid states - // that would otherwise cause error-looping, etc. - private static boolean isLogging = false; + private static final String KEYBOARD_TAG = "keyman.keyboardId"; + private static final String KEYBOARD_COUNT_TAG = "keyman.installedKeyboardCount"; + private static final String MODEL_TAG = "keyman.modelId"; + private static final String LANGCODE_TAG = "keyman.languageCode"; + private static final String DEBUG_LOGGING_ERROR_TAG = "keyman.debugLoggingError"; private static void tagDebugInfo() { String kbdId = ""; @@ -57,13 +52,16 @@ public final class KMLog { } } } catch (Exception ex) { - String msg = ex.getMessage() == null ? "" : ex.getMessage(); - Sentry.setExtra("debugLoggingError", msg); + Sentry.setTag(DEBUG_LOGGING_ERROR_TAG, ex.getMessage() == null ? "" : ex.getMessage()); } - Sentry.setExtra(KEYBOARD_TAG, kbdId); - Sentry.setExtra(KEYBOARD_COUNT_TAG, "" + kbdCount); - Sentry.setExtra(LANGCODE_TAG, lngCode); - Sentry.setExtra(MODEL_TAG, modelId); + Sentry.setTag(KEYBOARD_TAG, kbdId); + Sentry.setTag(KEYBOARD_COUNT_TAG, "" + kbdCount); + Sentry.setTag(LANGCODE_TAG, lngCode); + Sentry.setTag(MODEL_TAG, modelId); + } + + private static boolean canLogToSentry() { + return DependencyUtil.libraryExists(LibraryType.SENTRY) && Sentry.isEnabled(); } /** @@ -72,19 +70,20 @@ public final class KMLog { * @param msg String of the info message */ public static void LogInfo(String tag, String msg) { - if(isLogging) { + if (msg == null || msg.isEmpty()) { return; } - isLogging = true; - if (msg != null && !msg.isEmpty()) { - Log.i(tag, msg); - if (DependencyUtil.libraryExists(LibraryType.SENTRY) && Sentry.isEnabled()) { - tagDebugInfo(); - Sentry.captureMessage(msg, SentryLevel.INFO); - } + Log.i(tag, msg); + + if (!canLogToSentry()) { + return; } - isLogging = false; + + Sentry.withScope(scope -> { + tagDebugInfo(); + Sentry.captureMessage(msg, SentryLevel.INFO); + }); } /** @@ -98,15 +97,9 @@ public final class KMLog { return; } - if(isLogging) { - return; - } - isLogging = true; - Log.i(tag, msg); - if (!DependencyUtil.libraryExists(LibraryType.SENTRY) || !Sentry.isEnabled()) { - isLogging = false; + if (!canLogToSentry()) { return; } @@ -114,27 +107,30 @@ public final class KMLog { crumb.setMessage(msg); crumb.setLevel(SentryLevel.INFO); - if(addStackTrace) { - StackTraceElement[] rawTrace = Thread.currentThread().getStackTrace(); + try { + if(addStackTrace) { + StackTraceElement[] rawTrace = Thread.currentThread().getStackTrace(); - // The call that gets us the stack-trace above... shows up in the - // stack trace, so we'll skip the first few (redundant) entries. - int skipCount = 3; + // The call that gets us the stack-trace above... shows up in the + // stack trace, so we'll skip the first few (redundant) entries. + int skipCount = 3; - // Sentry does limit the size of messages... so let's just - // keep 10 entries and call it a day. - int limit = Math.min(rawTrace.length, 10 + skipCount); - if(rawTrace.length > skipCount) { - String[] trace = new String[limit - skipCount]; - for (int i = skipCount; i < limit; i++) { - trace[i-skipCount] = rawTrace[i].toString(); + // Sentry does limit the size of messages... so let's just + // keep 10 entries and call it a day. + int limit = Math.min(rawTrace.length, 10 + skipCount); + if(rawTrace.length > skipCount) { + String[] trace = new String[limit - skipCount]; + for (int i = skipCount; i < limit; i++) { + trace[i-skipCount] = rawTrace[i].toString(); + } + crumb.setData("stacktrace", trace); } - crumb.setData("stacktrace", trace); } + } catch (Exception e) { + Sentry.captureException(e); } - tagDebugInfo(); + Sentry.addBreadcrumb(crumb); - isLogging = false; } /** @@ -143,23 +139,32 @@ public final class KMLog { * @param msg String of the error message */ public static void LogError(String tag, String msg) { - if(isLogging) { + if (msg == null || msg.isEmpty()) { return; } - isLogging = true; - if (msg != null && !msg.isEmpty()) { - Log.e(tag, msg); + Log.e(tag, msg); + + try { + // On alpha and beta tiers, we pop an error toast so testers can be aware + // of the error if (KMManager.getTier(BuildConfig.KEYMAN_ENGINE_VERSION_NAME) != KMManager.Tier.STABLE) { BaseActivity.makeToast(null, msg, Toast.LENGTH_LONG); } - - if (DependencyUtil.libraryExists(LibraryType.SENTRY) && Sentry.isEnabled()) { - tagDebugInfo(); - Sentry.captureMessage(msg, SentryLevel.ERROR); + } catch(Exception e) { + if(canLogToSentry()) { + Sentry.captureException(e); } } - isLogging = false; + + if (!canLogToSentry()) { + return; + } + + Sentry.withScope(scope -> { + tagDebugInfo(); + Sentry.captureMessage(msg, SentryLevel.ERROR); + }); } /** @@ -169,28 +174,7 @@ public final class KMLog { * @param e Throwable exception */ public static void LogException(String tag, String msg, Throwable e) { - if(isLogging) { - return; - } - isLogging = true; - String errorMsg = ""; - if (msg != null && !msg.isEmpty()) { - errorMsg = msg + "\n" + e; - } else if (e != null) { - errorMsg = e.getMessage(); - } - Log.e(tag, errorMsg, e); - - if (KMManager.getTier(BuildConfig.KEYMAN_ENGINE_VERSION_NAME) != KMManager.Tier.STABLE) { - BaseActivity.makeToast(null, errorMsg, Toast.LENGTH_LONG); - } - - if (DependencyUtil.libraryExists(LibraryType.SENTRY) && Sentry.isEnabled()) { - tagDebugInfo(); - Sentry.addBreadcrumb(errorMsg); - Sentry.captureException(e); - } - isLogging = false; + KMLog.LogExceptionWithData(tag, msg, null, null, e); } /** @@ -203,25 +187,40 @@ public final class KMLog { */ public static void LogExceptionWithData(String tag, String msg, String objName, Object obj, Throwable e) { - if(isLogging) { + String errorMsg = ""; + try { + if (msg != null && !msg.isEmpty()) { + errorMsg = msg + "\n" + e.toString(); + } else if (e != null) { + errorMsg = e.toString(); + } + + // On alpha and beta tiers, we pop an error toast so testers can be aware + // of the exception + if (KMManager.getTier(BuildConfig.KEYMAN_ENGINE_VERSION_NAME) != KMManager.Tier.STABLE) { + BaseActivity.makeToast(null, errorMsg, Toast.LENGTH_LONG); + } + } catch (Exception innerE) { + if (canLogToSentry()) { + Sentry.captureException(innerE); + } + } + + if (!canLogToSentry()) { return; } - isLogging = true; - if (obj != null && DependencyUtil.libraryExists(LibraryType.SENTRY) && Sentry.isEnabled()) { + + Sentry.addBreadcrumb(errorMsg); + Sentry.withScope(scope -> { tagDebugInfo(); - String objStr = null; try { - objStr = obj.toString(); - Sentry.setExtra(objName, objStr); - } catch (Exception innerE) { - LogException(TAG, "Sentry.setExtra failed for " + objName, innerE); + if(obj != null && objName != null) { + Sentry.setTag(objName, obj.toString()); + } + } catch(Exception innerE) { + Sentry.captureException(innerE); } - // Report the original exception - LogException(tag, msg, e); - // And remove the exception-specific tagged data, lest it also be - // tracked on subsequent errors not associated with the current call. - Sentry.removeExtra(objName); - } - isLogging = false; + Sentry.captureException(e); + }); } } From 83e9501b6519394e9a1b133bdf925a7f7b060365 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 12:46:27 +0200 Subject: [PATCH 065/148] maint(linux): show output of API check also in log file Previously the output of running the API check was only shown on the summary page, but not in the log file of the step. With this change it is now also displayed in the log file. Build-bot: skip Test-bot: skip --- .github/workflows/api-verification.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index 56726817dc..95c9b959a1 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -71,7 +71,7 @@ jobs: - name: "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }})" if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' run: | - echo "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }}):" >> $GITHUB_STEP_SUMMARY + echo "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }}):" | tee -a ${GITHUB_STEP_SUMMARY} BIN_PACKAGE=$(ls "${GITHUB_WORKSPACE}/artifacts/" | grep "${PKG_NAME}[0-9]*_${{ steps.environment_step.outputs.KEYMAN_VERSION }}-1${{ steps.environment_step.outputs.PRERELEASE_TAG }}+$(lsb_release -c -s)1_amd64.deb") cd ${{ github.workspace }}/keyman/linux @@ -80,7 +80,7 @@ jobs: --bin-pkg "${GITHUB_WORKSPACE}/artifacts/${BIN_PACKAGE}" \ --git-sha "${{ steps.environment_step.outputs.GIT_SHA }}" \ --git-base "${{ steps.environment_step.outputs.GIT_BASE }}" \ - verify 2>> $GITHUB_STEP_SUMMARY + verify 2> >(tee -a ${GITHUB_STEP_SUMMARY} >&2) - name: Archive .symbols file if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' && always() From 9d1f197c39d4b840cb0b8e9b641b03b76cecf76e Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:09:45 +0200 Subject: [PATCH 066/148] chore: add missing line to history --- HISTORY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/HISTORY.md b/HISTORY.md index f2d3cab56b..f9d015bc76 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -39,6 +39,7 @@ ## 19.0.241 alpha 2026-06-02 +* chore: web-core preflight (#16015) * chore(web): web-core preflight - strip core references (#16040) * docs: add note on how to use composer on dockerized websites (#16029) * fix(web): fix race displaying active keyboard in menu (#16042) From c2d72b7a0331f8232447be7f81561acc3acd1022 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:40:05 +0200 Subject: [PATCH 067/148] fix(web): revert regression in setting `activeKeyboard` in `set osk` Fixes: #16155 Test-bot: skip --- web/src/engine/src/main/keymanEngineBase.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/engine/src/main/keymanEngineBase.ts b/web/src/engine/src/main/keymanEngineBase.ts index bdb43e05ea..268752ad48 100644 --- a/web/src/engine/src/main/keymanEngineBase.ts +++ b/web/src/engine/src/main/keymanEngineBase.ts @@ -3,7 +3,7 @@ import { ProcessorInitOptions } from 'keyman/engine/js-processor'; import { DOMKeyboardLoader } from "keyman/engine/keyboard"; import { WorkerFactory } from "@keymanapp/lexical-model-layer/web" import { InputProcessor } from './headless/inputProcessor.js'; -import { OSKView, KeyboardData } from "keyman/engine/osk"; +import { OSKView } from "keyman/engine/osk"; import { KeyboardRequisitioner, ModelCache, toUnprefixedKeyboardId, DOMCloudRequester } from "keyman/engine/keyboard-storage"; import { ModelSpec, PredictionContext } from "keyman/engine/interfaces"; @@ -386,7 +386,7 @@ export class KeymanEngineBase< this.core.keyboardProcessor.contextDevice = value?.targetDevice ?? this.config.softDevice; if(value) { // Don't build an OSK if no keyboard is available yet; avoid the extra flash. - if (this.contextManager.activeKeyboard && this.contextManager.activeKeyboard instanceof KeyboardData) { // TODO-embed-osk-in-kmx: add support for OSK for KMX keyboards + if (this.contextManager.activeKeyboard) { value.activeKeyboard = this.contextManager.activeKeyboard; } value.on('keyevent', this.keyEventListener); From 724249263ce4d91416866c6d9e1c90623c2d377c Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:56:31 +0200 Subject: [PATCH 068/148] maint(resources): DRY out `PRInformation` interface Test-bot: skip Build-bot: skip --- resources/build/version/src/fixupHistory.ts | 6 +----- resources/build/version/src/reportHistory.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/resources/build/version/src/fixupHistory.ts b/resources/build/version/src/fixupHistory.ts index 4f34c0c13d..ff98088571 100644 --- a/resources/build/version/src/fixupHistory.ts +++ b/resources/build/version/src/fixupHistory.ts @@ -7,13 +7,9 @@ type GitHub = ReturnType; import { readFileSync, writeFileSync } from 'node:fs'; import { gt } from 'semver'; -import { reportHistory } from './reportHistory.js'; +import { reportHistory, PRInformation } from './reportHistory.js'; import { spawnChild } from './util/spawnAwait.js'; -interface PRInformation { - title: string; - number: number; -} // ------------------------------------------------------------------------------------ // splitPullsIntoHistory diff --git a/resources/build/version/src/reportHistory.ts b/resources/build/version/src/reportHistory.ts index 91bfb53d33..8fbd8519b9 100644 --- a/resources/build/version/src/reportHistory.ts +++ b/resources/build/version/src/reportHistory.ts @@ -33,7 +33,7 @@ const getPullRequestInformation = async ( return commit_id; }; -interface PRInformation { +export interface PRInformation { title: string; number: number; version?: string; From e2f0954cd5ad4acbc45a4cf81196a0f8ace22912 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:59:26 +0200 Subject: [PATCH 069/148] fix(developer): Free val in GetSourcePathFromBody Co-authored-by: Eberhard Beilharz --- ....UfrmCloneKeymanCloudProjectParameters.pas | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas index 36014446b0..66f321befb 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas @@ -255,22 +255,25 @@ function TfrmCloneKeymanCloudProjectParameters.IsKeyboardSourceAvailable(const i // Not a valid response Exit(''); end; + try + if not (val is TJSONObject) then + begin + // Not a valid response + Exit(''); + end; - if not (val is TJSONObject) then - begin - // Not a valid response - Exit(''); + obj := val as TJSONObject; + val := obj.Values['sourcePath']; + if not Assigned(val) or not (val is TJSONString) then + begin + // no sourcePath property + Exit(''); + end; + + Result := (val as TJSONString).Value; + finally + val.Free; end; - - obj := val as TJSONObject; - val := obj.Values['sourcePath']; - if not Assigned(val) or not (val is TJSONString) then - begin - // no sourcePath property - Exit(''); - end; - - Result := (val as TJSONString).Value; end; var From b2ee534612b702fc2d99c08e4c6c992cbf737915 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 16:11:39 +0200 Subject: [PATCH 070/148] docs: improve comments in keyman-developer-options.ts Build-bot: skip Co-authored-by: Eberhard Beilharz --- .../src/common/web/utils/src/keyman-developer-options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/developer/src/common/web/utils/src/keyman-developer-options.ts b/developer/src/common/web/utils/src/keyman-developer-options.ts index f1e831e684..3117ab8633 100644 --- a/developer/src/common/web/utils/src/keyman-developer-options.ts +++ b/developer/src/common/web/utils/src/keyman-developer-options.ts @@ -17,7 +17,7 @@ export const KeymanDeveloperOptionsPath = [/* '~', */ '.keymandeveloper', 'optio /** * The set of standard user options for Keyman Developer. Corresponds to - * TKeymanDeveloperOptions in the Keyman Developer TIKE source. + * TKeymanDeveloperOptions in `developer/src/tike/main/KeymanDeveloperOptions.pas` */ export interface KeymanDeveloperOptions { "use tab char": boolean; @@ -61,7 +61,7 @@ export interface KeymanDeveloperOptions { export type KeymanDeveloperOption = keyof KeymanDeveloperOptions; const DEFAULT_OPTIONS: KeymanDeveloperOptions = { - // Corresponds to KeymanDeveloperOptions.pas, TKeymanDeveloperOptions.Read + // Corresponds to TKeymanDeveloperOptions.Read in KeymanDeveloperOptions.pas "use tab char": false, "link font sizes": true, "indent size": 4, From b535203e366311cba22f7d50b5ef08a6a17c8aaa Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 16:34:39 +0200 Subject: [PATCH 071/148] chore(developer): fix docs, api_extractor params in build.sh's Test-bot: skip Build-bot: skip release:developer --- common/tools/api-extractor/README.md | 36 ++++++++++++++---------- developer/src/kmc-generate/build.sh | 2 +- developer/src/kmc-keyboard-info/build.sh | 2 +- developer/src/kmc-kmn/build.sh | 2 +- developer/src/kmc-ldml/build.sh | 2 +- developer/src/kmc-model-info/build.sh | 2 +- developer/src/kmc-model/build.sh | 2 +- developer/src/kmc-package/build.sh | 2 +- 8 files changed, 28 insertions(+), 22 deletions(-) diff --git a/common/tools/api-extractor/README.md b/common/tools/api-extractor/README.md index e936730929..0462e78bcb 100644 --- a/common/tools/api-extractor/README.md +++ b/common/tools/api-extractor/README.md @@ -1,19 +1,29 @@ -# api-extractor.template.json +# Introduction + +These files are used by `typescript_run_api_extractor()` in typescript.inc.sh. + +This is setup only for Developer projects at this time as outputs go into +developer/docs and developer/build; future generalization requires changing only +`$report_temp` and `$report_folder` parameters in +`typescript_run_api_extractor()`. + +## api-extractor.template.json * Reference: https://api-extractor.com -api-extractor.template.json contains a template for api-extractor; these parameters -cannot be passed in to the tool, so we modify this template as needed with the following -parameters: +api-extractor.template.json contains a template for api-extractor; these +parameters cannot be passed in to the tool, so we modify this template as needed +with the following parameters: -* `$keyman_root`: the `$KEYMAN_ROOT` variable, with backslash \ translated to forward slash / +* `$keyman_root`: the `$KEYMAN_ROOT` variable, with backslash \ translated to + forward slash / * `$index_d_ts`: the filename `index.d.ts` or the corresponding filename for the entry point of the project * `$project_path`: the path for the module, relative to the base of the repo * `$report_temp`: a temporary path for output files for api-extractor * `$report_folder`: target folder for completed api-extractor API documentation -# tsdoc.template.json +## tsdoc.template.json * Reference: https://tsdoc.org/pages/packages/tsdoc-config/ @@ -22,14 +32,10 @@ project folders (alongside tsconfig.json) before running api-extractor and removed again afterwards; there is no way to specify an alternate location for the file. -tsdoc.template.json includes a definition for "@since" which has been proposed in -https://github.com/microsoft/tsdoc/issues/136. +tsdoc.template.json includes a definition for "@since" which has been proposed +in https://github.com/microsoft/tsdoc/issues/136. -# Notes +## Notes -These files are used by `typescript_run_api_extractor()` in typescript.inc.sh. - -This is setup only for Developer projects at this time as outputs go into -developer/docs and developer/build; future generalization requires changing only -`$report_temp` and `$report_folder` parameters in -`typescript_run_api_extractor()`. +See also .github/workflows/api-verification.yml which does API validation of +Core for desktop platforms. \ No newline at end of file diff --git a/developer/src/kmc-generate/build.sh b/developer/src/kmc-generate/build.sh index fd4c71de6e..323c78937b 100755 --- a/developer/src/kmc-generate/build.sh +++ b/developer/src/kmc-generate/build.sh @@ -39,5 +39,5 @@ do_build() { builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build do_build -builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts +builder_run_action api typescript_run_api_extractor developer/src/kmc-generate main.d.ts builder_run_action test typescript_run_eslint_mocha_tests diff --git a/developer/src/kmc-keyboard-info/build.sh b/developer/src/kmc-keyboard-info/build.sh index 58b415b47f..abd4604d96 100755 --- a/developer/src/kmc-keyboard-info/build.sh +++ b/developer/src/kmc-keyboard-info/build.sh @@ -32,7 +32,7 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api typescript_run_api_extractor developer/src/kmc-copy index.d.ts +builder_run_action api typescript_run_api_extractor developer/src/kmc-keyboard-info index.d.ts builder_run_action test typescript_run_eslint_mocha_tests #------------------------------------------------------------------------------------------------------------------- diff --git a/developer/src/kmc-kmn/build.sh b/developer/src/kmc-kmn/build.sh index bb64f5c48d..2f1dd214ed 100755 --- a/developer/src/kmc-kmn/build.sh +++ b/developer/src/kmc-kmn/build.sh @@ -65,5 +65,5 @@ function do_test() { } builder_run_action build do_build -builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts +builder_run_action api typescript_run_api_extractor developer/src/kmc-kmn main.d.ts builder_run_action test do_test diff --git a/developer/src/kmc-ldml/build.sh b/developer/src/kmc-ldml/build.sh index 627286551c..b9e259e8df 100755 --- a/developer/src/kmc-ldml/build.sh +++ b/developer/src/kmc-ldml/build.sh @@ -85,5 +85,5 @@ builder_run_action clean do_clean builder_run_action configure do_configure builder_run_action build do_build builder_run_action build-fixtures do_build_fixtures -builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts +builder_run_action api typescript_run_api_extractor developer/src/kmc-ldml main.d.ts builder_run_action test typescript_run_eslint_mocha_tests 90 diff --git a/developer/src/kmc-model-info/build.sh b/developer/src/kmc-model-info/build.sh index deb29bcd01..75b1d5d17e 100755 --- a/developer/src/kmc-model-info/build.sh +++ b/developer/src/kmc-model-info/build.sh @@ -29,5 +29,5 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api typescript_run_api_extractor developer/src/kmc-copy index.d.ts +builder_run_action api typescript_run_api_extractor developer/src/kmc-model-info index.d.ts builder_run_action test typescript_run_eslint_mocha_tests 55 diff --git a/developer/src/kmc-model/build.sh b/developer/src/kmc-model/build.sh index ab32fbd153..c01a4cdb76 100755 --- a/developer/src/kmc-model/build.sh +++ b/developer/src/kmc-model/build.sh @@ -36,5 +36,5 @@ function do_build() { builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build do_build -builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts +builder_run_action api typescript_run_api_extractor developer/src/kmc-model main.d.ts builder_run_action test typescript_run_eslint_mocha_tests diff --git a/developer/src/kmc-package/build.sh b/developer/src/kmc-package/build.sh index d435bb5919..fe2102ff40 100755 --- a/developer/src/kmc-package/build.sh +++ b/developer/src/kmc-package/build.sh @@ -34,5 +34,5 @@ builder_parse "$@" builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure node_select_version_and_npm_ci builder_run_action build tsc --build -builder_run_action api typescript_run_api_extractor developer/src/kmc-copy main.d.ts +builder_run_action api typescript_run_api_extractor developer/src/kmc-package main.d.ts builder_run_action test typescript_run_eslint_mocha_tests From eea463b18bc7c37f7196d36421a597d8bd9c82a8 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 09:38:03 -0500 Subject: [PATCH 072/148] fix(web): set proper font path for doc-keyboards --- web/src/engine/src/osk/visualKeyboard.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/src/engine/src/osk/visualKeyboard.ts b/web/src/engine/src/osk/visualKeyboard.ts index f5e2c9b59f..07bb794110 100644 --- a/web/src/engine/src/osk/visualKeyboard.ts +++ b/web/src/engine/src/osk/visualKeyboard.ts @@ -40,7 +40,7 @@ import { KeyTip } from './keytip.interface.js'; import { OSKKey } from './keyboard-layout/oskKey.js'; import { OSKLayer, LayerLayoutParams } from './keyboard-layout/oskLayer.js'; import { OSKLayerGroup } from './keyboard-layout/oskLayerGroup.js'; -import { OSKView } from './views/oskView.js'; +import { getResourcePath, OSKView } from './views/oskView.js'; import { ParsedLengthStyle } from './lengthStyle.js'; import { defaultFontSize } from './fontSizeUtils.js'; import { PhoneKeyTip } from './input/gestures/browser/phoneKeytip.js'; @@ -1372,7 +1372,8 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi device: { formFactor?: DeviceSpec.FormFactor, OS?: DeviceSpec.OperatingSystem, - touchable?: boolean + touchable?: boolean, + browser?: DeviceSpec.Browser } = {}; // Device emulation for target documentation. @@ -1380,9 +1381,11 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi if (formFactor != 'desktop') { device.OS = DeviceSpec.OperatingSystem.iOS; device.touchable = true; + device.browser = DeviceSpec.Browser.Safari; } else { device.OS = DeviceSpec.OperatingSystem.Windows; device.touchable = false; + device.browser = DeviceSpec.Browser.Chrome; } const layout = PKbd.layout(formFactor); @@ -1398,7 +1401,11 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi styleSheetManager: null, specialFont: { family: 'SpecialOSK', - files: [`${pathConfig.resources}keymanweb-osk.ttf`], + files: [`${getResourcePath({ + // Not actually leveraged. + hostDevice: device as DeviceSpec, + pathConfig + })}keymanweb-osk.ttf`], path: '' // Not actually used. } }); From 0847fb6ca22381e1f6e910ee198a03dda2035670 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 16:15:47 +0200 Subject: [PATCH 073/148] chore(android): address code review comments Co-authored-by: Marc Durdin --- android/KMEA/app/src/main/assets/keyboard.html | 2 +- .../main/java/com/keyman/engine/KMKeyboard.java | 11 ++++------- .../com/keyman/engine/KMKeyboardWebViewClient.java | 11 ++++++----- .../src/main/java/com/keyman/engine/KMManager.java | 6 +----- .../main/java/com/keyman/engine/data/Keyboard.java | 7 ++----- .../java/com/keyman/engine/util/WebViewUtils.java | 14 +++++++++++++- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/android/KMEA/app/src/main/assets/keyboard.html b/android/KMEA/app/src/main/assets/keyboard.html index a9b34fd486..38f5ad617e 100644 --- a/android/KMEA/app/src/main/assets/keyboard.html +++ b/android/KMEA/app/src/main/assets/keyboard.html @@ -1,7 +1,7 @@ diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 807014697d..2987ed1795 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -86,7 +86,7 @@ final class KMKeyboard extends WebView { private static String txtFont = ""; private static String oskFont = null; - private String keyboardRoot = ""; + private static String keyboardRoot = ""; private final String fontUndefined = "undefined"; private GestureDetector gestureDetector; private static ArrayList kbEventListeners = null; @@ -367,10 +367,7 @@ final class KMKeyboard extends WebView { } else { KMManager.SystemKeyboardWebViewClient.setKeyboardLoaded(false); } - - // Use the reserved magic domain for loading the keyboard from the local device. - // See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader - String htmlPath = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMFilename_KeyboardHtml; + String htmlPath = WebViewUtils.buildAssetUrl(KMManager.KMFilename_KeyboardHtml); loadUrl(htmlPath); setBackgroundColor(0); } @@ -815,9 +812,9 @@ final class KMKeyboard extends WebView { // Set the base path of the keyboard depending on the package ID private void setKeyboardRoot(String packageID) { if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_UndefinedPackageID + "/"; + this.keyboardRoot = WebViewUtils.buildAssetUrl(KMManager.KMDefault_UndefinedPackageID + "/"); } else { - this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; + this.keyboardRoot = WebViewUtils.buildAssetUrl(KMManager.KMDefault_AssetPackages + "/" + packageID + "/"); } } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java index c4a2818e6c..f043a56681 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java @@ -23,6 +23,7 @@ import com.keyman.engine.KMManager.KeyboardType; import com.keyman.engine.KMManager.SuggestionType; import com.keyman.engine.util.KMLog; import com.keyman.engine.data.Keyboard; +import com.keyman.engine.util.WebViewUtils; import org.json.JSONObject; @@ -42,8 +43,8 @@ public final class KMKeyboardWebViewClient extends WebViewClient { this.keyboardType = keyboardType; this.keyboardLoaded = false; this.assetLoader = new WebViewAssetLoader.Builder() - .addPathHandler("/data/", new InternalStoragePathHandler(context, - context.getDir("data", Context.MODE_PRIVATE))) + .addPathHandler(WebViewUtils.ASSET_DATA_PATH, + new InternalStoragePathHandler(context, context.getDir("data", Context.MODE_PRIVATE))) .build(); if (keyboardType != KeyboardType.KEYBOARD_TYPE_INAPP && keyboardType != KeyboardType.KEYBOARD_TYPE_SYSTEM) { @@ -92,10 +93,10 @@ public final class KMKeyboardWebViewClient extends WebViewClient { SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); - if (index < 0) { - index = 0; + Keyboard keyboardInfo = null; + if (index >= 0) { + keyboardInfo = KMManager.getKeyboardInfo(context, index); } - Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index); String langId = null; if (keyboardInfo != null) { langId = keyboardInfo.getLanguageID(); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index a4e054eb75..57fc8aa919 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -398,10 +398,6 @@ public final class KMManager { return appContext.getDir("data", Context.MODE_PRIVATE).toString() + File.separator; } - public static String getResourceUrl() { - return WebViewUtils.MAGIC_DEFAULT_DOMAIN +"/data/"; - } - public static String getPackagesDir() { return getResourceRoot() + KMDefault_AssetPackages + File.separator; } @@ -411,7 +407,7 @@ public final class KMManager { } public static String getLexicalModelsUrl() { - return getResourceUrl() + KMDefault_LexicalModelPackages + "/"; + return WebViewUtils.buildAssetUrl(KMDefault_LexicalModelPackages + "/"); } public static String getCloudDir() { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index a160d96d03..267942b203 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -175,13 +175,10 @@ public class Keyboard extends LanguageResource implements Serializable { } private String getKeyboardRoot() { - String keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/"; if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - keyboardRoot += KMManager.KMDefault_UndefinedPackageID + "/"; - } else { - keyboardRoot += KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; + return WebViewUtils.buildAssetUrl(KMManager.KMDefault_UndefinedPackageID + "/"); } - return keyboardRoot; + return WebViewUtils.buildAssetUrl(KMManager.KMDefault_AssetPackages + "/" + packageID + "/"); } private String getKeyboardUrl() { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index 8a82556f02..e0779e59bc 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -48,7 +48,19 @@ public final class WebViewUtils { * internal storage path. * See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader */ - public static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + private static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + + /** + * Path under the asset domain where all assets live + */ + public static final String ASSET_DATA_PATH = "/data/"; + + /** + * Build a full URL to the provided asset + */ + public static String buildAssetUrl(String assetPath) { + return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + assetPath; + } /** * Get the Keyman Engine mode based on the Chrome version. From b805f66177108d6717ff43f522c8827b40eaa501 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 17:08:24 +0200 Subject: [PATCH 074/148] chore(android): address code review comment --- web/build.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/build.sh b/web/build.sh index 4154c6203c..143a3b8aa9 100755 --- a/web/build.sh +++ b/web/build.sh @@ -125,6 +125,10 @@ build_tests_action() { # shellcheck disable=SC2310 if ! builder_is_ci_release_build; then for f in "${KEYMAN_ROOT}/web/build/docs/engine/guide/examples"/*.html; do + # Replace CDN URL (https://s.keyman.com/kwm/engine/18.0.123) with + # local local build path (/build/publish/debug). We write to a temp + # file and then replace the original instead of modifying in-place. + # This is safer and more portable. sed "s|https://s\.keyman\.com/kmw/engine/[0-9]*\.[0-9]*\.[0-9]*/|/build/publish/${config}/|g" \ "${f}" > "${f}.tmp" && mv "${f}.tmp" "${f}" done From a70861dad871520af293cf9720ac54bc1688dcb3 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 22:14:40 +0700 Subject: [PATCH 075/148] change(web): Fix ios/engine/build.sh comment Co-authored-by: Marc Durdin --- ios/engine/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/engine/build.sh b/ios/engine/build.sh index 50aafcac9f..f341e13983 100755 --- a/ios/engine/build.sh +++ b/ios/engine/build.sh @@ -102,7 +102,7 @@ function update_bundle ( ) { KMW_PRODUCT="$KEYMAN_ROOT/web/build/app/webview/$CONFIG" KMW_RESOURCES="$KEYMAN_ROOT/web/build/app/resources" - # Copy over the relevant resources! It's easiest to do if we navigate to the resulting folder. + # Copy relevant KeymanWeb resources; this list is also in Storage.swift cp "$KMW_RESOURCES/osk/globe-hint.css" "$BUNDLE_PATH/globe-hint.css" cp "$KMW_RESOURCES/osk/kmwosk.css" "$BUNDLE_PATH/kmwosk.css" cp "$KMW_RESOURCES/osk/keymanweb-osk.ttf" "$BUNDLE_PATH/keymanweb-osk.ttf" From fa435cbeaeb1fe29f7d2ca0878a30fccf26898ca Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 22:21:58 +0700 Subject: [PATCH 076/148] docs(ios): change file header date format Co-authored-by: Marc Durdin --- .../KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift index 28c41adaeb..f67f143503 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -1,7 +1,7 @@ /* * Keyman is copyright (C) SIL Global. MIT License. * - * Created by Joshua Horton on 6/24/26. + * Created by Joshua Horton on 2026-06-24. * * WebViewKeyboardLoader implements a URLSchemeHandler that allows * the hosted Keyman Engine for Web to access all files, consistently, From 700380ddc15a0a632691d7a4fba5214a61011c52 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 10:23:44 -0500 Subject: [PATCH 077/148] change(ios): pre-emptively denote utf-8 encoding for JSON We don't actually pass JSON through our scheme handler yet, but just in case... we ensure we detect JSON's MIME type and annotate appropriately --- .../KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift index f67f143503..0b09f79732 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -63,7 +63,9 @@ class WebViewSchemeHandler: NSObject, WKURLSchemeHandler { let fileExtension = fileUrl!.pathExtension let mimeType: String = getMimeType(forExtension: fileExtension) - let charset: String = mimeType.hasPrefix("text/") ? "; charset=utf-8" : "" + let charset: String = (mimeType.hasPrefix("text/") || mimeType == "application/json") + ? "; charset=utf-8" + : "" let response = HTTPURLResponse( url: url, From bec75197b1c01ab8b0e51b36f36b001351eb4bdd Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 17:56:06 +0200 Subject: [PATCH 078/148] fix(android): add tests for `WebViewUtils.buildAssertUrl` Also fix `assertEquals` in that test file - the parameter ordering is `expected, actual` but we had it the other way round which gives a confusing message if the test fails. --- .../com/keyman/engine/util/WebViewUtils.java | 6 ++-- .../keyman/engine/util/WebViewUtilsTest.java | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index e0779e59bc..11c64434da 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -48,7 +48,7 @@ public final class WebViewUtils { * internal storage path. * See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader */ - private static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + private static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; /** * Path under the asset domain where all assets live @@ -59,7 +59,9 @@ public final class WebViewUtils { * Build a full URL to the provided asset */ public static String buildAssetUrl(String assetPath) { - return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + assetPath; + String appendAsset = assetPath == null ? "" : + (assetPath.startsWith("/") ? assetPath.substring(1) : assetPath); + return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + appendAsset; } /** diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java index 342934200a..bfb867ecc9 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java @@ -26,37 +26,48 @@ public class WebViewUtilsTest { @Test public void test_ChromeEmpty_EngineWebViewVersionStatusDisabled() { String chromeVersion = ""; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DISABLED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DISABLED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome36_EngineWebViewVersionStatusDisabled() { double chromeVersionFloat = Float.parseFloat(WebViewUtils.KEYMAN_MIN_TARGET_VERSION_DEGRADED_ANDROID_CHROME) - 1.0; String chromeVersion = String.valueOf(chromeVersionFloat); - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DISABLED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DISABLED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome37_EngineWebViewVersionStatusDegraded() { String chromeVersion = WebViewUtils.KEYMAN_MIN_TARGET_VERSION_DEGRADED_ANDROID_CHROME; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DEGRADED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DEGRADED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome94_EngineWebViewVersionStatusDegraded() { double chromeVersionFloat = Float.parseFloat(WebViewUtils.KEYMAN_MIN_TARGET_VERSION_ANDROID_CHROME) - 1.0; String chromeVersion = String.valueOf(chromeVersionFloat); - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DEGRADED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DEGRADED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome95_EngineWebViewVersionStatusFull() { String chromeVersion = WebViewUtils.KEYMAN_MIN_TARGET_VERSION_ANDROID_CHROME; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.FULL); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.FULL, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } + + @Test + public void test_buildAssetUrl() { + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl("")); + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl(null)); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo", WebViewUtils.buildAssetUrl("foo")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("/foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/bar.html", WebViewUtils.buildAssetUrl("foo/bar.html")); + } + } From 71b0586c66913bb7bbbbe76222482a607e630540 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 18:22:02 +0200 Subject: [PATCH 079/148] fix(android): fix failing test on Windows On Windows the tests failed because the AppData directory doesn't yet exist under an allowed app internal storage path. This change creates the directory first when running tests. --- .../src/test/java/com/keyman/engine/KMManagerTest.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java index 91fc7dd370..71fa9c5ddb 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java @@ -1,5 +1,9 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ package com.keyman.engine; +import android.content.Context; import android.util.Log; import androidx.test.core.app.ApplicationProvider; @@ -32,7 +36,11 @@ public class KMManagerTest { // For some keyboard list tests, load an existing keyboard list. // Can't use @Before because context is null before running tests. public void loadOldKeyboardsList() { - KMManager.initialize(ApplicationProvider.getApplicationContext(), KMManager.KeyboardType.KEYBOARD_TYPE_INAPP); + Context context = ApplicationProvider.getApplicationContext(); + // Create appData directory accessed in KMKeyboardWebViewClient + File dataDir = context.getDir("data", Context.MODE_PRIVATE); + dataDir.mkdirs(); + KMManager.initialize(context, KMManager.KeyboardType.KEYBOARD_TYPE_INAPP); File keyboards_dat = new File(TEST_RESOURCE_ROOT, OLD_KEYBOARDS_LIST); if (keyboards_dat == null || !keyboards_dat.exists()) { From 03d20f1d1c343b3c07c908f876f8e48d0db1a166 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 12:17:51 -0500 Subject: [PATCH 080/148] change(web): rework type nomenclature --- .../worker-thread/src/main/predict-helpers.ts | 32 ++++++------- .../early-correction-search-stopping.tests.ts | 4 +- .../prediction-helpers/auto-correct.tests.ts | 48 +++++++++---------- .../create-default-keep.tests.ts | 14 +++--- ...ine-tokenized-correction-sequence.tests.ts | 4 +- ...raversalless-correction-sequences.tests.ts | 4 +- .../suggestion-deduplication.tests.ts | 10 ++-- .../suggestion-finalization.tests.ts | 10 ++-- .../suggestion-similarity.tests.ts | 18 +++---- 9 files changed, 72 insertions(+), 72 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 914f31cb95..3a7bcdf6ef 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -174,7 +174,7 @@ export interface PredictionMetadata { preservationTransform?: Transform; } -export interface IntermediateCompositedPrediction { +export interface CompositedIntermediatePrediction { /** * Contains the fully composited predictive-text Suggestion and its underlying correction string. */ @@ -185,7 +185,7 @@ export interface IntermediateCompositedPrediction { metadata: PredictionMetadata; } -type IntermediatePrediction = IntermediateCompositedPrediction; +type IntermediatePrediction = CompositedIntermediatePrediction; /** * An enum to be used when categorizing the level of similarity between @@ -494,7 +494,7 @@ export interface PredictionParameters { * @param entry * @returns */ - applyInPost: (entry: IntermediateCompositedPrediction) => void + applyInPost: (entry: CompositedIntermediatePrediction) => void } export function buildCorrectionSequence( @@ -577,7 +577,7 @@ export function determineTokenizedCorrectionSequence( return { ...suggestionParams, - applyInPost: (entry: IntermediateCompositedPrediction) => { + applyInPost: (entry: CompositedIntermediatePrediction) => { entry.metadata.preservationTransform = tokenization.taillessTrueKeystroke; // // Will need an extra lookup layer if the suggestion is generated from within a cluster. // entry.baseTokenization = transition.final.tokenizationSourceMap.get(tokenization); @@ -612,7 +612,7 @@ export async function correctAndEnumerate( /** * The suggestions generated based on the user's input state. */ - rawPredictions: IntermediateCompositedPrediction[]; + rawPredictions: CompositedIntermediatePrediction[]; /** * The id of a prior ContextTransition event that triggered a Suggestion found @@ -668,7 +668,7 @@ export async function correctAndEnumerate( const searchModules = tokenizations.map(t => t.tail.searchModule); // Only run the correction search when corrections are enabled. - let rawPredictions: IntermediateCompositedPrediction[] = []; + let rawPredictions: CompositedIntermediatePrediction[] = []; let bestCorrectionCost: number; for await(const match of getBestTokenMatches(searchModules, timer)) { // Corrections obtained: now to predict from them! @@ -721,7 +721,7 @@ export async function correctAndEnumerate( export function shouldStopSearchingEarly( bestCorrectionCost: number, currentCorrectionCost: number, - rawPredictions: IntermediateCompositedPrediction[] + rawPredictions: CompositedIntermediatePrediction[] ) { if(currentCorrectionCost >= bestCorrectionCost + CORRECTION_SEARCH_THRESHOLDS.MAX_SEARCH_THRESHOLD) { return true; @@ -767,7 +767,7 @@ export function predictFromCorrectionSequence( corrections: ProbabilityMass[], rootContext: Context, transitionId: number -): IntermediateCompositedPrediction[] { +): CompositedIntermediatePrediction[] { let predictionPrefixSequence: ProbabilityMass[] = []; let tailPredictions: ProbabilityMass[]; @@ -816,7 +816,7 @@ export function predictFromCorrectionSequence( return []; } - const predictions: IntermediateCompositedPrediction[] = tailPredictions.map((p) => { + const predictions: CompositedIntermediatePrediction[] = tailPredictions.map((p) => { // Concat corrections + predictions for their components. const predictionSequence = [...predictionPrefixSequence, p]; const fullPrediction: ProbabilityMass = predictionSequence.reduce((prev, curr) => { @@ -896,13 +896,13 @@ export function applySuggestionCasing(suggestion: Suggestion, baseWord: string, */ export function dedupeSuggestions( lexicalModel: LexicalModel, - rawPredictions: IntermediateCompositedPrediction[], + rawPredictions: CompositedIntermediatePrediction[], context: Context ) { const wordbreak = determineModelWordbreaker(lexicalModel); - let suggestionDistribMap: {[key: string]: IntermediateCompositedPrediction} = {}; - let suggestionDistribution: IntermediateCompositedPrediction[] = []; + let suggestionDistribMap: {[key: string]: CompositedIntermediatePrediction} = {}; + let suggestionDistribution: CompositedIntermediatePrediction[] = []; // Deduplicator + annotator of 'keep' suggestions. for(let tuple of rawPredictions) { @@ -953,7 +953,7 @@ export function dedupeSuggestions( */ export function processSimilarity( lexicalModel: LexicalModel, - suggestionDistribution: IntermediateCompositedPrediction[], + suggestionDistribution: CompositedIntermediatePrediction[], context: Context, trueInput: ProbabilityMass ): boolean { @@ -1030,7 +1030,7 @@ export function createDefaultKeep( lexicalModel: LexicalModel, context: Context, trueInput: ProbabilityMass -): IntermediateCompositedPrediction { +): CompositedIntermediatePrediction { const { sample: inputTransform, p: inputTransformProb } = trueInput; const wordbreak = determineModelWordbreaker(lexicalModel); const tokenizer = determineModelTokenizer(lexicalModel); @@ -1111,7 +1111,7 @@ export function correctionValidForAutoSelect(correction: string) { return false; } -export function predictionAutoSelect(suggestionDistribution: IntermediateCompositedPrediction[]) { +export function predictionAutoSelect(suggestionDistribution: CompositedIntermediatePrediction[]) { if(suggestionDistribution.length == 0) { return; } @@ -1205,7 +1205,7 @@ export function predictionAutoSelect(suggestionDistribution: IntermediateComposi */ export function finalizeSuggestions( lexicalModel: LexicalModel, - deduplicatedSuggestionTuples: IntermediateCompositedPrediction[], + deduplicatedSuggestionTuples: CompositedIntermediatePrediction[], context: Context, inputTransform: Transform, verbose?: boolean diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/early-correction-search-stopping.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/early-correction-search-stopping.tests.ts index 430d9c6c7e..bed2c0f225 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/early-correction-search-stopping.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/early-correction-search-stopping.tests.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; -import { CORRECTION_SEARCH_THRESHOLDS, IntermediateCompositedPrediction, ModelCompositor, shouldStopSearchingEarly } from "@keymanapp/lm-worker/test-index"; +import { CORRECTION_SEARCH_THRESHOLDS, CompositedIntermediatePrediction, ModelCompositor, shouldStopSearchingEarly } from "@keymanapp/lm-worker/test-index"; function mockIntermediatePrediction(value: number) { return { @@ -9,7 +9,7 @@ function mockIntermediatePrediction(value: number) { total: value } } - } as IntermediateCompositedPrediction + } as CompositedIntermediatePrediction } describe('correction-search: shouldStopSearchingEarly', () => { diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/auto-correct.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/auto-correct.tests.ts index b55886bb42..ecb4b09357 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/auto-correct.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/auto-correct.tests.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; -import { AUTOSELECT_PROPORTION_THRESHOLD, IntermediateCompositedPrediction, predictionAutoSelect, SuggestionSimilarity, tupleDisplayOrderSort } from "@keymanapp/lm-worker/test-index"; +import { AUTOSELECT_PROPORTION_THRESHOLD, CompositedIntermediatePrediction, predictionAutoSelect, SuggestionSimilarity, tupleDisplayOrderSort } from "@keymanapp/lm-worker/test-index"; /* * Preconditions: * - there should always be a 'keep' option. Now, whether or not that option @@ -9,7 +9,7 @@ import { AUTOSELECT_PROPORTION_THRESHOLD, IntermediateCompositedPrediction, pred */ describe('predictionAutoSelect', () => { it(`does not throw when no suggestions are available`, () => { - const predictions: IntermediateCompositedPrediction[] = []; + const predictions: CompositedIntermediatePrediction[] = []; const originalPredictions = [].concat(predictions); assert.doesNotThrow(() => predictionAutoSelect(predictions)); @@ -17,7 +17,7 @@ describe('predictionAutoSelect', () => { }); it(`selects solitary 'keep' suggestion that does match the model`, () => { - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ { components: { prediction: { @@ -51,7 +51,7 @@ describe('predictionAutoSelect', () => { }); it(`does not select suggestions if the root correction has no letters`, () => { - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ { components: { prediction: { @@ -106,7 +106,7 @@ describe('predictionAutoSelect', () => { }); it(`does not select solitary 'keep' suggestion that doesn't match the model`, () => { - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ { components: { prediction: { @@ -140,7 +140,7 @@ describe('predictionAutoSelect', () => { }); it(`selects 'keep' suggestion that does match the model over any alternatives`, () => { - const keepSuggestion: IntermediateCompositedPrediction = { + const keepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { tag: 'keep', @@ -163,7 +163,7 @@ describe('predictionAutoSelect', () => { } } - const highestNonKeepSuggestion: IntermediateCompositedPrediction = { + const highestNonKeepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { transform: { // can be null / "mocked out" @@ -184,7 +184,7 @@ describe('predictionAutoSelect', () => { } }; - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ keepSuggestion, highestNonKeepSuggestion, { @@ -238,7 +238,7 @@ describe('predictionAutoSelect', () => { }); it(`selects solitary non-'keep' suggestion when 'keep' does not match model`, () => { - const keepSuggestion: IntermediateCompositedPrediction = { + const keepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { tag: 'keep', @@ -265,7 +265,7 @@ describe('predictionAutoSelect', () => { // This threshold may be subject to change. // // Refer to AUTOSELECT_PROPORTION_THRESHOLD in predict-helpers.ts. - const onlyNonKeepSuggestion: IntermediateCompositedPrediction = { + const onlyNonKeepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { transform: { // can be null / "mocked out" @@ -286,7 +286,7 @@ describe('predictionAutoSelect', () => { } }; - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ keepSuggestion, onlyNonKeepSuggestion ]; @@ -305,7 +305,7 @@ describe('predictionAutoSelect', () => { }); it(`does not select non-'keep' without sufficient winning probability`, () => { - const keepSuggestion: IntermediateCompositedPrediction = { + const keepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { tag: 'keep', @@ -332,7 +332,7 @@ describe('predictionAutoSelect', () => { // This threshold may be subject to change. // // Refer to AUTOSELECT_PROPORTION_THRESHOLD in predict-helpers.ts. - const highestNonKeepSuggestion: IntermediateCompositedPrediction = { + const highestNonKeepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { transform: { // can be null / "mocked out" @@ -353,7 +353,7 @@ describe('predictionAutoSelect', () => { } }; - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ keepSuggestion, highestNonKeepSuggestion, { @@ -412,7 +412,7 @@ describe('predictionAutoSelect', () => { }); it(`does select non-'keep' with sufficient winning probability`, () => { - const keepSuggestion: IntermediateCompositedPrediction = { + const keepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { tag: 'keep', @@ -435,7 +435,7 @@ describe('predictionAutoSelect', () => { } } - const highestNonKeepSuggestion: IntermediateCompositedPrediction = { + const highestNonKeepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { transform: { // can be null / "mocked out" @@ -456,7 +456,7 @@ describe('predictionAutoSelect', () => { } }; - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ keepSuggestion, highestNonKeepSuggestion, { @@ -513,7 +513,7 @@ describe('predictionAutoSelect', () => { }); it('ignores non key-matched suggestions when key-matched suggestions exist', () => { - const keepSuggestion: IntermediateCompositedPrediction = { + const keepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { tag: 'keep', @@ -537,7 +537,7 @@ describe('predictionAutoSelect', () => { } } - const expectedSuggestion: IntermediateCompositedPrediction = { + const expectedSuggestion: CompositedIntermediatePrediction = { components: { prediction: { transform: { // can be null / "mocked out" @@ -559,7 +559,7 @@ describe('predictionAutoSelect', () => { } }; - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ keepSuggestion, expectedSuggestion, { @@ -597,7 +597,7 @@ describe('predictionAutoSelect', () => { // The idea: avoid "over-correcting" when a potential correction has a // super-high-frequency word. it('does not auto-select suggestion if its root correction is not most likely', () => { - const keepSuggestion: IntermediateCompositedPrediction = { + const keepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { tag: 'keep', @@ -620,7 +620,7 @@ describe('predictionAutoSelect', () => { } } - const highestCorrectionSuggestion: IntermediateCompositedPrediction = { + const highestCorrectionSuggestion: CompositedIntermediatePrediction = { components: { prediction: { transform: { // can be null / "mocked out" @@ -641,7 +641,7 @@ describe('predictionAutoSelect', () => { } }; - const highestNonKeepSuggestion: IntermediateCompositedPrediction = { + const highestNonKeepSuggestion: CompositedIntermediatePrediction = { components: { prediction: { transform: { // can be null / "mocked out" @@ -662,7 +662,7 @@ describe('predictionAutoSelect', () => { } }; - const predictions: IntermediateCompositedPrediction[] = [ + const predictions: CompositedIntermediatePrediction[] = [ keepSuggestion, highestNonKeepSuggestion, highestCorrectionSuggestion diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/create-default-keep.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/create-default-keep.tests.ts index afc0d4d232..8943b85da5 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/create-default-keep.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/create-default-keep.tests.ts @@ -12,7 +12,7 @@ import { assert } from 'chai'; import { LexicalModelTypes } from "@keymanapp/common-types"; import * as wordBreakers from '@keymanapp/models-wordbreakers'; -import { IntermediateCompositedPrediction, createDefaultKeep, models, SuggestionSimilarity } from "@keymanapp/lm-worker/test-index"; +import { CompositedIntermediatePrediction, createDefaultKeep, models, SuggestionSimilarity } from "@keymanapp/lm-worker/test-index"; import CasingFunction = LexicalModelTypes.CasingFunction; import Context = LexicalModelTypes.Context; @@ -108,7 +108,7 @@ describe('createDefaultKeep', () => { p: 1 }; - const expectedKeep: IntermediateCompositedPrediction = { + const expectedKeep: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -152,7 +152,7 @@ describe('createDefaultKeep', () => { p: 1 }; - const expectedKeep: IntermediateCompositedPrediction = { + const expectedKeep: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -196,7 +196,7 @@ describe('createDefaultKeep', () => { p: 1 }; - const expectedKeep: IntermediateCompositedPrediction = { + const expectedKeep: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -240,7 +240,7 @@ describe('createDefaultKeep', () => { p: 1 }; - const expectedKeep: IntermediateCompositedPrediction = { + const expectedKeep: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -284,7 +284,7 @@ describe('createDefaultKeep', () => { p: 1 }; - const expectedKeep: IntermediateCompositedPrediction = { + const expectedKeep: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -328,7 +328,7 @@ describe('createDefaultKeep', () => { p: 1 }; - const expectedKeep: IntermediateCompositedPrediction = { + const expectedKeep: CompositedIntermediatePrediction = { components: { prediction: { transform: { diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-tokenized-correction-sequence.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-tokenized-correction-sequence.tests.ts index 5cd7b67072..6f5729a7c8 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-tokenized-correction-sequence.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-tokenized-correction-sequence.tests.ts @@ -21,7 +21,7 @@ import { ContextState, ContextToken, ContextTokenization, - IntermediateCompositedPrediction, + CompositedIntermediatePrediction, ModelCompositor, TokenizationResultMapping } from "@keymanapp/lm-worker/test-index"; @@ -341,7 +341,7 @@ describe('determineTokenizedCorrectionSequence', () => { }); assert.approximately(results.tokenizedCorrection[0].p, Math.pow(trueInput.p, ModelCompositor.SINGLE_CHAR_KEY_PROB_EXPONENT), Number.EPSILON*1000); - const dummiedTuple: IntermediateCompositedPrediction = { + const dummiedTuple: CompositedIntermediatePrediction = { components: { prediction: { transform: { insert: 'dog', deleteLeft: 0 }, diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-traversalless-correction-sequences.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-traversalless-correction-sequences.tests.ts index a1872641d3..112c61d2a8 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-traversalless-correction-sequences.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-traversalless-correction-sequences.tests.ts @@ -13,7 +13,7 @@ import { LexicalModelTypes } from "@keymanapp/common-types"; import * as wordBreakers from '@keymanapp/models-wordbreakers'; import { KMWString } from 'keyman/common/web-utils'; -import { IntermediateCompositedPrediction, ModelCompositor, determineTraversallessCorrectionSequences, models } from "@keymanapp/lm-worker/test-index"; +import { CompositedIntermediatePrediction, ModelCompositor, determineTraversallessCorrectionSequences, models } from "@keymanapp/lm-worker/test-index"; import Context = LexicalModelTypes.Context; import DummyModel = models.DummyModel; @@ -377,7 +377,7 @@ describe('determineTraversallessCorrectionSequences', () => { }); assert.approximately(entry.tokenizedCorrection[0].p, Math.pow(trueInput.p, ModelCompositor.SINGLE_CHAR_KEY_PROB_EXPONENT), Number.EPSILON*1000); - const dummiedTuple: IntermediateCompositedPrediction = { + const dummiedTuple: CompositedIntermediatePrediction = { components: { prediction: { transform: { insert: 'dog', deleteLeft: 0 }, diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-deduplication.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-deduplication.tests.ts index 4a2afcf60d..d28d7a6343 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-deduplication.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-deduplication.tests.ts @@ -4,7 +4,7 @@ import * as wordBreakers from '@keymanapp/models-wordbreakers'; import { deepCopy } from 'keyman/common/web-utils'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { IntermediateCompositedPrediction, dedupeSuggestions, models } from "@keymanapp/lm-worker/test-index"; +import { CompositedIntermediatePrediction, dedupeSuggestions, models } from "@keymanapp/lm-worker/test-index"; import Context = LexicalModelTypes.Context; import DummyModel = models.DummyModel; @@ -24,7 +24,7 @@ const testModel = new DummyModel({ * @returns */ const build_its_is_set = () => { - const its: IntermediateCompositedPrediction = { + const its: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -46,7 +46,7 @@ const build_its_is_set = () => { } }; - const it_is: IntermediateCompositedPrediction = { + const it_is: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -67,7 +67,7 @@ const build_its_is_set = () => { } }; - const is: IntermediateCompositedPrediction = { + const is: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -88,7 +88,7 @@ const build_its_is_set = () => { } }; - const is_not: IntermediateCompositedPrediction = { + const is_not: CompositedIntermediatePrediction = { components: { prediction: { transform: { diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-finalization.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-finalization.tests.ts index 9ba1faec74..2bf9a6f96a 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-finalization.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-finalization.tests.ts @@ -5,7 +5,7 @@ import { deepCopy } from 'keyman/common/web-utils'; import * as wordBreakers from '@keymanapp/models-wordbreakers'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { IntermediateCompositedPrediction, finalizeSuggestions, models } from "@keymanapp/lm-worker/test-index"; +import { CompositedIntermediatePrediction, finalizeSuggestions, models } from "@keymanapp/lm-worker/test-index"; import DummyModel = models.DummyModel; import Outcome = LexicalModelTypes.Outcome; @@ -48,7 +48,7 @@ const testModelWithoutSpacing = new DummyModel({ */ const build_its_is_set = (verbose?: string) => { const verboseFlag = (verbose == 'verbose' ? true : false); - const its: IntermediateCompositedPrediction = { + const its: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -70,7 +70,7 @@ const build_its_is_set = (verbose?: string) => { } }; - const it_is: IntermediateCompositedPrediction = { + const it_is: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -91,7 +91,7 @@ const build_its_is_set = (verbose?: string) => { } }; - const is: IntermediateCompositedPrediction = { + const is: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -112,7 +112,7 @@ const build_its_is_set = (verbose?: string) => { } }; - const is_not: IntermediateCompositedPrediction = { + const is_not: CompositedIntermediatePrediction = { components: { prediction: { transform: { diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-similarity.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-similarity.tests.ts index 6a97039d24..76a605e869 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-similarity.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/suggestion-similarity.tests.ts @@ -5,7 +5,7 @@ import * as wordBreakers from '@keymanapp/models-wordbreakers'; import { deepCopy } from 'keyman/common/web-utils'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { IntermediateCompositedPrediction, models, processSimilarity, SuggestionSimilarity, toAnnotatedSuggestion } from "@keymanapp/lm-worker/test-index"; +import { CompositedIntermediatePrediction, models, processSimilarity, SuggestionSimilarity, toAnnotatedSuggestion } from "@keymanapp/lm-worker/test-index"; import CasingFunction = LexicalModelTypes.CasingFunction; import Context = LexicalModelTypes.Context; @@ -109,7 +109,7 @@ const testModelWithCasing = new DummyModel({ * @returns */ const build_its_is_set = () => { - const its: IntermediateCompositedPrediction = { + const its: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -131,7 +131,7 @@ const build_its_is_set = () => { } }; - const it_is: IntermediateCompositedPrediction = { + const it_is: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -152,7 +152,7 @@ const build_its_is_set = () => { } }; - const is: IntermediateCompositedPrediction = { + const is: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -173,7 +173,7 @@ const build_its_is_set = () => { } }; - const is_not: IntermediateCompositedPrediction = { + const is_not: CompositedIntermediatePrediction = { components: { prediction: { transform: { @@ -222,7 +222,7 @@ describe('processSimilarity', () => { const testSet = build_its_is_set(); const distribution = [...Object.values(testSet)]; - const expectation: IntermediateCompositedPrediction[] = [...Object.values(testSet)]; + const expectation: CompositedIntermediatePrediction[] = [...Object.values(testSet)]; expectation[0].metadata.matchLevel = SuggestionSimilarity.exact; // its expectation[1].metadata.matchLevel = SuggestionSimilarity.sameKey; // it_is expectation[2].metadata.matchLevel = SuggestionSimilarity.none; // is @@ -259,7 +259,7 @@ describe('processSimilarity', () => { const testSet = build_its_is_set(); const distribution = [...Object.values(testSet)]; - const expectation: IntermediateCompositedPrediction[] = [...Object.values(testSet)]; + const expectation: CompositedIntermediatePrediction[] = [...Object.values(testSet)]; expectation[0].metadata.matchLevel = SuggestionSimilarity.sameKey; // its expectation[1].metadata.matchLevel = SuggestionSimilarity.exact; // it_is expectation[2].metadata.matchLevel = SuggestionSimilarity.none; // is @@ -313,7 +313,7 @@ describe('processSimilarity', () => { const distribution = [...Object.values(testSet)]; - const expectation: IntermediateCompositedPrediction[] = [...Object.values(testSet)]; + const expectation: CompositedIntermediatePrediction[] = [...Object.values(testSet)]; expectation[0].metadata.matchLevel = SuggestionSimilarity.sameKey; // its expectation[1].metadata.matchLevel = SuggestionSimilarity.sameText; // it_is expectation[2].metadata.matchLevel = SuggestionSimilarity.none; // is @@ -355,7 +355,7 @@ describe('processSimilarity', () => { const distribution = [...Object.values(testSet)]; - const expectation: IntermediateCompositedPrediction[] = [...Object.values(testSet)]; + const expectation: CompositedIntermediatePrediction[] = [...Object.values(testSet)]; expectation.forEach((entry) => entry.metadata.matchLevel = SuggestionSimilarity.none); processSimilarity(testModelWithoutCasing, distribution, context, trueInput); From c1fbf2fdf169ea0228854d86d5e24d1ecfa74b37 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 29 Jun 2026 13:02:46 -0500 Subject: [PATCH 081/148] auto: increment master version to 19.0.249 Test-bot: skip Build-bot: skip --- HISTORY.md | 11 +++++++++++ VERSION.md | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index f9d015bc76..e4696631e8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,16 @@ # Keyman Version History +## 19.0.248 alpha 2026-06-29 + +* chore: add missing line to history (#16154) +* maint(resources): DRY out `PRInformation` interface (#16158) +* fix(developer): prevent clone of legacy keyboards with no source (#16111) +* fix(developer): consolidate user options in TypeScript code (#16134) +* fix(developer): fixup references to layer after deleting in Touch Layout Editor (#16129) +* fix(web): revert regression in setting `activeKeyboard` in `set osk` (#16156) +* test(web): add e2e tests for examples from guide (#16108) +* maint(linux): show output of API check also in log file (#16153) + ## 19.0.247 alpha 2026-06-16 * chore: update multi-labeler to 5.0.0 (#16100) diff --git a/VERSION.md b/VERSION.md index a0b5f4d5f2..a202b62bed 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.248 \ No newline at end of file +19.0.249 \ No newline at end of file From 23764b7e561d48f0e857ab0b9199d2ceacaade87 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 30 Jun 2026 11:49:54 +0200 Subject: [PATCH 082/148] chore(android): allow to build FV app in docker container Building the FirstVoices app is triggered in the top-level `build.sh` if the two environment variables are set. Previously we didn't pass these variables to Docker, so the FV app was never built even when the env variables were set. This change passes the two variables to the container, thus allowing to build the FW app in the docker container. Build-bot: skip Test-bot: skip --- resources/docker-images/run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/docker-images/run.sh b/resources/docker-images/run.sh index 534e84eebf..a76d2bcb0b 100755 --- a/resources/docker-images/run.sh +++ b/resources/docker-images/run.sh @@ -44,6 +44,9 @@ else fi run_android() { + if [[ -n ${RELEASE_OEM:-} ]] && [[ -n ${RELEASE_OEM_FIRSTVOICES:-} ]]; then + DOCKER_RUN_ARGS+=(-e "RELEASE_OEM=${RELEASE_OEM:-}" -e "RELEASE_OEM_FIRSTVOICES=${RELEASE_OEM_FIRSTVOICES:-}") + fi docker_wrapper run "${DOCKER_RUN_ARGS[@]}" -i --rm -v "${KEYMAN_ROOT}":/home/build/build \ -v "${KEYMAN_ROOT}/core/build/docker-core/${build_dir}":/home/build/build/core/build \ "${registry_slash}keymanapp/keyman-android-ci:${image_version}" \ From c606733a5688f35899e3a0c8a009d7308b7e0402 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 30 Jun 2026 11:31:51 +0200 Subject: [PATCH 083/148] fix(android): add missing dependency for FirstVoices and Samples Fixes: FV-ANDROID-20E Build-bot: release:android --- android/Samples/KMSample1/app/build.gradle | 1 + android/Samples/KMSample2/app/build.gradle | 1 + oem/firstvoices/android/app/build.gradle | 1 + 3 files changed, 3 insertions(+) diff --git a/android/Samples/KMSample1/app/build.gradle b/android/Samples/KMSample1/app/build.gradle index ae4ff55d7e..395d6a69e5 100644 --- a/android/Samples/KMSample1/app/build.gradle +++ b/android/Samples/KMSample1/app/build.gradle @@ -50,4 +50,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Samples/KMSample2/app/build.gradle b/android/Samples/KMSample2/app/build.gradle index a148a7ab19..40e30c0025 100644 --- a/android/Samples/KMSample2/app/build.gradle +++ b/android/Samples/KMSample2/app/build.gradle @@ -49,4 +49,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/oem/firstvoices/android/app/build.gradle b/oem/firstvoices/android/app/build.gradle index 9f42f94085..aa5e65941d 100644 --- a/oem/firstvoices/android/app/build.gradle +++ b/oem/firstvoices/android/app/build.gradle @@ -137,6 +137,7 @@ dependencies { implementation files('libs/keyman-engine.aar') implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } apply plugin: 'com.android.application' From b490d20098cd0a2ed98f54df2dbbba0f106409b4 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 30 Jun 2026 12:56:50 +0200 Subject: [PATCH 084/148] fix(android): add missing dependency for KeyboardHarness app --- android/Tests/KeyboardHarness/app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/android/Tests/KeyboardHarness/app/build.gradle b/android/Tests/KeyboardHarness/app/build.gradle index 25d5c8250a..d9502cfa64 100644 --- a/android/Tests/KeyboardHarness/app/build.gradle +++ b/android/Tests/KeyboardHarness/app/build.gradle @@ -60,4 +60,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } From fd6e2bfc6f34d8aea67231577e13f3c20a273d14 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 30 Jun 2026 14:42:21 +0200 Subject: [PATCH 085/148] chore(developer): remove outdated comment --- .../http/Keyman.Developer.System.HttpServer.AppSource.pas | 4 ---- 1 file changed, 4 deletions(-) diff --git a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas index 5c125aa4cd..37c4b483b4 100644 --- a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas +++ b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas @@ -76,10 +76,6 @@ var begin T := FSources.LockList; try - // Note: Unlike regular functions, Assert has short-circuit evaluation - // intrinsics on the first param which makes it safe to dereference T[0] in - // the second parameter. - // There is a race where RegisterSource is called on the server side // where a request is started in the form but the server does not respond // before the form is destroyed: From 809a3cad4a42df5324d002af1c8362cbd2f2bc00 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 30 Jun 2026 15:12:59 +0200 Subject: [PATCH 086/148] chore(android): address review comments Also add logging if unit test fails --- .../main/java/com/keyman/engine/util/KMLog.java | 14 +++++++++++--- .../java/com/keyman/engine/util/FileUtilsTest.java | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java index e6eb05a96e..633f5c853b 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/KMLog.java @@ -117,8 +117,8 @@ public final class KMLog { // Sentry does limit the size of messages... so let's just // keep 10 entries and call it a day. - int limit = Math.min(rawTrace.length, 10 + skipCount); if(rawTrace.length > skipCount) { + int limit = Math.min(rawTrace.length, 10 + skipCount); String[] trace = new String[limit - skipCount]; for (int i = skipCount; i < limit; i++) { trace[i-skipCount] = rawTrace[i].toString(); @@ -190,7 +190,7 @@ public final class KMLog { String errorMsg = ""; try { if (msg != null && !msg.isEmpty()) { - errorMsg = msg + "\n" + e.toString(); + errorMsg = msg + "\n" + e; } else if (e != null) { errorMsg = e.toString(); } @@ -206,6 +206,8 @@ public final class KMLog { } } + Log.e(tag, errorMsg); + if (!canLogToSentry()) { return; } @@ -215,12 +217,18 @@ public final class KMLog { tagDebugInfo(); try { if(obj != null && objName != null) { - Sentry.setTag(objName, obj.toString()); + Sentry.setExtra(objName, obj.toString()); } } catch(Exception innerE) { Sentry.captureException(innerE); } Sentry.captureException(e); + + if(obj != null && objName != null) { + // And remove the exception-specific tagged data, lest it also be + // tracked on subsequent errors not associated with the current call. + Sentry.removeExtra(objName); + } }); } } diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/util/FileUtilsTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/util/FileUtilsTest.java index 5118159c9b..9541a59afe 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/util/FileUtilsTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/util/FileUtilsTest.java @@ -23,7 +23,7 @@ public class FileUtilsTest { List logs = ShadowLog.getLogs(); // The logs contain type 4, but we only care about type 6 for connection messages - Assert.assertEquals(2, logs.size()); + Assert.assertEquals("Size did not match 2: " + logs.toString(), 2, logs.size()); Assert.assertEquals("Connection", logs.get(0).tag); Assert.assertEquals("Initialization failed:\njava.net.MalformedURLException: no protocol: invalidURL", logs.get(0).msg); From 4fbfcb99402a51437b71cef8c88255506bf07b95 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 30 Jun 2026 10:40:00 -0500 Subject: [PATCH 087/148] change(web): clarify merge, split edit token-mapping types Build-bot: skip build:web Test-bot: skip --- .../src/main/correction/context-token.ts | 4 +- .../main/correction/context-tokenization.ts | 51 +++++++++++++------ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts index 942773fa57..4d4247a086 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts @@ -10,7 +10,7 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { SearchQuotientNode, PathInputProperties } from "./search-quotient-node.js"; -import { TokenSplitMap } from "./context-tokenization.js"; +import { TokenSplitMapping } from "./context-tokenization.js"; import { LegacyQuotientSpur } from "./legacy-quotient-spur.js"; import { LegacyQuotientRoot } from "./legacy-quotient-root.js"; import { generateSubsetId } from './tokenization-subsets.js'; @@ -199,7 +199,7 @@ export class ContextToken { * @param lexicalModel * @returns */ - split(split: TokenSplitMap): ContextToken[] { + split(split: TokenSplitMapping): ContextToken[] { // Split from tail to head - leave as much 'head' intact as possible at each // step, rather than needing to reconstruct the tail multiple times. const splitSpecs = split.matches.slice(); diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts index dcd7b0a295..6933e3c836 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts @@ -31,7 +31,7 @@ const MIN_CHARS_TO_RECONSIDER_FOR_TOKENIZATION = 8; * This type is used to indicate properties of tokens affected by merges and * splits during a context transition. */ -interface EditTokenMap { +interface EditTokenMappingHalf { /** * The index of the affected token. */ @@ -42,10 +42,31 @@ interface EditTokenMap { text: string }; +/** + * This type is used to indicate properties of tokens affected by splits during + * a context transition. + */ +interface SplitTokenMappingHalf extends EditTokenMappingHalf { + /** + * The index of the affected token. + */ + index: number, + /** + * The token's most likely represented text. + */ + text: string, + + /** + * The codepoint index within the original token at which the split-off token + * begins. + */ + textOffset: number +}; + /** * This type represents mappings for tokens affected by merge edit operations. */ -interface TokenMergeMap { +interface TokenMergeMapping { /** * Entries here represent source-context tokens that are combined as part of * the merge edit operation. @@ -53,7 +74,7 @@ interface TokenMergeMap { * Entries will appear in the same ordering as the codepoints they represent * appear in the underlying context. */ - inputs: EditTokenMap[], + inputs: EditTokenMappingHalf[], /** * This entry represents the post-transition context token produced from the @@ -62,14 +83,14 @@ interface TokenMergeMap { * Note that it is possible for extra codepoints to exist here that were not * represented in the original source-context tokens. */ - match: EditTokenMap + match: EditTokenMappingHalf }; /** * This type represents mappings for tokens affected by split edit operations. */ -export interface TokenSplitMap { +export interface TokenSplitMapping { /** * This entry represents the source-context token that was split as part of * the split edit operation. @@ -77,7 +98,7 @@ export interface TokenSplitMap { * Note that it is possible for codepoints to exist here but not be * represented in the post-transition context tokens resulting from the split. */ - input: EditTokenMap, + input: EditTokenMappingHalf, /** * Entries here represent post-transition tokens that represent pieces of the @@ -86,7 +107,7 @@ export interface TokenSplitMap { * Entries will appear in the same ordering as the codepoints they represent * appear in the underlying context. */ - matches: (EditTokenMap & { textOffset: number })[] + matches: SplitTokenMappingHalf[] }; /** @@ -99,11 +120,11 @@ export interface TransitionEdgeAlignment { /** * Denotes any token merge edits needed after applying the Transform. */ - merges: TokenMergeMap[]; + merges: TokenMergeMapping[]; /** * Denotes any token split edits needed after applying the Transform. */ - splits: TokenSplitMap[]; + splits: TokenSplitMapping[]; /** * Denotes any further token edits needed that cannot be attributed to * 'merge's, 'split's, or edits from the input `Transform`. @@ -1036,13 +1057,13 @@ export function analyzePathMergesAndSplits(priorTokenization: string[], resultTo * generality, if two separate groups of tokens are merged, two groups will be * defined - one for each token resulting from a merge. */ - merges: TokenMergeMap[], + merges: TokenMergeMapping[], /** * Indicates groupings of directly related splits. Without loss of * generality, if two separate tokens are split, two groups will be defined - * one for each source token split. */ - splits: TokenSplitMap[] + splits: TokenSplitMapping[] } { // We've found the root token to which changes may apply. // We've found the last post-application token to which transform changes contributed. @@ -1080,8 +1101,8 @@ export function analyzePathMergesAndSplits(priorTokenization: string[], resultTo const mappedPath: EditTuple[] = []; let mergeOffset = 0; let splitOffset = 0; - const merges: TokenMergeMap[] = []; - const splits: TokenSplitMap[] = []; + const merges: TokenMergeMapping[] = []; + const splits: TokenSplitMapping[] = []; while(queueIndex < editPath.length) { const edit = editPath[queueIndex]; const { input, match } = edit; @@ -1091,7 +1112,7 @@ export function analyzePathMergesAndSplits(priorTokenization: string[], resultTo let matchOffset: number = 0; if(op == 'merge') { const mergeTarget = resultTokenization[match]; - const merge: TokenMergeMap = { + const merge: TokenMergeMapping = { match: { index: match, text: mergeTarget @@ -1122,7 +1143,7 @@ export function analyzePathMergesAndSplits(priorTokenization: string[], resultTo merges.push(merge); } else if(op == 'split') { const splitTarget = preTokenization[input]; - const split: TokenSplitMap = { + const split: TokenSplitMapping = { input: { index: input, text: splitTarget From d549508f5bb36dcb7d151f2f72abc5ffd3276e6e Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 30 Jun 2026 13:02:05 -0500 Subject: [PATCH 088/148] auto: increment master version to 19.0.250 Test-bot: skip Build-bot: skip --- HISTORY.md | 7 +++++++ VERSION.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index e4696631e8..1de8faa65c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,12 @@ # Keyman Version History +## 19.0.249 alpha 2026-06-30 + +* fix(windows): add update property to remote check (#16126) +* chore(android): allow to build FV app in docker container (#16163) +* fix(developer): warn only on race when destroying TAppSourceHttpResponder (#16140) +* fix(developer): map shift key nextlayer property when importing OSK (#16110) + ## 19.0.248 alpha 2026-06-29 * chore: add missing line to history (#16154) diff --git a/VERSION.md b/VERSION.md index a202b62bed..0115601331 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.249 \ No newline at end of file +19.0.250 \ No newline at end of file From 0fc869faa4f2dff6f8dcdb47813724424a6a56ce Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 1 Jul 2026 07:38:57 +0200 Subject: [PATCH 089/148] fix(web): define IME interfaces in `KeyboardInterfaceBase` Ensure that the IME interface methods are all defined for both browser and webview modes. Deprecate 'Element' methods and replace with 'TextStore' to clarify return type. Add deprecation flags where needed and update documentation accordingly. Fixes: #16144 --- .../compatibility/FocusLastActiveElement.md | 26 ------ .../compatibility/GetLastActiveElement.md | 26 ------ .../reference/compatibility/HideHelp.md | 25 ------ .../reference/compatibility/ShowPinnedHelp.md | 26 ------ .../engine/reference/compatibility/index.md | 33 ------- web/docs/engine/reference/index.md | 3 - .../interface/FocusLastActiveElement.md | 38 ++++++++ .../interface/GetLastActiveElement.md | 39 +++++++++ .../engine/reference/interface/HideHelp.md | 30 +++++++ .../{compatibility => interface}/ShowHelp.md | 13 ++- .../reference/interface/ShowPinnedHelp.md | 31 +++++++ .../interface/focusLastActiveTextStore.md | 24 +++++ .../interface/getLastActiveTextStore.md | 26 ++++++ web/docs/engine/reference/interface/index.md | 28 ++++++ web/src/app/browser/src/keyboardInterface.ts | 24 ++--- web/src/app/browser/src/keymanEngine.ts | 2 +- .../engine/src/main/keyboardInterfaceBase.ts | 87 +++++++++++++++++++ 17 files changed, 325 insertions(+), 156 deletions(-) delete mode 100644 web/docs/engine/reference/compatibility/FocusLastActiveElement.md delete mode 100644 web/docs/engine/reference/compatibility/GetLastActiveElement.md delete mode 100644 web/docs/engine/reference/compatibility/HideHelp.md delete mode 100644 web/docs/engine/reference/compatibility/ShowPinnedHelp.md delete mode 100644 web/docs/engine/reference/compatibility/index.md create mode 100644 web/docs/engine/reference/interface/FocusLastActiveElement.md create mode 100644 web/docs/engine/reference/interface/GetLastActiveElement.md create mode 100644 web/docs/engine/reference/interface/HideHelp.md rename web/docs/engine/reference/{compatibility => interface}/ShowHelp.md (65%) create mode 100644 web/docs/engine/reference/interface/ShowPinnedHelp.md create mode 100644 web/docs/engine/reference/interface/focusLastActiveTextStore.md create mode 100644 web/docs/engine/reference/interface/getLastActiveTextStore.md diff --git a/web/docs/engine/reference/compatibility/FocusLastActiveElement.md b/web/docs/engine/reference/compatibility/FocusLastActiveElement.md deleted file mode 100644 index 49cb6dfed4..0000000000 --- a/web/docs/engine/reference/compatibility/FocusLastActiveElement.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: FocusLastActiveElement ---- - -## Summary - -Restore the focus to the element active before input was moved to -KeymanWeb. - -## Syntax - -``` -keyman.FocusLastActiveElement() -``` - -### Parameters - -None. - -### Return Value - -`undefined` - -### Replaced By - -[`keyman.focusLastActiveElement()`](../core/focusLastActiveElement) diff --git a/web/docs/engine/reference/compatibility/GetLastActiveElement.md b/web/docs/engine/reference/compatibility/GetLastActiveElement.md deleted file mode 100644 index 32ca21c103..0000000000 --- a/web/docs/engine/reference/compatibility/GetLastActiveElement.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: GetLastActiveElement ---- - -## Summary - -Returns the last active target element before the current KMW operation. - -## Syntax - -``` -keyman.GetLastActiveElement(); -``` - -### Parameters - -None. - -### Return Value - -`Element` -: The requested element. - -### Replaced By - -[`keyman.getLastActiveElement()`](../core/getLastActiveElement) diff --git a/web/docs/engine/reference/compatibility/HideHelp.md b/web/docs/engine/reference/compatibility/HideHelp.md deleted file mode 100644 index 4c916c02a0..0000000000 --- a/web/docs/engine/reference/compatibility/HideHelp.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: HideHelp ---- - -## Summary - -Hides the OSK (visual help). - -## Syntax - -``` -keyman.HideHelp(); -``` - -### Parameters - -None. - -### Return Value - -`undefined` - -### Replaced By - -[`keyman.osk.hide()`](../osk/hide) diff --git a/web/docs/engine/reference/compatibility/ShowPinnedHelp.md b/web/docs/engine/reference/compatibility/ShowPinnedHelp.md deleted file mode 100644 index 43b3e40946..0000000000 --- a/web/docs/engine/reference/compatibility/ShowPinnedHelp.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: ShowPinnedHelp ---- - -## Summary - -Shows the on-screen keyboard and locks it at the resulting location. - -## Syntax - -``` -keyman.ShowPinnedHelp(x, y); -``` - -### Parameters - -None. - -### Return Value - -`undefined` - -### Replaced by - -[`keyman.osk.setRect()`](../osk/setRect), -[`keyman.osk.show()`](../osk/show) diff --git a/web/docs/engine/reference/compatibility/index.md b/web/docs/engine/reference/compatibility/index.md deleted file mode 100644 index 98f4e37813..0000000000 --- a/web/docs/engine/reference/compatibility/index.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Compatibility Functions ---- - -The following *KeymanWeb* core functions -have been retained for compatibility with existing custom keyboards, but -should not be used in any new keyboards or user interfaces. Equivalent -new function calls are indicated. - -[`GetLastActiveElement` Function](GetLastActiveElement) -: [`keyman.getLastActiveElement()`](../core/getLastActiveElement) - - - -[`FocusLastActiveElement` Function](FocusLastActiveElement) -: [`keyman.focusLastActiveElement()`](../core/focusLastActiveElement) - - - -[`HideHelp` Function](HideHelp) -: [`keyman.osk.hide()`](../osk/hide) - - - -[`ShowHelp` Function](ShowHelp) -: [`keyman.osk.setPos()`](../osk/setPos), - [`keyman.osk.show()`](../osk/show) - - - -[`ShowPinnedHelp` Function](ShowPinnedHelp) -: [`keyman.osk.setRect()`](../osk/setRect), - [`keyman.osk.show()`](../osk/show) diff --git a/web/docs/engine/reference/index.md b/web/docs/engine/reference/index.md index f6e5a3a8de..1002a802e6 100644 --- a/web/docs/engine/reference/index.md +++ b/web/docs/engine/reference/index.md @@ -17,9 +17,6 @@ title: KeymanWeb Reference [KeymanWeb Output Functions](interface) : KeymanWeb exposes its keyboard interface object through `window.keyman.interface`, providing a number of functions for low-level processing of input, context and output. -[KeymanWeb Compatibility Functions](compatibility) -: The following KeymanWeb core functions have been retained for compatibility with existing custom keyboards, but should not be used in any new keyboards or user interfaces. Equivalent new function calls are indicated. - [Desktop User Interfaces](ui) : Four different KeymanWeb user interfaces for desktop browsers are included, allowing users to select and enable keyboard mapping from a list of installed keyboards, and to control the visibility of the On-Screen Keyboard. diff --git a/web/docs/engine/reference/interface/FocusLastActiveElement.md b/web/docs/engine/reference/interface/FocusLastActiveElement.md new file mode 100644 index 0000000000..a06e886781 --- /dev/null +++ b/web/docs/engine/reference/interface/FocusLastActiveElement.md @@ -0,0 +1,38 @@ +--- +title: FocusLastActiveElement (deprecated) +--- + +## Summary + +Restore the focus to the text store active before input was moved to KeymanWeb. + +Note that the name is misleading: this function move focus to the last active +`TextStore`, not the last active `HTMLElement` (in practice, this is equivalent +in a web browser context). + +## Syntax + +```js +keyman.interface.FocusLastActiveElement(); +keyman.interface.focusLastActiveElement(); +KeymanWeb.FocusLastActiveElement(); +KeymanWeb.focusLastActiveElement(); +``` + +### Parameters + +None. + +### Return Value + +`undefined` + +### Replaced By + +This function is deprecated; see instead: + +* [`keyman.interface.focusLastActiveTextStore()`](focusLastActiveTextStore) + +### See also + +* [`keyman.focusLastActiveElement()`](../core/focusLastActiveElement) diff --git a/web/docs/engine/reference/interface/GetLastActiveElement.md b/web/docs/engine/reference/interface/GetLastActiveElement.md new file mode 100644 index 0000000000..ee6eaf0e8d --- /dev/null +++ b/web/docs/engine/reference/interface/GetLastActiveElement.md @@ -0,0 +1,39 @@ +--- +title: GetLastActiveElement (deprecated) +--- + +## Summary + +Returns the last active target text store before the current KMW operation. + +Note that the name is misleading: this function returns the last active +`TextStore`, not the last active `HTMLElement`. Use instead +[`keyman.interface.getLastActiveTextStore()`](getLastActiveTextStore). + +## Syntax + +```js +keyman.interface.GetLastActiveElement(); +keyman.interface.getLastActiveElement(); +KeymanWeb.GetLastActiveElement(); +KeymanWeb.getLastActiveElement(); +``` + +### Parameters + +None. + +### Return Value + +`TextStore` +: The requested text store. + +### Replaced By + +This function is deprecated; see instead: + +* [`keyman.interface.getLastActiveTextStore()`](getLastActiveTextStore) + +### See also + +* [`keyman.getLastActiveElement()`](../core/getLastActiveElement) diff --git a/web/docs/engine/reference/interface/HideHelp.md b/web/docs/engine/reference/interface/HideHelp.md new file mode 100644 index 0000000000..f5f7a6c0a1 --- /dev/null +++ b/web/docs/engine/reference/interface/HideHelp.md @@ -0,0 +1,30 @@ +--- +title: HideHelp (deprecated) +--- + +## Summary + +Hides the OSK (visual help). + +## Syntax + +```js +keyman.interface.HideHelp(); +keyman.interface.hideHelp(); +KeymanWeb.HideHelp(); +KeymanWeb.hideHelp(); +``` + +### Parameters + +None. + +### Return Value + +`undefined` + +### Replaced By + +This function is deprecated; see instead: + +[`keyman.osk.hide()`](../osk/hide) diff --git a/web/docs/engine/reference/compatibility/ShowHelp.md b/web/docs/engine/reference/interface/ShowHelp.md similarity index 65% rename from web/docs/engine/reference/compatibility/ShowHelp.md rename to web/docs/engine/reference/interface/ShowHelp.md index 9647ec6b16..3ded765ff2 100644 --- a/web/docs/engine/reference/compatibility/ShowHelp.md +++ b/web/docs/engine/reference/interface/ShowHelp.md @@ -1,15 +1,18 @@ --- -title: ShowHelp +title: ShowHelp (deprecated) --- - + ## Summary Shows the on-screen keyboard at the requested (x, y) coordinates. ## Syntax -``` -keyman.ShowHelp(x, y); +```js +keyman.interface.ShowHelp(x, y); +keyman.interface.showHelp(x, y); +KeymanWeb.ShowHelp(x, y); +KeymanWeb.showHelp(x, y); ``` ### Parameters @@ -28,5 +31,7 @@ keyman.ShowHelp(x, y); ### Replaced by +This function is deprecated; see instead: + [`keyman.osk.setPos()`](../osk/setPos), [`keyman.osk.show()`](../osk/show) diff --git a/web/docs/engine/reference/interface/ShowPinnedHelp.md b/web/docs/engine/reference/interface/ShowPinnedHelp.md new file mode 100644 index 0000000000..7a03d2dc91 --- /dev/null +++ b/web/docs/engine/reference/interface/ShowPinnedHelp.md @@ -0,0 +1,31 @@ +--- +title: ShowPinnedHelp (deprecated) +--- + +## Summary + +Shows the on-screen keyboard and locks it at the resulting location. + +## Syntax + +```js +keyman.interface.ShowPinnedHelp(x, y); +keyman.interface.showPinnedHelp(x, y); +KeymanWeb.ShowPinnedHelp(x, y); +KeymanWeb.showPinnedHelp(x, y); +``` + +### Parameters + +None. + +### Return Value + +`undefined` + +### Replaced by + +This function is deprecated; see instead: + +* [`keyman.osk.setRect()`](../osk/setRect), +* [`keyman.osk.show()`](../osk/show) diff --git a/web/docs/engine/reference/interface/focusLastActiveTextStore.md b/web/docs/engine/reference/interface/focusLastActiveTextStore.md new file mode 100644 index 0000000000..d0ab7127db --- /dev/null +++ b/web/docs/engine/reference/interface/focusLastActiveTextStore.md @@ -0,0 +1,24 @@ +--- +title: focusLastActiveTextStore +--- + +## Summary + +Set focus to the currently-focused or most recently focused text store. This is +for use, for example, by IME keyboards to return the focus to the text store +after the user clicks on an IME element, without resetting context. + +## Syntax + +```js +keyman.interface.focusLastActiveTextStore(); +KeymanWeb.focusLastActiveTextStore(); +``` + +### Parameters + +None. + +### Return Value + +`undefined` diff --git a/web/docs/engine/reference/interface/getLastActiveTextStore.md b/web/docs/engine/reference/interface/getLastActiveTextStore.md new file mode 100644 index 0000000000..7a0ab7a9b3 --- /dev/null +++ b/web/docs/engine/reference/interface/getLastActiveTextStore.md @@ -0,0 +1,26 @@ +--- +title: getLastActiveTextStore +--- + +## Summary + +Get the currently or most recently focused text store. This is for use by IME +keyboards, to allow for more complex focus interactions when clicking IME +elements. + +## Syntax + +```js +keyman.interface.getLastActiveTextStore(); +KeymanWeb.getLastActiveTextStore(); +``` + +### Parameters + +None. + +### Return Value + +`TextStore` +: The last active text store. + diff --git a/web/docs/engine/reference/interface/index.md b/web/docs/engine/reference/interface/index.md index d257b7e3e3..10e412d345 100644 --- a/web/docs/engine/reference/interface/index.md +++ b/web/docs/engine/reference/interface/index.md @@ -42,10 +42,16 @@ Custom user interfaces would not normally use these functions, but they are desc : Context deletion - removes the specified number of deadkeys and characters from the left of the caret. : Shorthand name: `KeymanWeb.KDC` +[`focusLastActiveTextStore` Function](focusLastActiveTextStore) +: Set focus to the currently-focused or most recently focused text store. + [`fullContextMatch` Function](fullContextMatch) : Context matching: Returns `true` if the current context matches the specified rule context specification. : Shorthand name: `KeymanWeb.KFCM` +[`getLastActiveTextStore` Function](getLastActiveTextStore) +: Get the currently or most recently focused text store. This is for use by IME keyboards. + [`ifStore` Function](ifStore) : `ifStore` compares the content of a [system `store`](/developer/language/guide/stores#toc-system-stores) with a string value. : Shorthand name: `KeymanWeb.KIFS` @@ -105,3 +111,25 @@ Custom user interfaces would not normally use these functions, but they are desc [`stateMatch` Function](stateMatch) : State-key matching: Returns `true` if the event matches the rule's state-key requirements. : Shorthand name: `KeymanWeb.KSM` + +## Deprecated functions for IMEs + +The following functions have been retained for compatibility with existing IME +keyboards, but should not be used in any new keyboards or user interfaces. + +[`GetLastActiveElement` or `getLastActiveElement` Function (Deprecated)](GetLastActiveElement) +: Use [`keyman.interface.getLastActiveTextStore()`](getLastActiveTextStore) + +[`FocusLastActiveElement` or `focusLastActiveElement` Function (Deprecated)](FocusLastActiveElement) +: Use [`keyman.interface.focusLastActiveTextStore()`](focusLastActiveTextStore) + +[`HideHelp` or `hideHelp` Function (Deprecated)](HideHelp) +: Use [`keyman.osk.hide()`](../osk/hide) + +[`ShowHelp` or `showHelp` Function (Deprecated)](ShowHelp) +: Use [`keyman.osk.setPos()`](../osk/setPos) + Use [`keyman.osk.show()`](../osk/show) + +[`ShowPinnedHelp` or `showPinnedHelp` Function (Deprecated)](ShowPinnedHelp) +: Use [`keyman.osk.setRect()`](../osk/setRect) +: Use [`keyman.osk.show()`](../osk/show) diff --git a/web/src/app/browser/src/keyboardInterface.ts b/web/src/app/browser/src/keyboardInterface.ts index 4da0579a00..e66fc70565 100644 --- a/web/src/app/browser/src/keyboardInterface.ts +++ b/web/src/app/browser/src/keyboardInterface.ts @@ -26,23 +26,27 @@ export class KeyboardInterface extends KeyboardInterfaceBase { this.engine.contextManager.focusAssistant._IgnoreNextSelChange = 1; } - /** - * Legacy entry points (non-standard names)- included only to allow existing IME keyboards to continue to be used - */ - getLastActiveElement(): AbstractElementTextStore { + getLastActiveTextStore(): AbstractElementTextStore { return this.engine.contextManager.lastActiveTextStore; } - focusLastActiveElement(): void { + focusLastActiveTextStore(): void { this.engine.contextManager.restoreLastActiveTextStore(); } //The following entry points are defined but should not normally be used in a keyboard, as OSK display is no longer determined by the keyboard + + /** + * @deprecated + */ hideHelp(): void { const osk = this.engine.osk; osk.startHide(true); } + /** + * @deprecated + */ showHelp(Px: number, Py: number): void { const osk = this.engine.osk; @@ -53,6 +57,9 @@ export class KeyboardInterface extends KeyboardInterfaceBase { } } + /** + * @deprecated + */ showPinnedHelp(): void { const osk = this.engine.osk; @@ -74,13 +81,6 @@ export class KeyboardInterface extends KeyboardInterfaceBase { // pinned position. osk.present(); } - - // Also needed for some legacy CJK keyboards. - readonly GetLastActiveElement = this.getLastActiveElement; - readonly FocusLastActiveElement = this.focusLastActiveElement; - readonly HideHelp = this.hideHelp; - readonly ShowHelp = this.showHelp; - readonly ShowPinnedHelp = this.showPinnedHelp; } (function() { diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index af3bae2d20..0b3d54aebb 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -543,7 +543,7 @@ export class KeymanEngine extends KeymanEngineBase> extends JSKeyboardInterface { protected readonly engine: KeymanEngineBase; @@ -118,6 +119,92 @@ export class KeyboardInterfaceBase Date: Wed, 1 Jul 2026 08:06:26 +0200 Subject: [PATCH 090/148] docs(web): cleanup of index pages and page titles Fixes: #16169 Test-bot: skip --- web/docs/engine/reference/core/index.md | 2 +- web/docs/engine/reference/events/index.md | 4 +- web/docs/engine/reference/index.md | 41 ++++++------ web/docs/engine/reference/interface/index.md | 68 ++++++++++++-------- web/docs/engine/reference/layouts.md | 7 -- web/docs/engine/reference/osk/index.md | 2 +- web/docs/engine/reference/overview.md | 12 +++- web/docs/engine/reference/ui/index.md | 4 +- web/docs/engine/reference/util/index.md | 2 +- 9 files changed, 80 insertions(+), 62 deletions(-) delete mode 100644 web/docs/engine/reference/layouts.md diff --git a/web/docs/engine/reference/core/index.md b/web/docs/engine/reference/core/index.md index 8ce45877fd..ff474d539b 100644 --- a/web/docs/engine/reference/core/index.md +++ b/web/docs/engine/reference/core/index.md @@ -1,5 +1,5 @@ --- -title: Core Module +title: `keyman` - Core Module --- The KeymanWeb core module is exposed to the developer as `window.keyman`. diff --git a/web/docs/engine/reference/events/index.md b/web/docs/engine/reference/events/index.md index df7538c2c1..962c09d795 100644 --- a/web/docs/engine/reference/events/index.md +++ b/web/docs/engine/reference/events/index.md @@ -7,8 +7,8 @@ interface to control the appearance and behavior of user interface elements. Standard event-processing requires all arguments to be passed as an array (object) with named member variables. -Two components of Keyman Engine for Web specify events: -* `keyman` object -- the main component +Two components of Keyman Engine for Web specify events: +* `keyman` object -- the core component * `keyman.osk` object -- the on-screen keyboard component Object events are handled in user code by passing the handler entry to diff --git a/web/docs/engine/reference/index.md b/web/docs/engine/reference/index.md index 1002a802e6..2445ec292a 100644 --- a/web/docs/engine/reference/index.md +++ b/web/docs/engine/reference/index.md @@ -5,35 +5,38 @@ title: KeymanWeb Reference [KeymanWeb Overview](overview) : KeymanWeb is a cross-browser JavaScript input method solution. -[KeymanWeb Core Module](core) -: The KeymanWeb core module is exposed to the developer as `window.keyman`. +# API Reference -[On-Screen Keyboard Module](osk) -: The On-Screen Keyboard module is exposed to the developer as `window.keyman.osk`. +[`keyman`](core) +: Core APIs for KeymanWeb, including initialization, adding keyboards, focus and element control. -[KeymanWeb Utility Module](util) -: The KeymanWeb Utility Function module is exposed to the developer as `window.keyman.util`. +[`keyman.osk`](osk) +: On-Screen Keyboard module. -[KeymanWeb Output Functions](interface) -: KeymanWeb exposes its keyboard interface object through `window.keyman.interface`, providing a number of functions for low-level processing of input, context and output. +[`keyman.util`](util) +: Utility functions. -[Desktop User Interfaces](ui) +[`keyman.interface` (also `KeymanWeb`)](interface) +: API surface for keyboards, providing a number of functions for low-level processing of input, context and output. + +[`keyman.ui` - Desktop User Interfaces](ui) : Four different KeymanWeb user interfaces for desktop browsers are included, allowing users to select and enable keyboard mapping from a list of installed keyboards, and to control the visibility of the On-Screen Keyboard. -[KeymanWeb Initialization Options](core/init#init_options) +# API Guides + +[Initialization Options](core/init#init_options) : The following options can be defined by the site designer in the initialization call to KeymanWeb. -[KeymanWeb Events](events) -: A number of KeymanWeb events are exposed to allow the designer of a user interface to control the appearance and behavior of user interface elements. Standard event-processing requires all arguments to be passed as an array (object) with named member variables. +[Events](events) +: A number of KeymanWeb events are exposed to allow the designer of a user interface to control the appearance and behavior of user interface elements. -[KeymanWeb Keyboard Properties](keyboard_properties) -: Most keyboards are generated automatically from the Keyman keyboard source by Keyman Developer and contain properties used by KeymanWeb during keyboard mapping. +[Keyboard Properties](keyboard_properties) +: Standard properties for a Keyboard object returned from a keyboard .js file. -[KeymanWeb Layout Designer](layouts) -: One of the main features of KeymanWeb is its ability to support distinct, user-customizable layouts for touch-screen keyboards on phones and tablets. - -[KeymanWeb Layout Specifications](layoutspec) +[Touch Layout Specifications](layoutspec) : Touch-screen layouts for KeymanWeb are specified as JSON objects containing a member object for each specified device type. -[KeymanWeb Keyboard Development Reference](/developer/language/guide/multi-platform) +# See Also + +[Keyboard Development Reference](/developer/language/guide/multi-platform) : Information on how to explicitly support KeymanWeb in custom keyboards. diff --git a/web/docs/engine/reference/interface/index.md b/web/docs/engine/reference/interface/index.md index 10e412d345..fc32fa9cdd 100644 --- a/web/docs/engine/reference/interface/index.md +++ b/web/docs/engine/reference/interface/index.md @@ -1,116 +1,128 @@ --- -title: Output Functions - the Keyboard API +title: `keyman.interface` - the Keyboard API --- -The KeymanWeb core object `window.keyman.interface` (with legacy-oriented, deprecated alias `KeymanWeb`) exposes a number of functions for low-level processing of input, context and output. These functions are designed for use by keyboards compiled through Keyman Developer in order to facilitate input text processing and will also work for custom-coded KeymanWeb keyboards. As such, most of these functions should only be called by keyboard code, and a good understanding of the [Keyman Keyboard Language](/developer/language) will prove extremely beneficial toward understanding the keyboard API functions enumerated in this section. +The KeymanWeb core object `window.keyman.interface` exposes a number of +functions for low-level processing of input, context and output. These functions +are designed for use by keyboards compiled through Keyman Developer in order to +facilitate input text processing and will also work for custom-coded KeymanWeb +keyboards. These functions should only be called by keyboard code, and a good +understanding of the [Keyman Keyboard Language](/developer/language) will prove +extremely beneficial toward understanding the keyboard API functions enumerated +in this section. -Custom user interfaces would not normally use these functions, but they are described here as some custom keyboards, such as IME-style keyboards, may need to interact with the user interface. +The KeymanWeb Keyboard API publishes a deprecated global `KeymanWeb` which is +the same as `keyman.interface`. Use `keyman.interface` in preference to +`KeymanWeb`. + +Custom user interfaces should not use these functions. These functions are intended +for use in a keyboard context, in particular during a keystroke event cycle. [`any` Function (Deprecated)](any) : Returns whether or not the char `ch` is found within the [`any`](/developer/language/reference/any)([`store`](/developer/language/reference/store)) string, setting an internally-tracked index for use in the `indexOutput` function. -: Shorthand name: `KeymanWeb.KA` +: Shorthand name: `keyman.interface.KA` [`beep` Function](beep) : Flash body or element as substitute for an audible feedback [`beep`](/developer/language/reference/beep). -: Shorthand name: `KeymanWeb.KB` +: Shorthand name: `keyman.interface.KB` [`beepReset` Function](beepReset) : Cancels a previous feedback [`beep`](/developer/language/reference/beep) operation on a page element. -: Shorthand name: `KeymanWeb.KBR` +: Shorthand name: `keyman.interface.KBR` [`context` Function (Deprecated)](context) : Gets [`context`](/developer/language/reference/context) for an ongoing keyboard operation relative to the caret's present position. -: Shorthand name: `KeymanWeb.KC` +: Shorthand name: `keyman.interface.KC` [`contextExOutput` Function](contextExOutput) : Emits the character or object at `contextOffset` from the current matched rule's context. -: Shorthand name: `KeymanWeb.KCXO` +: Shorthand name: `keyman.interface.KCXO` [`contextMatch` Function (Deprecated)](contextMatch) : Context matching: Returns `true` if the specified `context` call matches a provided string. -: Shorthand name: `KeymanWeb.KCM` +: Shorthand name: `keyman.interface.KCM` [`deadkeyMatch` Function (Deprecated)](deadkeyMatch) : Deadkey matching: Seeks to match the [`deadkey`](/developer/language/reference/deadkey) state `dk` at the relative caret position `n`. -: Shorthand name: `KeymanWeb.KDM` +: Shorthand name: `keyman.interface.KDM` [`deadkeyOutput` Function](deadkeyOutput) : Deadkey output: Associates the [`deadkey`](/developer/language/reference/deadkey) state `dk` with the element at the current caret position, after overwriting `nd` characters. -: Shorthand name: `KeymanWeb.KDO` +: Shorthand name: `keyman.interface.KDO` [`deleteContext` Function](deleteContext) : Context deletion - removes the specified number of deadkeys and characters from the left of the caret. -: Shorthand name: `KeymanWeb.KDC` +: Shorthand name: `keyman.interface.KDC` [`focusLastActiveTextStore` Function](focusLastActiveTextStore) : Set focus to the currently-focused or most recently focused text store. [`fullContextMatch` Function](fullContextMatch) : Context matching: Returns `true` if the current context matches the specified rule context specification. -: Shorthand name: `KeymanWeb.KFCM` +: Shorthand name: `keyman.interface.KFCM` [`getLastActiveTextStore` Function](getLastActiveTextStore) : Get the currently or most recently focused text store. This is for use by IME keyboards. [`ifStore` Function](ifStore) : `ifStore` compares the content of a [system `store`](/developer/language/guide/stores#toc-system-stores) with a string value. -: Shorthand name: `KeymanWeb.KIFS` +: Shorthand name: `keyman.interface.KIFS` [`indexOutput` Function](indexOutput) : Index-based output: Outputs a mapped character according to a previous selection from a `keyman.interface.any()` call upon a [`store`](/developer/language/reference/store) string, after deleting `nd` characters. -: Shorthand name: `KeymanWeb.KIO` +: Shorthand name: `keyman.interface.KIO` [`insertText` Function](insertText) : Inserts a text string and optional [`deadkey`](/developer/language/reference/deadkey) into the active output element. -: Shorthand name: `KeymanWeb.KT` +: Shorthand name: `keyman.interface.KT` [`isKeypress` Function](isKeypress) : Returns `true` if the input event corresponds to a keypress event resulting in character output. -: Shorthand name: `KeymanWeb.KIK` +: Shorthand name: `keyman.interface.KIK` [`keyInformation` Function](keyInformation) : Returns an object with extended information about a specified keystroke event. -: Shorthand name: `KeymanWeb.KKI` +: Shorthand name: `keyman.interface.KKI` [`keyMatch` Function](keyMatch) : Keystroke matching: Returns `true` if the event matches the rule's shift mask and key code. -: Shorthand name: `KeymanWeb.KKM` +: Shorthand name: `keyman.interface.KKM` [`loadStore` Function](loadStore) : Load an option [`store`](/developer/language/guide/stores) value from a cookie or default value if no prior stored value exists. -: Shorthand name: `KeymanWeb.KLOAD` +: Shorthand name: `keyman.interface.KLOAD` [`nul` Function](_nul) : [`nul`](/developer/language/reference/nul) context check: Returns `true` if the length of the [`context`](/developer/language/reference/context) is less than or equal to `n` characters. -: Shorthand name: `KeymanWeb.KN` +: Shorthand name: `keyman.interface.KN` [`output` Function](output) : Outputs the specified string to an element, overwriting characters before the caret if specified. -: Shorthand name: `KeymanWeb.KO` +: Shorthand name: `keyman.interface.KO` [`registerKeyboard` Function](registerKeyboard) : Register the keyboard stub and load the keyboard. -: Shorthand name: `KeymanWeb.KR` +: Shorthand name: `keyman.interface.KR` [`registerStub` Function](registerStub) : Register the keyboard stub, return true if already registered. -: Shorthand name: `KeymanWeb.KRS` +: Shorthand name: `keyman.interface.KRS` [`saveFocus` Function](saveFocus) : Save focus: Temporarily saves keyboard processing data for the currently-focused control. -: Shorthand name: `KeymanWeb.KSF` +: Shorthand name: `keyman.interface.KSF` [`saveStore` Function](saveStore) : Save an option [`store`](/developer/language/guide/stores) value to a cookie for the active keyboard. -: Shorthand name: `KeymanWeb.KSAVE` +: Shorthand name: `keyman.interface.KSAVE` [`setStore` Function](setStore) : `setStore` sets the value of a [system `store`](/developer/language/guide/stores#toc-system-stores) to a string. -: Shorthand name: `KeymanWeb.KSETS` +: Shorthand name: `keyman.interface.KSETS` [`stateMatch` Function](stateMatch) : State-key matching: Returns `true` if the event matches the rule's state-key requirements. -: Shorthand name: `KeymanWeb.KSM` +: Shorthand name: `keyman.interface.KSM` ## Deprecated functions for IMEs diff --git a/web/docs/engine/reference/layouts.md b/web/docs/engine/reference/layouts.md deleted file mode 100644 index 56d1e40d62..0000000000 --- a/web/docs/engine/reference/layouts.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Layout Designer ---- - -One of the main features of *KeymanWeb* is its ability to support distinct, user-customizable -layouts for touch-screen keyboards on phones and tablets. *Keyman Developer* includes a layout -designer to simplify the process of creating custom layouts for any keyboard. diff --git a/web/docs/engine/reference/osk/index.md b/web/docs/engine/reference/osk/index.md index 999a25c504..d95aeaa5d0 100644 --- a/web/docs/engine/reference/osk/index.md +++ b/web/docs/engine/reference/osk/index.md @@ -1,5 +1,5 @@ --- -title: On-Screen Keyboard Module +title: `keyman.osk` - On-Screen Keyboard Module --- The On-Screen Keyboard module is exposed to the developer as `window.keyman.osk`. diff --git a/web/docs/engine/reference/overview.md b/web/docs/engine/reference/overview.md index 712a20640b..4fef7b48af 100644 --- a/web/docs/engine/reference/overview.md +++ b/web/docs/engine/reference/overview.md @@ -11,7 +11,7 @@ the *On-Screen Keyboard* module, a *Utility function* library, or one of the sta Interface* modules. A *KeymanWeb* instance is automatically constructed when you include the compiled KeymanWeb -script (kmw-release.js) in your web page source. +script in your web page source. The *KeymanWeb API* comprises the following objects: @@ -26,3 +26,13 @@ The *KeymanWeb API* comprises the following objects: - *User Interface* : Exposed as [`keyman.ui`](ui). + +The *KeymanWeb Keyboard API* provides endpoints for KeymanWeb keyboards to +interact with KeymanWeb. This API should not be used by website code. + +- *Interface* +: Exposed as [`keyman.interface`](interface) + +The KeymanWeb Keyboard API publishes a deprecated global `KeymanWeb` which is +the same as `keyman.interface`. Use `keyman.interface` in preference to +`KeymanWeb`. \ No newline at end of file diff --git a/web/docs/engine/reference/ui/index.md b/web/docs/engine/reference/ui/index.md index 74788baf51..53a555996d 100644 --- a/web/docs/engine/reference/ui/index.md +++ b/web/docs/engine/reference/ui/index.md @@ -1,7 +1,7 @@ --- -title: Desktop User Interfaces +title: `keyman.ui` - Desktop User Interfaces --- - + Four different KeymanWeb user interfaces for desktop browsers are included, allowing users to select and enable keyboard mapping from a list of installed keyboards, and to control the visibility of the diff --git a/web/docs/engine/reference/util/index.md b/web/docs/engine/reference/util/index.md index f599e3d5d6..eb860adaa0 100644 --- a/web/docs/engine/reference/util/index.md +++ b/web/docs/engine/reference/util/index.md @@ -1,5 +1,5 @@ --- -title: Utility Module +title: `keyman.util` - Utility Module --- The KeymanWeb Utility Function module is exposed to the developer as `window.keyman.util`. From e6d86d7e042472f4a9cbfdb9bcbd071b991bd17c Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 1 Jul 2026 09:05:18 +0200 Subject: [PATCH 091/148] refactor(web): rename `keymanweb` to `keyman` in UI modules The variable name `keymanweb` used in the UI modules is confusing because `KeymanWeb` also exists and points to something else. Rename to `keyman` which just mirrors `window.keyman`. Also fixup type declaration for toolbar `lastDismissalCallback`, which did not match the assigned type. Fixes: #16168 Test-bot: skip --- web/src/app/ui/kmwuibutton.ts | 66 ++++++++++++++-------------- web/src/app/ui/kmwuifloat.ts | 80 +++++++++++++++++----------------- web/src/app/ui/kmwuitoggle.ts | 80 +++++++++++++++++----------------- web/src/app/ui/kmwuitoolbar.ts | 66 ++++++++++++++-------------- 4 files changed, 146 insertions(+), 146 deletions(-) diff --git a/web/src/app/ui/kmwuibutton.ts b/web/src/app/ui/kmwuibutton.ts index 16377d4f3d..d375dae5ca 100644 --- a/web/src/app/ui/kmwuibutton.ts +++ b/web/src/app/ui/kmwuibutton.ts @@ -11,14 +11,14 @@ declare global { } } -const keymanweb=window.keyman; +const keyman=window.keyman; // If a UI module has been loaded, we can rely on the publically-published 'name' property // having been set as a way to short-out a UI reload. Its parent object always exists by // this point in the build process. -if(!keymanweb) { +if(!keyman) { throw new Error("`keyman` global is missing; Keyman Engine for Web script has not been loaded"); -} else if(!keymanweb.ui?.name) { +} else if(!keyman.ui?.name) { /********************************/ /* */ @@ -37,8 +37,8 @@ if(!keymanweb) { try { // Declare KeymanWeb, OnScreen keyboard and Util objects - const util=keymanweb.util; - // var dbg=keymanweb['debug']; + const util=keyman.util; + // var dbg=keyman['debug']; // Disable UI for touch devices if(util.isTouchDevice()) { @@ -78,8 +78,8 @@ if(!keymanweb) { * Highlight the currently active keyboard in the list of keyboards **/ private _ShowSelected() { - const kbd=keymanweb.getActiveKeyboard(); - const lgc=keymanweb.getActiveLanguage(); + const kbd=keyman.getActiveKeyboard(); + const lgc=keyman.getActiveLanguage(); const kList = this._KeymanWeb_KbdList.childNodes; const _r = /^KMWSel_(.*)\$(.*)$/; @@ -135,13 +135,13 @@ if(!keymanweb) { _k.className='selected'; } this._KMWSel = _k; - await keymanweb.setActiveKeyboard(_name,_lgc); + await keyman.setActiveKeyboard(_name,_lgc); } else { _name=null; } - keymanweb.focusLastActiveElement(); - const osk = keymanweb.osk; + keyman.focusLastActiveElement(); + const osk = keyman.osk; if(osk && osk.isEnabled()) { osk.show(true); } @@ -156,17 +156,17 @@ if(!keymanweb) { * @param {Event} e event */ readonly _SelectorMouseDown = (e: MouseEvent) => { - const x=keymanweb.getLastActiveElement(); + const x=keyman.getLastActiveElement(); // Set the focus to an input field, to get correct OSK display behaviour if(!x) { this._FocusFirstInput(); } else { - keymanweb.focusLastActiveElement(); + keyman.focusLastActiveElement(); } - if(keymanweb.activatingUI) { - keymanweb.activatingUI(1); + if(keyman.activatingUI) { + keyman.activatingUI(1); } } @@ -176,13 +176,13 @@ if(!keymanweb) { * @param {Event} e event */ readonly _SelectorMouseUp = (e: MouseEvent) => { - const x=keymanweb.getLastActiveElement(); + const x=keyman.getLastActiveElement(); // Set the focus to an input field, to get correct OSK display behaviour if(!x) { this._FocusFirstInput(); } else { - keymanweb.focusLastActiveElement(); + keyman.focusLastActiveElement(); } } @@ -195,8 +195,8 @@ if(!keymanweb) { // highlight the currently active keyboard this._ShowSelected(); - if(keymanweb.activatingUI) { - keymanweb.activatingUI(1); + if(keyman.activatingUI) { + keyman.activatingUI(1); } document.getElementById("kmwico_li").className="sfhover"; @@ -253,8 +253,8 @@ if(!keymanweb) { * @param {Event} e event */ private readonly _SelectorMouseOut = (e: MouseEvent) => { - if(keymanweb.activatingUI) { - keymanweb.activatingUI(0); + if(keyman.activatingUI) { + keyman.activatingUI(0); } document.getElementById("kmwico_li").className="sfunhover"; } @@ -265,24 +265,24 @@ if(!keymanweb) { * @param {?string=} _name current keyboard name */ private _ShowKeyboardButton(_name?: string) { - let kbdName = keymanweb.getActiveKeyboard(); + let kbdName = keyman.getActiveKeyboard(); const kbdId = document.getElementById("KMW_Keyboard"); if(arguments.length > 0) { kbdName = _name; } if(kbdId) { - if((kbdName == '') || keymanweb.isCJK()) { + if((kbdName == '') || keyman.isCJK()) { kbdId.className='kmw_disabled'; } else { - const osk = keymanweb.osk; + const osk = keyman.osk; kbdId.className = osk && osk.isEnabled() ? 'kmw_show' : 'kmw_hide'; } } } registerEvents() { - const osk = keymanweb.osk; + const osk = keyman.osk; if(!osk) { return; } @@ -290,7 +290,7 @@ if(!keymanweb) { * UI Functions called by KeymanWeb or OSK */ osk.addEventListener('show', (oskPosition) => { - const t=keymanweb.getLastActiveElement(); + const t=keyman.getLastActiveElement(); if(t) { if(!oskPosition['userLocated']) { oskPosition['x'] = util.getAbsoluteX(t); @@ -321,7 +321,7 @@ if(!keymanweb) { **/ readonly _ShowKeymanWebKeyboard = () => { const kbdId=document.getElementById("KMW_Keyboard"); - const osk = keymanweb.osk; + const osk = keyman.osk; if((kbdId.className!='kmw_disabled') && osk && osk.show) { if(osk.isEnabled()) { @@ -334,7 +334,7 @@ if(!keymanweb) { window.event.returnValue=false; } - keymanweb.focusLastActiveElement(); + keyman.focusLastActiveElement(); return false; } @@ -348,7 +348,7 @@ if(!keymanweb) { } //Never initialize UI before KMW (parameters will be undefined) - if(!keymanweb.initialized) { + if(!keyman.initialized) { this.initTimer = window.setTimeout(this.initialize, 50); return; } @@ -417,7 +417,7 @@ if(!keymanweb) { // Even tag `release-web-stable-2.0` turns up results only for this specific sourcefile. // Thus, in essence: if(true) { /* ... */ } // @ts-ignore - if(!keymanweb['iOS']) { + if(!keyman['iOS']) { const _li = util.createElement('li'); const _a = util.createElement('a'); const _img = util.createElement('img'); @@ -468,7 +468,7 @@ if(!keymanweb) { util.attachDOMEvent(_sfEl,'mouseup',this._SelectorMouseUp); this.registerEvents(); - keymanweb.focusLastActiveElement(); //TODO: this needs to be extended - if no element is active, try and identify an enabled input element + keyman.focusLastActiveElement(); //TODO: this needs to be extended - if no element is active, try and identify an enabled input element } shutdown() { @@ -493,7 +493,7 @@ if(!keymanweb) { this._KeymanWeb_KbdList.removeChild(this._KeymanWeb_KbdList.childNodes[i-1]); } - const kbds=keymanweb.getKeyboards(); + const kbds=keyman.getKeyboards(); if(kbds.length > 0) { for(let i:number=0; i { - keymanweb.activatingUI(true); - const osk = keymanweb.osk; + keyman.activatingUI(true); + const osk = keyman.osk; if(osk && osk.show) { if(osk.isEnabled()) { osk.hide(); @@ -99,8 +99,8 @@ if(!keymanweb) { if(window.event) { window.event.returnValue=false; } - keymanweb.focusLastActiveElement(); - keymanweb.activatingUI(false); + keyman.focusLastActiveElement(); + keyman.activatingUI(false); return false; } @@ -116,7 +116,7 @@ if(!keymanweb) { } // Must always initialize after keymanWeb itself, otherwise options are undefined - if(!keymanweb.initialized) { + if(!keyman.initialized) { this.initTimer = window.setTimeout(this.initialize, 50); return; } @@ -239,7 +239,7 @@ if(!keymanweb) { } // Loop over installed keyboards and add to selection list - const Lkbds=keymanweb.getKeyboards(); + const Lkbds=keyman.getKeyboards(); for(let Ln=0; Ln { + keyman.addEventListener('keyboardregistered', (p) => { this.updateList = true; if(this.updateTimer) { clearTimeout(this.updateTimer); @@ -371,8 +371,8 @@ if(!keymanweb) { * Note: Cannot simply set it to the loaded keyboard, * as more than one language may be supported by that keyboard. */ - keymanweb.addEventListener('keyboardloaded', (p) => { - const sk = keymanweb.getSavedKeyboard().split(':'); + keyman.addEventListener('keyboardloaded', (p) => { + const sk = keyman.getSavedKeyboard().split(':'); if(sk.length > 1) { this.updateMenu(sk[0],sk[1]); } @@ -383,7 +383,7 @@ if(!keymanweb) { * * Update menu selection and control OSK display appropriately */ - keymanweb.addEventListener('keyboardchange', (p) => { + keyman.addEventListener('keyboardchange', (p) => { // Update the keyboard selector whenever a keyboard is loaded this.updateMenu(p.internalName, p.languageCode); @@ -391,7 +391,7 @@ if(!keymanweb) { this.addButtonOSK(); }); - const osk = keymanweb.osk; + const osk = keyman.osk; if(!osk) { return; } @@ -422,8 +422,8 @@ if(!keymanweb) { * Description Set KMW UI activation state on mouse click */ readonly _SelectorMouseDown = (e: MouseEvent) => { - if(keymanweb.activatingUI) { - keymanweb.activatingUI(1); + if(keyman.activatingUI) { + keyman.activatingUI(1); } } @@ -434,8 +434,8 @@ if(!keymanweb) { * Description Set KMW UI activation state on mouse over */ readonly _SelectorMouseOver = (e: MouseEvent) => { - if(keymanweb.activatingUI) { - keymanweb.activatingUI(1); + if(keyman.activatingUI) { + keyman.activatingUI(1); } } @@ -446,8 +446,8 @@ if(!keymanweb) { * Description Clear KMW UI activation state on mouse out */ readonly _SelectorMouseOut = (e: MouseEvent) => { - if(keymanweb.activatingUI) { - keymanweb.activatingUI(0); + if(keyman.activatingUI) { + keyman.activatingUI(0); } } @@ -458,19 +458,19 @@ if(!keymanweb) { * Description Change active keyboard in response to user selection event */ private readonly SelectKeyboardChange = async (e: Event): Promise => { - keymanweb.activatingUI(true); + keyman.activatingUI(true); if(this.KeyboardSelector.value != '-') { const i=this.KeyboardSelector.selectedIndex; const t=this.KeyboardSelector.options[i].value.split(':'); - await keymanweb.setActiveKeyboard(t[0],t[1]); + await keyman.setActiveKeyboard(t[0],t[1]); } else { - await keymanweb.setActiveKeyboard(''); + await keyman.setActiveKeyboard(''); } //if(osk['show']) osk['show'](osk['isEnabled']()); handled by keyboard change event??? - keymanweb.focusLastActiveElement(); - keymanweb.activatingUI(false); + keyman.focusLastActiveElement(); + keyman.activatingUI(false); this.selecting = true; } @@ -482,7 +482,7 @@ if(!keymanweb) { */ readonly SelectBlur = (e: Event) => { if(!this.selecting) { - keymanweb.focusLastActiveElement(); + keyman.focusLastActiveElement(); } this.selecting = false; } @@ -511,7 +511,7 @@ if(!keymanweb) { this.addButtonOSK(); // Set the language selection to the currently active keyboard, if listed - this.updateMenu(keymanweb.getActiveKeyboard(), keymanweb.getActiveLanguage()); + this.updateMenu(keyman.getActiveKeyboard(), keyman.getActiveLanguage()); } /** @@ -534,12 +534,12 @@ if(!keymanweb) { */ readonly addButtonOSK = () => { if(this.oskButton != null) { - if(keymanweb.isCJK() || (this.KeyboardSelector.selectedIndex==0)) { + if(keyman.isCJK() || (this.KeyboardSelector.selectedIndex==0)) { this.oskButton.style.display = 'none'; this.outerDiv.style.width = this.KeyboardSelector.offsetWidth+30+'px'; } else { this.oskButton.style.display = 'block'; - const osk = keymanweb.osk; + const osk = keyman.osk; if(osk) { this.oskButtonState(osk.isEnabled()); } else { @@ -559,7 +559,7 @@ if(!keymanweb) { */ readonly _Resize = (e: Event) => { if(this.outerDiv.style.display =='block') { - const elem = keymanweb.getLastActiveElement(); + const elem = keyman.getLastActiveElement(); if(this.floatRight) { // I1296 this.ShowInterface(util.getAbsoluteX(elem) + elem.offsetWidth + 1, util.getAbsoluteY(elem) + 1); } else { @@ -570,13 +570,13 @@ if(!keymanweb) { } } - const ui=keymanweb.ui = new UIFloat(); + const ui=keyman.ui = new UIFloat(); //TODO: had to expose properties of params - what does that do? (focus event doesn't normally include these properties?) - keymanweb.addEventListener('controlfocused', (params) => { + keyman.addEventListener('controlfocused', (params) => { // ... this check shouldn't need to check _kmwAttachment directly. if(params.activeControl == null || params.activeControl['_kmwAttachment']) { - /*if(keymanweb.domManager._IsEditableIframe(Ltarg)) + /*if(keyman.domManager._IsEditableIframe(Ltarg)) Ltarg = Ltarg.defaultView.frameElement;*/ if(ui.floatRight) { // I1296 ui.ShowInterface(util.getAbsoluteX(params.target) + params.target.offsetWidth + 1, util.getAbsoluteY(params.target) + 1); @@ -588,7 +588,7 @@ if(!keymanweb) { return true; }); - keymanweb.addEventListener('controlblurred', (params) => { + keyman.addEventListener('controlblurred', (params) => { if(!params.event) { return true; // I2404 - Manage IE events in IFRAMEs } @@ -608,7 +608,7 @@ if(!keymanweb) { ui.initialize(); // is/was never actually raised. Note that the `shutdown` method likely fulfills a similar role. - // keymanweb.addEventListener('unloaduserinterface', ui._UnloadUserInterface); + // keyman.addEventListener('unloaduserinterface', ui._UnloadUserInterface); } catch(err){} diff --git a/web/src/app/ui/kmwuitoggle.ts b/web/src/app/ui/kmwuitoggle.ts index 79b7829322..dc449c28e5 100644 --- a/web/src/app/ui/kmwuitoggle.ts +++ b/web/src/app/ui/kmwuitoggle.ts @@ -22,14 +22,14 @@ interface Owned { _owningObject?: T; } -const keymanweb=window.keyman; +const keyman=window.keyman; // If a UI module has been loaded, we can rely on the publically-published 'name' property // having been set as a way to short-out a UI reload. Its parent object always exists by // this point in the build process. -if(!keymanweb) { +if(!keyman) { throw new Error("`keyman` global is missing; Keyman Engine for Web script has not been loaded"); -} else if(!keymanweb.ui?.name) { +} else if(!keyman.ui?.name) { /********************************/ /* */ /* Toggle User Interface Code */ @@ -47,8 +47,8 @@ if(!keymanweb) { try { // Declare KeymanWeb, OnScreen Keyboard and Util objects - //var dbg=keymanweb['debug']; - const util = keymanweb.util; + //var dbg=keyman['debug']; + const util = keyman.util; // Disable UI for touch devices if(util.isTouchDevice()) { @@ -99,14 +99,14 @@ if(!keymanweb) { // We don't want to shift the controller to something that's not an input element, // but do want to account for window.event's data when legitimate. - if(window.event && keymanweb.isAttached(window.event.srcElement as HTMLElement)) { + if(window.event && keyman.isAttached(window.event.srcElement as HTMLElement)) { someElement=window.event.srcElement as HTMLElement; } if(focusing) { this.controller.style.display = 'block'; } else { - if(!(keymanweb.getUIState().activationPending) && !this.controllerHovered) { + if(!(keyman.getUIState().activationPending) && !this.controllerHovered) { this.controller.style.display = 'none'; } } @@ -145,7 +145,7 @@ if(!keymanweb) { } registerEvents() { - const osk = keymanweb.osk; + const osk = keyman.osk; if(!osk) { return; @@ -171,13 +171,13 @@ if(!keymanweb) { **/ readonly switchOsk = () => { // Check that user control of OSK is allowed - if((keymanweb.getActiveKeyboard() == '') || keymanweb.isCJK()) { + if((keyman.getActiveKeyboard() == '') || keyman.isCJK()) { return; } - if(keymanweb.osk) { - const newState = !keymanweb.osk.isEnabled(); - keymanweb.osk.show(newState); + if(keyman.osk) { + const newState = !keyman.osk.isEnabled(); + keyman.osk.show(newState); // Also, indicate that the OSK is intentionally hidden. this.oskButton._setSelected(newState); @@ -188,7 +188,7 @@ if(!keymanweb) { * Toggle a single keyboard on or off - KMW button control event **/ readonly switchSingleKbd = async () => { - const _v = keymanweb.getActiveKeyboard() == ''; + const _v = keyman.getActiveKeyboard() == ''; let nLastKbd=0, kbdName='', lgCode=''; if(_v) { @@ -202,10 +202,10 @@ if(!keymanweb) { kbdName = this.keyboards[nLastKbd]._InternalName; lgCode = this.keyboards[nLastKbd]._LanguageCode; - await keymanweb.setActiveKeyboard(kbdName,lgCode); + await keyman.setActiveKeyboard(kbdName,lgCode); this.lastActiveKeyboard = nLastKbd; } else { - await keymanweb.setActiveKeyboard(''); + await keyman.setActiveKeyboard(''); } if(this.kbdButton) { @@ -217,7 +217,7 @@ if(!keymanweb) { * Switch to the next keyboard in the list - KMW button control event **/ readonly switchNextKbd = async () => { - let _v = (keymanweb.getActiveKeyboard() == ''); + let _v = (keyman.getActiveKeyboard() == ''); let kbdName='', lgCode=''; if(_v) { @@ -227,16 +227,16 @@ if(!keymanweb) { kbdName = this.keyboards[0]._InternalName; lgCode = this.keyboards[0]._LanguageCode; - await keymanweb.setActiveKeyboard(kbdName,lgCode); + await keyman.setActiveKeyboard(kbdName,lgCode); this.lastActiveKeyboard = 0; } else { if(this.lastActiveKeyboard == this.keyboards.length-1) { - await keymanweb.setActiveKeyboard(''); + await keyman.setActiveKeyboard(''); _v = false; } else { kbdName = this.keyboards[++this.lastActiveKeyboard]._InternalName; lgCode = this.keyboards[this.lastActiveKeyboard]._LanguageCode; - await keymanweb.setActiveKeyboard(kbdName,lgCode); + await keyman.setActiveKeyboard(kbdName,lgCode); _v = true; } } @@ -329,7 +329,7 @@ if(!keymanweb) { }; private __click = () => { - keymanweb.activatingUI(false); // Clear activating UI flag once click is acknowledged + keyman.activatingUI(false); // Clear activating UI flag once click is acknowledged if(this._onclick != null) { return this._onclick(); } @@ -337,7 +337,7 @@ if(!keymanweb) { }; private __mousedown = () => { - keymanweb.activatingUI(true); // Set activating UI flag (to manage focus/blur) on any UI mouse down event + keyman.activatingUI(true); // Set activating UI flag (to manage focus/blur) on any UI mouse down event this._down = true; this.__updatestyle(); return false; @@ -350,7 +350,7 @@ if(!keymanweb) { _setSelected(_value: boolean) { - keymanweb.activatingUI(false); // Always clear activating UI flag after selecting UI + keyman.activatingUI(false); // Always clear activating UI flag after selecting UI this._selected = _value; this.__updatestyle(); }; @@ -408,7 +408,7 @@ if(!keymanweb) { **/ initialize() { //Never initialize before KMW! - if(!keymanweb.initialized || util.isTouchDevice()) { + if(!keyman.initialized || util.isTouchDevice()) { return; } @@ -488,13 +488,13 @@ if(!keymanweb) { * Description Rebuild the UI and keyboard list **/ readonly updateKeyboardList = () => { - if(!(keymanweb.initialized || this.initialized)) { + if(!(keyman.initialized || this.initialized)) { return; //TODO: may want to restart the timer?? } this.updateList = false; - const _kbds=keymanweb.getKeyboards(); + const _kbds=keyman.getKeyboards(); const imgPath=util.getOption('resources') +'ui/toggle/'; // Check the number of installed keyboards to determine whether or not we will have a dropdown @@ -542,14 +542,14 @@ if(!keymanweb) { } // Highlight the last active keyboard - let activeKeyboard = keymanweb.getActiveKeyboard(); + let activeKeyboard = keyman.getActiveKeyboard(); let activeLanguage = ''; if (activeKeyboard) { - activeLanguage = keymanweb.getActiveLanguage(); + activeLanguage = keyman.getActiveLanguage(); } else { // savedKeyboard is only correct if we use global keyboard settings, // otherwise it's set to the first keyboard in the list - const savedKeyboard = keymanweb.getSavedKeyboard().split(':'); + const savedKeyboard = keyman.getSavedKeyboard().split(':'); activeKeyboard = savedKeyboard[0]; activeLanguage = savedKeyboard[1]; } @@ -578,8 +578,8 @@ if(!keymanweb) { languageCode = this.keyboards[kbdIndex]._LanguageCode; } - await keymanweb.setActiveKeyboard(name, languageCode); - keymanweb.focusLastActiveElement(); + await keyman.setActiveKeyboard(name, languageCode); + keyman.focusLastActiveElement(); this.kbdButton._setSelected(name != ''); if (kbdIndex >= 0) { this.lastActiveKeyboard = kbdIndex; @@ -734,7 +734,7 @@ if(!keymanweb) { // Add loaded keyboards to the menu this.keyboards = []; - const keyboards = keymanweb.getKeyboards(); + const keyboards = keyman.getKeyboards(); const added: Record = {}; for (let kbdIndex = 0; kbdIndex < keyboards.length; kbdIndex++) { if (!added[keyboards[kbdIndex].InternalName]) { @@ -761,25 +761,25 @@ if(!keymanweb) { // Actually assign our UI module to the active Keyman engine. - const ui = keymanweb.ui = new ToggleUI(); + const ui = keyman.ui = new ToggleUI(); // CTRL-K_SLASH: toggles to and from default keyboard - keymanweb.addHotKey(191, 0x20, ui.switchSingleKbd); + keyman.addHotKey(191, 0x20, ui.switchSingleKbd); // SHIFT-CTRL-K_SLASH: cycles among available keyboards in sequence - keymanweb.addHotKey(191, 0x30, ui.switchNextKbd); + keyman.addHotKey(191, 0x30, ui.switchNextKbd); // ALT-K_SLASH: Hides the OSK - keymanweb.addHotKey(191, 0x40, ui.switchOsk); + keyman.addHotKey(191, 0x40, ui.switchOsk); // // Initialize after KMW is fully initialized - // keymanweb['addEventListener']('loaduserinterface', ui.initialize); + // keyman['addEventListener']('loaduserinterface', ui.initialize); - keymanweb.addEventListener('controlfocused',function(params){ + keyman.addEventListener('controlfocused',function(params){ ui.doFocus(params.target, true, params.activeControl); }); - keymanweb.addEventListener('controlblurred',function(params){ + keyman.addEventListener('controlblurred',function(params){ ui.doFocus(params.target, false, null); }); @@ -789,7 +789,7 @@ if(!keymanweb) { * Set a timer to update the UI keyboard list on timeout after each keyboard is registered, * thus updating only once when only if multiple keyboards are registered together */ - keymanweb.addEventListener('keyboardregistered', function(p) { + keyman.addEventListener('keyboardregistered', function(p) { ui.updateList = true; if(ui.updateTimer) { clearTimeout(ui.updateTimer); @@ -803,7 +803,7 @@ if(!keymanweb) { * * Update menu selection and control OSK display appropriately */ - keymanweb.addEventListener('keyboardchange', function(p) { + keyman.addEventListener('keyboardchange', function(p) { ui.updateMenu(p.internalName, p.languageCode); }); diff --git a/web/src/app/ui/kmwuitoolbar.ts b/web/src/app/ui/kmwuitoolbar.ts index 6ed8d82e23..f121bb66cd 100644 --- a/web/src/app/ui/kmwuitoolbar.ts +++ b/web/src/app/ui/kmwuitoolbar.ts @@ -52,14 +52,14 @@ interface ListedKeyboard { aNode: HTMLAnchorElement; } -const keymanweb=window.keyman; +const keyman=window.keyman; // If a UI module has been loaded, we can rely on the publically-published 'name' property // having been set as a way to short-out a UI reload. Its parent object always exists by // this point in the build process. -if(!keymanweb) { +if(!keyman) { throw new Error("`keyman` global is missing; Keyman Engine for Web script has not been loaded"); -} else if(!keymanweb.ui?.name) { +} else if(!keyman.ui?.name) { /********************************/ /* */ /* Toolbar User Interface */ @@ -77,7 +77,7 @@ if(!keymanweb) { try { // Declare KeymanWeb, OnScreen keyboard and Util objects - const util=keymanweb.util; + const util=keyman.util; // Disable UI for touch devices if(util.isTouchDevice()) { @@ -228,7 +228,7 @@ if(!keymanweb) { * Initialize toolbar UI */ initialize() { - if(!keymanweb.initialized || this.init) { + if(!keyman.initialized || this.init) { return; } @@ -367,7 +367,7 @@ if(!keymanweb) { aNode.href = '#'; aNode.onclick = this.showOSK; aNode.onmousedown = function() { - keymanweb.activatingUI(true); + keyman.activatingUI(true); }; aNode.title = this.ToolBar_Text.ShowOSK; aNode.appendChild(this.createNode('div', 'kmw_img_osk', 'kmw_img')); @@ -403,7 +403,7 @@ if(!keymanweb) { this.registerEvents(); // Restore focus - keymanweb.focusLastActiveElement(); + keyman.focusLastActiveElement(); } shutdown() { @@ -433,7 +433,7 @@ if(!keymanweb) { this.regionLanguageListNodes = {}; // Build list of keyboards by region and language - const Keyboards = keymanweb.getKeyboards(); + const Keyboards = keyman.getKeyboards(); // Sort the keyboards by region and language Keyboards.sort(this.sortKeyboards); @@ -465,7 +465,7 @@ if(!keymanweb) { } // Get JUST the language code for this section. BCP-47 codes can include more! - const bcpSubtags: string[] = keymanweb.util.getLanguageCodes(Keyboards[j].LanguageCode); + const bcpSubtags: string[] = keyman.util.getLanguageCodes(Keyboards[j].LanguageCode); if(bcpSubtags[0] == languageCode) { continue; // Same language as previous keyboard } @@ -489,7 +489,7 @@ if(!keymanweb) { continue; // Not this region } - const bcpSubtags: string[] = keymanweb.util.getLanguageCodes(Keyboards[j].LanguageCode); + const bcpSubtags: string[] = keyman.util.getLanguageCodes(Keyboards[j].LanguageCode); if(bcpSubtags[0] == languageCode) { // Same language as previous keyboard, so add it to that entry const x = this.languages[languageCode].keyboards; @@ -551,7 +551,7 @@ if(!keymanweb) { } // Restore focus - keymanweb.focusLastActiveElement(); + keyman.focusLastActiveElement(); } /** @@ -819,7 +819,7 @@ if(!keymanweb) { * @return {boolean} **/ private async selectKeyboard(event: Event, lang: LanguageEntry, kbd: KeyboardDetails, updateKeyman: boolean): Promise { - keymanweb.activatingUI(true); + keyman.activatingUI(true); if(this.selectedLanguage) { const found = this.findListedKeyboard(this.selectedLanguage); @@ -839,7 +839,7 @@ if(!keymanweb) { // Return focus to input area and activate the selected keyboard this.addKeyboardToList(lang, kbd); if(updateKeyman) { - await keymanweb.setActiveKeyboard(kbd.InternalName, kbd.LanguageCode).then(() => { + await keyman.setActiveKeyboard(kbd.InternalName, kbd.LanguageCode).then(() => { // Restore focus _after_ the keyboard finishes loading. this.setLastFocus(); }); @@ -850,7 +850,7 @@ if(!keymanweb) { this.saveCookie(); this.enableControls(); - keymanweb.activatingUI(false); + keyman.activatingUI(false); return this.hideKeyboardsPopup(event) || this.hideKeyboardsForLanguage(event); } @@ -869,7 +869,7 @@ if(!keymanweb) { ]; let hideOskButton=false; - if(keymanweb.isCJK(this.selectedKeyboard)) { + if(keyman.isCJK(this.selectedKeyboard)) { hideOskButton = true; } else if(this.selectedKeyboard == null) { hideOskButton = (elems[2].style.display == 'none'); @@ -903,7 +903,7 @@ if(!keymanweb) { * Restore the focus to the last focused element **/ setLastFocus() { - keymanweb.focusLastActiveElement(); + keyman.focusLastActiveElement(); } /** @@ -915,13 +915,13 @@ if(!keymanweb) { * @return {boolean} **/ readonly showOSK = (event: Event) => { - const osk = keymanweb.osk; + const osk = keyman.osk; if(!osk) { return false; } - keymanweb.activatingUI(true); + keyman.activatingUI(true); //Toggle OSK on or off - if(osk && keymanweb.getActiveKeyboard() != '') { + if(osk && keyman.getActiveKeyboard() != '') { if(osk.isEnabled()) { osk.hide(); } else { @@ -929,7 +929,7 @@ if(!keymanweb) { } } this.setLastFocus(); - keymanweb.activatingUI(false); + keyman.activatingUI(false); return this.eventCapture(event); } @@ -957,7 +957,7 @@ if(!keymanweb) { // Return the focus to the input area and set the active keyboard to nothing this.setLastFocus(); - await keymanweb.setActiveKeyboard('',''); + await keyman.setActiveKeyboard('',''); //Save current state when deselecting a keyboard (may not be needed) this.saveCookie(); @@ -1082,10 +1082,10 @@ if(!keymanweb) { } } else { if(this.selectedKeyboard != null) { - if(keymanweb.isCJK()) { + if(keyman.isCJK()) { this.oskButtonNode.style.display = this.oskBarNode.style.display = 'none'; } else { - const osk = keymanweb.osk; + const osk = keyman.osk; this.oskButtonNode.className = (osk && osk.isEnabled()) ? 'kmw_button_selected' : 'kmw_button'; } } @@ -1154,7 +1154,7 @@ if(!keymanweb) { // https://help.keyman.com/developer/engine/web/current-version/reference/events/kmw.keyboardchange this.lastSelectedKeyboard = null; const kbName=p.internalName, - lgName=keymanweb.util.getLanguageCodes(p.languageCode)[0]; + lgName=keyman.util.getLanguageCodes(p.languageCode)[0]; if(lgName != '' && kbName != '') { const lg = this.languages[lgName]; if(lg != null) { @@ -1200,7 +1200,7 @@ if(!keymanweb) { } registerEvents() { - const osk = keymanweb.osk; + const osk = keyman.osk; if(!osk) { return; } @@ -1282,7 +1282,7 @@ if(!keymanweb) { * for the popup, we stash the old event listener here and restore * it when we're done. */ - lastDismissalCallback: (event: MouseEvent) => any = null; + lastDismissalCallback: (this: GlobalEventHandlers, ev: MouseEvent) => any = null; /** * Function PopupDismissal @@ -1392,8 +1392,8 @@ if(!keymanweb) { } else { // If language list and keyboard list have not yet been saved as a cookie, // initialize to the current (default) language and keyboard, if set by KMW - const kbName=keymanweb.getActiveKeyboard(); - const lgName=keymanweb.getActiveLanguage(); + const kbName=keyman.getActiveKeyboard(); + const lgName=keyman.getActiveLanguage(); if(lgName != '' && kbName != '') { const lg = this.languages[lgName]; if(lg != null) { @@ -1429,12 +1429,12 @@ if(!keymanweb) { } - const ui = keymanweb.ui = new ToolbarUI(); + const ui = keyman.ui = new ToolbarUI(); - keymanweb.addEventListener('keyboardregistered', ui.registerKeyboard); - keymanweb.addEventListener('controlfocused', ui.focusControlEvent); - keymanweb.addEventListener('controlblurred', ui.blurControlEvent); - keymanweb.addEventListener('keyboardchange', ui.changeKeyboardEvent); + keyman.addEventListener('keyboardregistered', ui.registerKeyboard); + keyman.addEventListener('controlfocused', ui.focusControlEvent); + keyman.addEventListener('controlblurred', ui.blurControlEvent); + keyman.addEventListener('keyboardchange', ui.changeKeyboardEvent); // Initialize when everything defined (replaces unreliable onload event handler) // In case the toolbar script loads a bit later than the main KMW script From cda7dc6d2428870ce90d2499a1ea9fbe02bced47 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 1 Jul 2026 10:27:22 +0200 Subject: [PATCH 092/148] chore: remove deprecated "X-UA-Compatible" and "apple-mobile-web-app-capable" metas For Keyman Developer Server, add app.webmanifest so that it can be saved to the home screen; other pages should not require this. Fixes: #16172 Test-bot: skip --- common/test/resources/keyboards/test_8568_deadkeys.html | 6 ------ developer/src/server/src/site/app.webmanifest | 9 +++++++++ developer/src/server/src/site/index.html | 2 +- developer/src/tike/xml/app/editor/index.html | 4 ---- oem/firstvoices/windows/src/xml/onlineupdate.xsl | 1 - web/index.html | 4 ---- web/src/samples/complex/root/pages/index.html | 6 ------ web/src/samples/index.html | 4 ---- web/src/samples/simplest/index.html | 6 ------ web/src/samples/subfolder_toggle/index.html | 6 ------ web/src/samples/subfolder_toolbar/index.html | 4 ---- .../dom/cases/gesture-processor/host-page.tests.html | 3 --- web/src/test/auto/e2e/baseline/baseline.tests.html | 2 -- web/src/test/manual/embed/android-harness/index.html | 6 ------ web/src/test/manual/embed/index.html | 4 ---- web/src/test/manual/web/attachment-api/index.html | 6 ------ web/src/test/manual/web/basic-iframe/index.html | 6 ------ web/src/test/manual/web/build-visual-keyboard/index.html | 6 ------ web/src/test/manual/web/chirality/index.html | 6 ------ web/src/test/manual/web/default-subkey/index.html | 6 ------ web/src/test/manual/web/desktop-ui/button.html | 6 ------ web/src/test/manual/web/desktop-ui/float.html | 6 ------ web/src/test/manual/web/desktop-ui/toggle.html | 6 ------ web/src/test/manual/web/desktop-ui/toolbar.html | 6 ------ web/src/test/manual/web/empty-row/index.html | 6 ------ web/src/test/manual/web/gesture-recognizer/index.html | 3 --- web/src/test/manual/web/index.html | 4 ---- web/src/test/manual/web/init-race-10743/index.html | 6 ------ web/src/test/manual/web/inline-osk/index.html | 6 ------ web/src/test/manual/web/issue005/index.html | 6 ------ web/src/test/manual/web/issue103/index.html | 6 ------ web/src/test/manual/web/issue115/index.html | 6 ------ web/src/test/manual/web/issue116/index.html | 6 ------ web/src/test/manual/web/issue11785/index.html | 6 ------ web/src/test/manual/web/issue1332/index.html | 6 ------ web/src/test/manual/web/issue160/index.html | 6 ------ web/src/test/manual/web/issue266/index.html | 6 ------ web/src/test/manual/web/issue271/index.html | 6 ------ web/src/test/manual/web/issue29/index.html | 6 ------ web/src/test/manual/web/issue3701/index.html | 6 ------ web/src/test/manual/web/issue382/index.html | 6 ------ web/src/test/manual/web/issue53/index.html | 6 ------ web/src/test/manual/web/issue6005/index.html | 6 ------ web/src/test/manual/web/issue62/index.html | 6 ------ web/src/test/manual/web/issue63/index.html | 6 ------ .../manual/web/issue917-context-and-notany/index.html | 6 ------ web/src/test/manual/web/issue920/index.html | 6 ------ web/src/test/manual/web/issue9469/index.html | 6 ------ web/src/test/manual/web/keyboard-errors/index.html | 6 ------ web/src/test/manual/web/kmxkeyboard.html | 6 ------ web/src/test/manual/web/mnemonic/index.html | 3 --- web/src/test/manual/web/options-with-save/index.html | 3 --- web/src/test/manual/web/osk-event-buttons/index.html | 6 ------ web/src/test/manual/web/osk-movement/index.html | 6 ------ web/src/test/manual/web/osk/scratchspace/index.html | 6 ------ web/src/test/manual/web/platform/index.html | 6 ------ web/src/test/manual/web/pr10506/index.html | 6 ------ web/src/test/manual/web/prediction-mtnt/index.html | 6 ------ web/src/test/manual/web/prediction-ui/index.html | 6 ------ web/src/test/manual/web/promise-api/index.html | 6 ------ web/src/test/manual/web/regression-tests/index.html | 3 --- web/src/test/manual/web/regression-tests/test.html | 5 +---- web/src/test/manual/web/sentry-integration/index.html | 6 ------ web/src/test/manual/web/test-updateLayer/index.html | 6 ------ web/src/test/manual/web/unminified - manual.html | 6 ------ web/src/test/manual/web/unminified.html | 6 ------ web/src/tools/testing/bulk_rendering/index.html | 6 ------ web/src/tools/testing/bulk_rendering/local-renderer.html | 6 ------ .../gesture-processor/host-fixture/host-fixture.html | 3 --- .../testing/gesture-processor/recorder/src/index.html | 3 --- web/src/tools/testing/index.html | 4 ---- web/src/tools/testing/recorder/index.html | 6 ------ windows/src/desktop/kmshell/xml/onlineupdate.xsl | 1 - 73 files changed, 11 insertions(+), 376 deletions(-) create mode 100644 developer/src/server/src/site/app.webmanifest diff --git a/common/test/resources/keyboards/test_8568_deadkeys.html b/common/test/resources/keyboards/test_8568_deadkeys.html index 054f19b4f8..4ce169551f 100644 --- a/common/test/resources/keyboards/test_8568_deadkeys.html +++ b/common/test/resources/keyboards/test_8568_deadkeys.html @@ -6,12 +6,6 @@ - - - - - - KeymanWeb #8568 diff --git a/developer/src/server/src/site/app.webmanifest b/developer/src/server/src/site/app.webmanifest new file mode 100644 index 0000000000..288b305963 --- /dev/null +++ b/developer/src/server/src/site/app.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "Keyman Developer Server", + "display": "standalone", + "id": "com.keyman.developer.server.self-hosted", + "icons": [{ + "src": "keyman-developer.svg", + "sizes": "any" + }] +} \ No newline at end of file diff --git a/developer/src/server/src/site/index.html b/developer/src/server/src/site/index.html index 8147a849a4..cd9cb5ae02 100644 --- a/developer/src/server/src/site/index.html +++ b/developer/src/server/src/site/index.html @@ -5,7 +5,7 @@ - + Keyman Developer Keyboard Test Site diff --git a/developer/src/tike/xml/app/editor/index.html b/developer/src/tike/xml/app/editor/index.html index b30fa799cf..9054aea37f 100644 --- a/developer/src/tike/xml/app/editor/index.html +++ b/developer/src/tike/xml/app/editor/index.html @@ -9,10 +9,6 @@ - - - - Text Editor diff --git a/oem/firstvoices/windows/src/xml/onlineupdate.xsl b/oem/firstvoices/windows/src/xml/onlineupdate.xsl index 931c0c106b..95024605ac 100644 --- a/oem/firstvoices/windows/src/xml/onlineupdate.xsl +++ b/oem/firstvoices/windows/src/xml/onlineupdate.xsl @@ -10,7 +10,6 @@ - <xsl:value-of select="$locale/string[@name='S_Update_Title']"/> diff --git a/web/index.html b/web/index.html index 7dc3175649..87a7fe5cb5 100644 --- a/web/index.html +++ b/web/index.html @@ -6,10 +6,6 @@ - - - - KeymanWeb Testing + + + +

javascript_error

+ +

+Keyboard with a script error in `gs()`, taken originally from release/b/banne. The .js and .kmp files in modified_build/ have been manually tweaked to throw an error. +

+ + + diff --git a/common/test/keyboards/invalid/engine/javascript_error/source/welcome.htm b/common/test/keyboards/invalid/engine/javascript_error/source/welcome.htm new file mode 100644 index 0000000000..acc98782a0 --- /dev/null +++ b/common/test/keyboards/invalid/engine/javascript_error/source/welcome.htm @@ -0,0 +1,178 @@ + + + + + + Start Using Banne + + + + +

Start Using Banne

+ +

+Keyboard with a script error in `gs()`, taken originally from release/b/banne. The .js and .kmp files in modified_build/ have been manually tweaked to throw an error. +

+ +

Keyboard Layout

+

Keystrokes

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Base+Modifier=Result
s+'=š
S+'=Š
+

Punctuation

+

+ The ' key has been modified to cycle between (U+2039) and (U+203A) from the second keypress onwards. +
+ The first keypress will produce the ' (U+0027) character as expected, and pressing it again will turn the ' into a . Pressing the ' key one more time will produce a . +

+

+ The " key has been modified to automatically type the character « (U+00AB). To cancel this, pressing the ; key before typing a " will produce a normal ". +
+ Pressing the " key repeatedly will cycle between the » (U+00BB) and « characters. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Base+Modifier=Result
'+'=
<+'=
>+'=
" =«
<+<=«
>+>=»
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Base+Modifier=Result
+'=
+'=
«+"=»
»+"=«
;+"="
+
+ + + \ No newline at end of file From ab2bfc90479bd7c316a1c8f7209897762d8e9ac1 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 13 Jul 2026 11:05:42 +0200 Subject: [PATCH 128/148] maint(resources): add extra debug reporting for builder Relates-to: #16137 Test-bot: skip Build-bot: skip build:all release:android --- resources/build/builder-basic.inc.sh | 42 ++++--- resources/build/test/build.sh | 5 + resources/build/test/test.sh | 5 +- resources/builder.inc.sh | 119 +++++++++++++++--- .../android/keyman-android-test-samples.sh | 2 +- 5 files changed, 138 insertions(+), 35 deletions(-) diff --git a/resources/build/builder-basic.inc.sh b/resources/build/builder-basic.inc.sh index a2077b081a..8e0954f5c5 100644 --- a/resources/build/builder-basic.inc.sh +++ b/resources/build/builder-basic.inc.sh @@ -177,19 +177,33 @@ function _builder_basic_print_build_number_for_teamcity() { } function _builder_basic_print_version_utils_debug() { - echo "KEYMAN_ROOT: $KEYMAN_ROOT" - echo "KEYMAN_VERSION: $KEYMAN_VERSION" - echo "KEYMAN_VERSION_WIN: $KEYMAN_VERSION_WIN" - echo "KEYMAN_VERSION_RELEASE: $KEYMAN_VERSION_RELEASE" - echo "KEYMAN_VERSION_MAJOR: $KEYMAN_VERSION_MAJOR" - echo "KEYMAN_VERSION_MINOR: $KEYMAN_VERSION_MINOR" - echo "KEYMAN_VERSION_PATCH: $KEYMAN_VERSION_PATCH" - echo "KEYMAN_TIER: $KEYMAN_TIER" - echo "KEYMAN_VERSION_TAG: $KEYMAN_VERSION_TAG" - echo "KEYMAN_VERSION_WITH_TAG: $KEYMAN_VERSION_WITH_TAG" - echo "KEYMAN_VERSION_GIT_TAG: $KEYMAN_VERSION_GIT_TAG" - echo "KEYMAN_VERSION_ENVIRONMENT: $KEYMAN_VERSION_ENVIRONMENT" - echo "KEYMAN_VERSION_FOR_FILENAME: $KEYMAN_VERSION_FOR_FILENAME" + if ! builder_is_debug_internal && ! builder_is_ci_build; then + return + fi + + if [[ ${_builder_basic_environment_printed:-false} == true ]]; then + return + fi + + builder_echo start builder_basic_debug "Builder internal environment variables" + + echo " KEYMAN_ROOT: $KEYMAN_ROOT" + echo " KEYMAN_VERSION: $KEYMAN_VERSION" + echo " KEYMAN_VERSION_WIN: $KEYMAN_VERSION_WIN" + echo " KEYMAN_VERSION_RELEASE: $KEYMAN_VERSION_RELEASE" + echo " KEYMAN_VERSION_MAJOR: $KEYMAN_VERSION_MAJOR" + echo " KEYMAN_VERSION_MINOR: $KEYMAN_VERSION_MINOR" + echo " KEYMAN_VERSION_PATCH: $KEYMAN_VERSION_PATCH" + echo " KEYMAN_TIER: $KEYMAN_TIER" + echo " KEYMAN_VERSION_TAG: $KEYMAN_VERSION_TAG" + echo " KEYMAN_VERSION_WITH_TAG: $KEYMAN_VERSION_WITH_TAG" + echo " KEYMAN_VERSION_GIT_TAG: $KEYMAN_VERSION_GIT_TAG" + echo " KEYMAN_VERSION_ENVIRONMENT: $KEYMAN_VERSION_ENVIRONMENT" + echo " KEYMAN_VERSION_FOR_FILENAME: $KEYMAN_VERSION_FOR_FILENAME" + + export _builder_basic_environment_printed=true + + builder_echo end builder_basic_debug success "Builder internal environment variables" } # TODO: consolidate with buildLevel, see #14285 @@ -284,8 +298,8 @@ _builder_basic_find_keyman_root _builder_basic_find_tier _builder_basic_find_version -# _builder_basic_print_version_utils_debug _builder_basic_print_build_number_for_teamcity +_builder_basic_print_version_utils_debug _builder_basic_find_should_sentry_release diff --git a/resources/build/test/build.sh b/resources/build/test/build.sh index d1dec4a505..6c4c4dec2c 100755 --- a/resources/build/test/build.sh +++ b/resources/build/test/build.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash +# Avoid timing reports and internal debugging in unit tests. These conflict with +# the printing of outputs and cause tests to fail. +export _builder_debug_internal=false +export _builder_timings=false + ## START STANDARD BUILD SCRIPT INCLUDE # adjust relative paths as necessary THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" diff --git a/resources/build/test/test.sh b/resources/build/test/test.sh index f13a68c727..746ad48027 100755 --- a/resources/build/test/test.sh +++ b/resources/build/test/test.sh @@ -2,7 +2,10 @@ set -eu -# Avoid timing reports in unit tests + +# Avoid timing reports and internal debugging in unit tests. These conflict with +# the printing of outputs and cause tests to fail. +export _builder_debug_internal=false export _builder_timings=false ## START STANDARD BUILD SCRIPT INCLUDE diff --git a/resources/builder.inc.sh b/resources/builder.inc.sh index 6ec2fbcebf..f316b5ade5 100755 --- a/resources/builder.inc.sh +++ b/resources/builder.inc.sh @@ -425,7 +425,7 @@ _builder_failure_trap() { # _builder_cleanup_deps() { if ! builder_is_dep_build && ! builder_is_child_build && [[ ! -z ${_builder_deps_built+x} ]]; then - if $_builder_debug_internal; then + if builder_is_debug_internal; then builder_echo_debug "Dependencies that were built:" cat "$_builder_deps_built" fi @@ -1722,18 +1722,6 @@ _builder_parse_expanded_parameters() { _builder_add_chosen_action_target_dependencies fi - if $_builder_debug_internal; then - builder_echo_debug "Selected actions and targets:" - for e in "${_builder_chosen_action_targets[@]}"; do - builder_echo_debug "* $e" - done - builder_echo_debug - builder_echo_debug "Selected options:" - for e in "${_builder_chosen_options[@]}"; do - builder_echo_debug "* $e" - done - fi - if builder_is_dep_build; then _builder_verify_expected_sub_process_variables elif builder_is_child_build; then @@ -1773,6 +1761,8 @@ _builder_parse_expanded_parameters() { BUILDER_CONFIGURATION=release fi + _builder_print_internal_debug_info + # Now that we've successfully parsed options adhering to the _builder spec, we may activate our # action_failure and action_hanging traps. (We don't want them active on scripts not yet using # said script.) @@ -2080,7 +2070,7 @@ _builder_do_build_deps() { $_builder_offline \ $_builder_build_deps \ --builder-dep-parent "$THIS_SCRIPT_IDENTIFIER" && ( - if $_builder_debug_internal; then + if builder_is_debug_internal; then builder_echo success "## Dependency $dep$dep_target for $_builder_matched_action_name completed successfully" fi ) || ( @@ -2149,6 +2139,9 @@ builder_is_full_dep_build() { # returns `0` if the current build script has at least one dependency. # builder_has_dependencies() { + if [[ ${_builder_deps:-false} == false ]]; then + return 1 + fi if [[ ${#_builder_deps[@]} -eq 0 ]]; then return 1 fi @@ -2245,17 +2238,29 @@ _builder_report_dependencies() { # returns `0` if we should be verbose in output # builder_verbose() { - if [[ $builder_verbose == --verbose ]]; then + if [[ ${builder_verbose:-} == --verbose ]]; then return 0 fi return 1 } # -# returns `0` if we are doing a debug build +# Returns `0` if we are doing a debug build. Not the same as +# `builder_is_debug_internal`. # builder_is_debug_build() { - if [[ $builder_debug == --debug ]]; then + if [[ ${builder_debug:-} == --debug ]]; then + return 0 + fi + return 1 +} + +# +# Returns `0` if builder internal debug reporting is enabled. This is not the +# same as a debug build. +# +builder_is_debug_internal() { + if $_builder_debug_internal; then return 0 fi return 1 @@ -2539,6 +2544,82 @@ _builder_get_operating_system() { readonly BUILDER_OS } +function _builder_echo_function_result() { + if $1; then + printf " %-40s%s\n" "$1:" true + else + printf " %-40s%s\n" "$1:" false + fi +} + +function _builder_print_internal_debug_info() { + if ! builder_is_debug_internal && ! builder_is_ci_build; then + return + fi + + if [[ ${_builder_internal_debug_info_printed:-false} == false ]]; then + builder_echo start builder_debug "Builder internal debug information" + + # For CI builds, we report this only once per build; for internal debug + # builds, it prints for every child/dep build also + echo -e "${COLOR_TEAL}Selected actions and targets${COLOR_RESET}" + for e in "${_builder_chosen_action_targets[@]}"; do + echo " $e" + done + + echo + echo -e "${COLOR_TEAL}Selected options${COLOR_RESET}" + for e in "${_builder_chosen_options[@]}"; do + echo " $e" + done + + echo + echo -e "${COLOR_TEAL}Builder configuration${COLOR_RESET}" + _builder_echo_function_result builder_is_debug_build + _builder_echo_function_result builder_verbose + + echo + echo -e "${COLOR_TEAL}Builder CI information${COLOR_RESET}" + _builder_echo_function_result builder_is_ci_build + _builder_echo_function_result builder_is_ci_release_build + _builder_echo_function_result builder_is_ci_test_build + _builder_echo_function_result builder_is_ci_build_level_release + _builder_echo_function_result builder_is_ci_build_level_build + + echo + echo -e "${COLOR_TEAL}Builder platform information${COLOR_RESET}" + _builder_echo_function_result builder_is_running_on_docker + _builder_echo_function_result builder_is_running_on_gha + _builder_echo_function_result builder_is_running_on_teamcity + _builder_echo_function_result builder_is_windows + _builder_echo_function_result builder_is_macos + _builder_echo_function_result builder_is_linux + fi + + if builder_is_debug_internal; then + # This may change in sub-project builds, so we print it every time, + # but we won't print it in CI to prevent logs exploding; note that + # the builder_debug header will not be printed each time; that is + # "by design" + echo + echo -e "${COLOR_TEAL}Builder dependency internal data${COLOR_RESET}" + _builder_echo_function_result builder_is_dep_build + _builder_echo_function_result builder_is_child_build + _builder_echo_function_result builder_is_quick_dep_build + _builder_echo_function_result builder_is_full_dep_build + _builder_echo_function_result builder_has_dependencies + fi + + if [[ ${_builder_internal_debug_info_printed:-false} == false ]]; then + builder_echo end builder_debug success "Builder internal debug information" + + if builder_is_ci_build; then + # In CI builds, we will only print the debug info once + export _builder_internal_debug_info_printed=true + fi + fi +} + ################################################################################ # Final initialization ################################################################################ @@ -2557,6 +2638,6 @@ if [ -z ${_builder_debug_internal+x} ]; then _builder_debug_internal=false fi -if $_builder_debug_internal; then - builder_echo_debug "Command line: $0 $@" +if builder_is_debug_internal; then + builder_echo_debug "Command line: $0 $*" fi diff --git a/resources/teamcity/android/keyman-android-test-samples.sh b/resources/teamcity/android/keyman-android-test-samples.sh index 675d339dcf..c79612a3ca 100755 --- a/resources/teamcity/android/keyman-android-test-samples.sh +++ b/resources/teamcity/android/keyman-android-test-samples.sh @@ -29,7 +29,7 @@ builder_parse "$@" cd "${KEYMAN_ROOT}/android" function do_build() { - builder_launch /android/build.sh configure,build:engine,sample1,sample2,keyboardharness + builder_launch /android/build.sh configure,build:sample1,sample2,keyboardharness } if builder_has_action all; then From a1c8e63cc8f0dc702a206e79df740b7743f9d539 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 13 Jul 2026 16:28:33 +0200 Subject: [PATCH 129/148] chore(android): cleanup Android build scripts and artifact filenames * Specify clearer filenames for Android samples and test KeyboardHarness * Cleanup android/*/build.sh to match current patterns * Remove obsolete jcenter dependency * Export BUILDER_CONFIGURATION in builder.inc.sh Fixes: #16137 Fixes: #16181 Test-bot: skip Build-bot: skip release:android --- android/KMAPro/build.sh | 96 ++++++++---------- android/KMEA/app/build.gradle | 2 +- android/KMEA/build.sh | 99 +++++++------------ android/Samples/KMSample1/app/build.gradle | 17 +++- android/Samples/KMSample1/build.gradle | 2 - android/Samples/KMSample1/build.sh | 61 ++---------- android/Samples/KMSample2/app/build.gradle | 16 ++- android/Samples/KMSample2/build.gradle | 2 - android/Samples/KMSample2/build.sh | 61 ++---------- .../Tests/KeyboardHarness/app/build.gradle | 3 +- android/Tests/KeyboardHarness/build.sh | 72 ++------------ android/android-utils.inc.sh | 20 ++++ android/build.sh | 14 +-- android/version.gradle | 11 +++ oem/firstvoices/android/build.gradle | 1 - resources/builder.inc.sh | 4 +- 16 files changed, 176 insertions(+), 305 deletions(-) create mode 100644 android/android-utils.inc.sh diff --git a/android/KMAPro/build.sh b/android/KMAPro/build.sh index 06370c06d2..ba3ea5aa59 100755 --- a/android/KMAPro/build.sh +++ b/android/KMAPro/build.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Build Keyman for Android app (KMAPro) +# Keyman is copyright (C) SIL Global. MIT License. +# ## START STANDARD BUILD SCRIPT INCLUDE # adjust relative paths as necessary THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" @@ -9,111 +10,92 @@ THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" . "$KEYMAN_ROOT/resources/build/utils.inc.sh" . "$KEYMAN_ROOT/resources/build/build-help.inc.sh" . "$KEYMAN_ROOT/resources/build/build-download-resources.sh" - . "$KEYMAN_ROOT/android/KMAPro/build-play-store-notes.inc.sh" # ################################ Main script ################################ -# Definition of global compile constants - -CONFIG="release" -BUILD_FLAGS="build -x lint -x test" # Gradle build w/o test -TEST_FLAGS="-x assembleRelease lintRelease testRelease" # Gradle test w/o build -DAEMON_FLAG= - builder_describe "Builds Keyman for Android app." \ "@/android/KMEA" \ "clean" \ "configure" \ "build" \ - "test Runs lint and unit tests." \ - "publish-symbols Publishes symbols to Sentry." \ + "test Runs lint and unit tests." \ + "publish-symbols Publishes symbols to Sentry." \ "publish-play-store Publishes the APK to the Play Store." -# parse before describe_outputs to check debug flags builder_parse "$@" if builder_is_debug_build; then - builder_heading "### Debug config ####" - CONFIG="debug" BUILD_FLAGS="assembleDebug -x lintDebug -x testDebug" TEST_FLAGS="-x assembleDebug lintDebug testDebug" +else + BUILD_FLAGS="build -x lint -x test" # Gradle build w/o test + TEST_FLAGS="-x assembleRelease lintRelease testRelease" # Gradle test w/o build fi +if builder_is_ci_build; then + GRADLE_DAEMON=--no-daemon +else + GRADLE_DAEMON= +fi + +# Locations for bundled keyboard and lexical model + KEYBOARD_PACKAGE_ID="sil_euro_latin" KEYBOARDS_TARGET="android/KMAPro/kMAPro/src/main/assets/${KEYBOARD_PACKAGE_ID}.kmp" MODEL_PACKAGE_ID="nrc.en.mtnt" MODELS_TARGET="android/KMAPro/kMAPro/src/main/assets/${MODEL_PACKAGE_ID}.model.kmp" + builder_describe_outputs \ configure "/${MODELS_TARGET}" \ - build /android/KMAPro/kMAPro/build/outputs/apk/$CONFIG/keyman-${KEYMAN_VERSION_FOR_FILENAME}.apk - -#### Build - - -# Parse args - -if builder_is_ci_build; then - DAEMON_FLAG=--no-daemon -fi + build /android/KMAPro/kMAPro/build/outputs/apk/$BUILDER_CONFIGURATION/keyman-${KEYMAN_VERSION_FOR_FILENAME}.apk #### Build action definitions #### -# Check about cleaning artifact paths -if builder_start_action clean; then - rm -rf "$KEYMAN_ROOT/android/KMAPro/kMAPro/build/outputs" - builder_finish_action success clean -fi - -if builder_start_action configure; then - +do_configure() { downloadKeyboardPackage "$KEYBOARD_PACKAGE_ID" "${KEYMAN_ROOT}/$KEYBOARDS_TARGET" downloadModelPackage "$MODEL_PACKAGE_ID" "${KEYMAN_ROOT}/$MODELS_TARGET" +} - builder_finish_action success configure -fi - -if builder_start_action build; then - +do_build() { # Copy Keyman Engine for Android - cp "${KEYMAN_ROOT}/android/KMEA/app/build/outputs/aar/keyman-engine-${CONFIG}.aar" "${KEYMAN_ROOT}/android/KMAPro/kMAPro/libs/keyman-engine.aar" + cp "${KEYMAN_ROOT}/android/KMEA/app/build/outputs/aar/keyman-engine-${BUILDER_CONFIGURATION}.aar" "${KEYMAN_ROOT}/android/KMAPro/kMAPro/libs/keyman-engine.aar" # Convert markdown to html for offline help build_help_html android KMAPro/kMAPro/src/main/assets/info - echo "BUILD_FLAGS $BUILD_FLAGS" - ./gradlew $DAEMON_FLAG clean $BUILD_FLAGS + builder_echo "BUILD_FLAGS $BUILD_FLAGS" + ./gradlew $GRADLE_DAEMON $BUILD_FLAGS - mv "${KEYMAN_ROOT}/android/KMAPro/kMAPro/build/outputs/apk/$CONFIG/keyman-${KEYMAN_VERSION}.apk" "${KEYMAN_ROOT}/android/KMAPro/kMAPro/build/outputs/apk/$CONFIG/keyman-${KEYMAN_VERSION_FOR_FILENAME}.apk" + mv "${KEYMAN_ROOT}/android/KMAPro/kMAPro/build/outputs/apk/$BUILDER_CONFIGURATION/keyman-${KEYMAN_VERSION}.apk" "${KEYMAN_ROOT}/android/KMAPro/kMAPro/build/outputs/apk/$BUILDER_CONFIGURATION/keyman-${KEYMAN_VERSION_FOR_FILENAME}.apk" +} - builder_finish_action success build -fi +do_test() { + builder_echo "TEST_FLAGS $TEST_FLAGS" + ./gradlew $GRADLE_DAEMON $TEST_FLAGS +} -if builder_start_action test; then - - echo "TEST_FLAGS $TEST_FLAGS" - ./gradlew $DAEMON_FLAG $TEST_FLAGS - - builder_finish_action success test -fi - -publish_symbols() { +do_publish_symbols() { if builder_is_ci_build && builder_is_ci_build_level_release; then # TODO: what does publishSentry even do? - ./gradlew $DAEMON_FLAG publishSentry + ./gradlew $GRADLE_DAEMON publishSentry builder_echo "Making a Sentry release for tag $KEYMAN_VERSION_GIT_TAG" sentry-cli upload-dif -p keyman-android --include-sources sentry-cli releases -p keyman-android files $KEYMAN_VERSION_GIT_TAG upload-sourcemaps ./ fi } -publish_play_store() { +do_publish_play_store() { if builder_is_ci_build && builder_is_ci_build_level_release; then generateReleaseNotes # Publish Keyman for Android to Play Store - ./gradlew $DAEMON_FLAG publishReleaseApk + ./gradlew $GRADLE_DAEMON publishReleaseApk fi } -builder_run_action publish-symbols publish_symbols -builder_run_action publish-play-store publish_play_store +builder_run_action clean rm -rf "$KEYMAN_ROOT/android/KMAPro/kMAPro/build" +builder_run_action configure do_configure +builder_run_action build do_build +builder_run_action test do_test +builder_run_action publish-symbols do_publish_symbols +builder_run_action publish-play-store do_publish_play_store diff --git a/android/KMEA/app/build.gradle b/android/KMEA/app/build.gradle index 4bb05106e3..8f77d155f0 100644 --- a/android/KMEA/app/build.gradle +++ b/android/KMEA/app/build.gradle @@ -8,7 +8,7 @@ ext.rootPath = '../../' apply from: "$rootPath/version.gradle" base { - // Specify library filename keyman-engine-$CONFIG.aar + // Specify library filename keyman-engine-$BUILDER_CONFIGURATION.aar archivesName = "keyman-engine" } diff --git a/android/KMEA/build.sh b/android/KMEA/build.sh index 28d9877285..85a1894672 100755 --- a/android/KMEA/build.sh +++ b/android/KMEA/build.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Keyman is copyright (C) SIL Global. MIT License. +# # Build Keyman Engine for Android using Keyman Web artifacts # ## START STANDARD BUILD SCRIPT INCLUDE @@ -7,20 +9,9 @@ THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" . "${THIS_SCRIPT%/*}/../../resources/build/builder-full.inc.sh" ## END STANDARD BUILD SCRIPT INCLUDE -. "$KEYMAN_ROOT/resources/build/utils.inc.sh" # ################################ Main script ################################ -# Definition of global compile constants - -KEYMAN_ANDROID_ROOT="$KEYMAN_ROOT/android" -KEYMAN_WEB_ROOT="$KEYMAN_ROOT/web" -ENGINE_ASSETS="$KEYMAN_ANDROID_ROOT/KMEA/app/src/main/assets" -CONFIG="release" -BUILD_FLAGS="aR -x lint -x test" # Gradle build w/o test -TEST_FLAGS="-x aR lintRelease testRelease" # Gradle test w/o build -JUNIT_RESULTS="##teamcity[importData type='junit' path='keyman\android\KMEA\app\build\test-results\testReleaseUnitTest\']" - builder_describe "Builds Keyman Engine for Android." \ "@/web/src/app/webview" \ "@/common/web/sentry-manager" \ @@ -30,78 +21,64 @@ builder_describe "Builds Keyman Engine for Android." \ "test Runs lint and unit tests." \ ":engine Builds Engine" -# parse before describe_outputs to check debug flags builder_parse "$@" +# Definition of global compile constants + if builder_is_debug_build; then - builder_heading "### Debug config ####" - CONFIG="debug" BUILD_FLAGS="assembleDebug -x lintDebug -x testDebug" TEST_FLAGS="-x assembleDebug lintDebug testDebug" JUNIT_RESULTS="##teamcity[importData type='junit' path='keyman\android\KMEA\app\build\test-results\testDebugUnitTest\']" +else + BUILD_FLAGS="assembleRelease -x lint -x test" # Gradle build w/o test + TEST_FLAGS="-x assembleRelease lintRelease testRelease" # Gradle test w/o build + JUNIT_RESULTS="##teamcity[importData type='junit' path='keyman\android\KMEA\app\build\test-results\testReleaseUnitTest\']" +fi + +if builder_is_ci_build; then + GRADLE_DAEMON=--no-daemon +else + GRADLE_DAEMON= fi builder_describe_outputs \ - build:engine /android/KMEA/app/build/outputs/aar/keyman-engine-${CONFIG}.aar - -#### Build - - -# Parse args - -DAEMON_FLAG= -if builder_is_ci_build; then - DAEMON_FLAG=--no-daemon -fi + build:engine /android/KMEA/app/build/outputs/aar/keyman-engine-${BUILDER_CONFIGURATION}.aar #### Build action definitions #### -# Check about cleaning artifact paths -if builder_start_action clean:engine; then - rm -rf "$KEYMAN_ROOT/android/KMEA/app/build/outputs" - builder_finish_action success clean:engine -fi - -if builder_start_action configure:engine; then - - builder_finish_action success configure:engine -fi - -# Destinations that will need the keymanweb artifacts - - -if builder_start_action build:engine; then +do_build() { + local ENGINE_ASSETS="$KEYMAN_ROOT/android/KMEA/app/src/main/assets" # Copy KeymanWeb artifacts - echo "Copying Keyman Web artifacts" - cp "$KEYMAN_WEB_ROOT/build/app/webview/$CONFIG/keymanweb-webview.js" "$ENGINE_ASSETS/keymanweb-webview.js" - cp "$KEYMAN_WEB_ROOT/build/app/webview/$CONFIG/keymanweb-webview.js.map" "$ENGINE_ASSETS/keymanweb-webview.js.map" - cp "$KEYMAN_WEB_ROOT/build/app/webview/$CONFIG/map-polyfill.js" "$ENGINE_ASSETS/map-polyfill.js" - cp "$KEYMAN_WEB_ROOT/build/app/resources/osk/ajax-loader.gif" "$ENGINE_ASSETS/ajax-loader.gif" - cp "$KEYMAN_WEB_ROOT/build/app/resources/osk/kmwosk.css" "$ENGINE_ASSETS/kmwosk.css" - cp "$KEYMAN_WEB_ROOT/build/app/resources/osk/globe-hint.css" "$ENGINE_ASSETS/globe-hint.css" - cp "$KEYMAN_WEB_ROOT/build/app/resources/osk/keymanweb-osk.ttf" "$ENGINE_ASSETS/keymanweb-osk.ttf" + builder_echo "Copying Keyman Web artifacts" + cp "$KEYMAN_ROOT/web/build/app/webview/$BUILDER_CONFIGURATION/keymanweb-webview.js" "$ENGINE_ASSETS/" + cp "$KEYMAN_ROOT/web/build/app/webview/$BUILDER_CONFIGURATION/keymanweb-webview.js.map" "$ENGINE_ASSETS/" + cp "$KEYMAN_ROOT/web/build/app/webview/$BUILDER_CONFIGURATION/map-polyfill.js" "$ENGINE_ASSETS/" + cp "$KEYMAN_ROOT/web/build/app/resources/osk/ajax-loader.gif" "$ENGINE_ASSETS/" + cp "$KEYMAN_ROOT/web/build/app/resources/osk/kmwosk.css" "$ENGINE_ASSETS/" + cp "$KEYMAN_ROOT/web/build/app/resources/osk/globe-hint.css" "$ENGINE_ASSETS/" + cp "$KEYMAN_ROOT/web/build/app/resources/osk/keymanweb-osk.ttf" "$ENGINE_ASSETS/" cp "$KEYMAN_ROOT/common/web/sentry-manager/build/lib/index.js" "$ENGINE_ASSETS/keyman-sentry.js" - echo "Copying es6-shim polyfill" - cp "$KEYMAN_ROOT/node_modules/es6-shim/es6-shim.min.js" "$ENGINE_ASSETS/es6-shim.min.js" + builder_echo "Copying es6-shim polyfill" + cp "$KEYMAN_ROOT/node_modules/es6-shim/es6-shim.min.js" "$ENGINE_ASSETS/" + + builder_echo "BUILD_FLAGS $BUILD_FLAGS" - echo "BUILD_FLAGS $BUILD_FLAGS" # Build without test - ./gradlew $DAEMON_FLAG clean $BUILD_FLAGS - - builder_finish_action success build:engine -fi - -if builder_start_action test:engine; then + ./gradlew $GRADLE_DAEMON $BUILD_FLAGS +} +do_test() { if builder_is_ci_build; then # Report JUnit test results to CI echo "$JUNIT_RESULTS" fi - echo "TEST_FLAGS: $TEST_FLAGS" - ./gradlew $DAEMON_FLAG $TEST_FLAGS + builder_echo "TEST_FLAGS: $TEST_FLAGS" + ./gradlew $GRADLE_DAEMON $TEST_FLAGS +} - builder_finish_action success test:engine -fi +builder_run_action clean:engine rm -rf build app/build +builder_run_action build:engine do_build +builder_run_action test:engine do_test diff --git a/android/Samples/KMSample1/app/build.gradle b/android/Samples/KMSample1/app/build.gradle index 395d6a69e5..4643d0e796 100644 --- a/android/Samples/KMSample1/app/build.gradle +++ b/android/Samples/KMSample1/app/build.gradle @@ -2,6 +2,9 @@ plugins { id 'com.android.application' } +ext.rootPath = '../../../' +apply from: "$rootPath/version.gradle" + java { toolchain { languageVersion = JavaLanguageVersion.of(21) @@ -17,13 +20,22 @@ android { noCompress "kmp" } + buildFeatures { + buildConfig = true + } + defaultConfig { applicationId "com.keyman.kmsample1" minSdkVersion 21 targetSdkVersion 35 - versionCode 1 - versionName "1.0" + + // KEYMAN_VERSION_CODE, KEYMAN_VERSION_NAME, KEYMAN_VERSION_FOR_FILENAME from version.gradle + versionCode KEYMAN_VERSION_CODE as Integer + versionName KEYMAN_VERSION_NAME + buildConfigField "String", "KEYMAN_VERSION_ENVIRONMENT", "\""+KEYMAN_VERSION_ENVIRONMENT+"\"" + archivesBaseName = "KMSample1-$KEYMAN_VERSION_FOR_FILENAME" } + buildTypes { release { minifyEnabled false @@ -40,7 +52,6 @@ android { repositories { google() - } dependencies { diff --git a/android/Samples/KMSample1/build.gradle b/android/Samples/KMSample1/build.gradle index 18d4c46cd5..43ca9c592c 100644 --- a/android/Samples/KMSample1/build.gradle +++ b/android/Samples/KMSample1/build.gradle @@ -3,7 +3,6 @@ buildscript { repositories { google() - jcenter() mavenCentral() } dependencies { @@ -17,7 +16,6 @@ buildscript { allprojects { repositories { google() - jcenter() mavenCentral() } } diff --git a/android/Samples/KMSample1/build.sh b/android/Samples/KMSample1/build.sh index d1d41bc200..d6fca14653 100755 --- a/android/Samples/KMSample1/build.sh +++ b/android/Samples/KMSample1/build.sh @@ -1,21 +1,16 @@ #!/usr/bin/env bash +# Keyman is copyright (C) SIL Global. MIT License. # -# Samples: KMsample1 ## START STANDARD BUILD SCRIPT INCLUDE # adjust relative paths as necessary THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" . "${THIS_SCRIPT%/*}/../../../resources/build/builder-full.inc.sh" ## END STANDARD BUILD SCRIPT INCLUDE -. "$KEYMAN_ROOT/resources/build/utils.inc.sh" +. "${KEYMAN_ROOT}/android/android-utils.inc.sh" ################################ Main script ################################ -# Definition of global compile constants - -CONFIG="release" -SAMPLE_FLAGS="build" - builder_describe "Build KMSample1 app for Android." \ "@/android/KMEA" \ "clean" \ @@ -24,55 +19,19 @@ builder_describe "Build KMSample1 app for Android." \ "test" \ ":app KMSample1" -# parse before describe_outputs to check debug flags builder_parse "$@" -ARTIFACT="app-release-unsigned.apk" - -if builder_is_debug_build; then - builder_heading "### Debug config ####" - CONFIG="debug" - SAMPLE_FLAGS="assembleDebug" - ARTIFACT="app-$CONFIG.apk" -fi - +android_set_gradle_environment builder_describe_outputs \ - build:app /android/Samples/KMSample1/app/build/outputs/apk/$CONFIG/$ARTIFACT - - - -# Parse args - -if builder_is_ci_build; then - SAMPLE_FLAGS="$SAMPLE_FLAGS -no-daemon" -fi + build:app "/android/Samples/KMSample1/app/build/outputs/apk/${BUILDER_CONFIGURATION}/KMSample1-${KEYMAN_VERSION_FOR_FILENAME}-${ARCHIVE_TARGET}.apk" #### Build action definitions #### -# Check about cleaning artifact paths -if builder_start_action clean:app; then - rm -rf "$KEYMAN_ROOT/android/Samples/KMSample1/app/build/outputs" - builder_finish_action success clean:app -fi +do_build() { + cp "$KEYMAN_ROOT/android/KMEA/app/build/outputs/aar/keyman-engine-${BUILDER_CONFIGURATION}.aar" "$KEYMAN_ROOT/android/Samples/KMSample1/app/libs/keyman-engine.aar" + ./gradlew $GRADLE_DAEMON $GRADLE_TARGET +} -if builder_start_action configure:app; then - - builder_finish_action success configure:app -fi - -# Building KMSample1 -if builder_start_action build:app; then - - # Copy Keyman Engine for Android - cp "$KEYMAN_ROOT/android/KMEA/app/build/outputs/aar/keyman-engine-${CONFIG}.aar" "$KEYMAN_ROOT/android/Samples/KMSample1/app/libs/keyman-engine.aar" - - ./gradlew clean $SAMPLE_FLAGS - - builder_finish_action success build:app -fi - -if builder_start_action test:app; then - # TODO: define tests - builder_finish_action success test:app -fi +builder_run_action clean:app rm -rf build app/build +builder_run_action build:app do_build diff --git a/android/Samples/KMSample2/app/build.gradle b/android/Samples/KMSample2/app/build.gradle index 40e30c0025..a3c9658056 100644 --- a/android/Samples/KMSample2/app/build.gradle +++ b/android/Samples/KMSample2/app/build.gradle @@ -2,6 +2,9 @@ plugins { id 'com.android.application' } +ext.rootPath = '../../../' +apply from: "$rootPath/version.gradle" + java { toolchain { languageVersion = JavaLanguageVersion.of(21) @@ -17,13 +20,22 @@ android { noCompress "kmp" } + buildFeatures { + buildConfig = true + } + defaultConfig { applicationId "com.keyman.kmsample2" minSdkVersion 21 targetSdkVersion 35 - versionCode 1 - versionName "1.0" + + // KEYMAN_VERSION_CODE, KEYMAN_VERSION_NAME, KEYMAN_VERSION_FOR_FILENAME from version.gradle + versionCode KEYMAN_VERSION_CODE as Integer + versionName KEYMAN_VERSION_NAME + buildConfigField "String", "KEYMAN_VERSION_ENVIRONMENT", "\""+KEYMAN_VERSION_ENVIRONMENT+"\"" + archivesBaseName = "KMSample2-$KEYMAN_VERSION_FOR_FILENAME" } + buildTypes { release { minifyEnabled false diff --git a/android/Samples/KMSample2/build.gradle b/android/Samples/KMSample2/build.gradle index 18d4c46cd5..43ca9c592c 100644 --- a/android/Samples/KMSample2/build.gradle +++ b/android/Samples/KMSample2/build.gradle @@ -3,7 +3,6 @@ buildscript { repositories { google() - jcenter() mavenCentral() } dependencies { @@ -17,7 +16,6 @@ buildscript { allprojects { repositories { google() - jcenter() mavenCentral() } } diff --git a/android/Samples/KMSample2/build.sh b/android/Samples/KMSample2/build.sh index 3c60795e8d..14a9a49507 100755 --- a/android/Samples/KMSample2/build.sh +++ b/android/Samples/KMSample2/build.sh @@ -1,21 +1,16 @@ #!/usr/bin/env bash +# Keyman is copyright (C) SIL Global. MIT License. # -# Samples: KMSample2 ## START STANDARD BUILD SCRIPT INCLUDE # adjust relative paths as necessary THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" . "${THIS_SCRIPT%/*}/../../../resources/build/builder-full.inc.sh" ## END STANDARD BUILD SCRIPT INCLUDE -. "$KEYMAN_ROOT/resources/build/utils.inc.sh" +. "${KEYMAN_ROOT}/android/android-utils.inc.sh" ################################ Main script ################################ -# Definition of global compile constants - -CONFIG="release" -SAMPLE_FLAGS="build" - builder_describe "Build KMSample2 app for Android." \ "@/android/KMEA" \ "clean" \ @@ -24,55 +19,19 @@ builder_describe "Build KMSample2 app for Android." \ "test" \ ":app KMSample2" -# parse before describe_outputs to check debug flags builder_parse "$@" -ARTIFACT="app-release-unsigned.apk" - -if builder_is_debug_build; then - builder_heading "### Debug config ####" - CONFIG="debug" - SAMPLE_FLAGS="assembleDebug" - ARTIFACT="app-$CONFIG.apk" -fi - - +android_set_gradle_environment builder_describe_outputs \ - build:app /android/Samples/KMSample2/app/build/outputs/apk/$CONFIG/$ARTIFACT - - - -# Parse args - -if builder_is_ci_build; then - SAMPLE_FLAGS="$SAMPLE_FLAGS -no-daemon" -fi + build:app "/android/Samples/KMSample2/app/build/outputs/apk/${BUILDER_CONFIGURATION}/KMSample2-${KEYMAN_VERSION_FOR_FILENAME}-${ARCHIVE_TARGET}.apk" #### Build action definitions #### -# Check about cleaning artifact paths -if builder_start_action clean:app; then - rm -rf "$KEYMAN_ROOT/android/Samples/KMSample2/app/build/outputs" - builder_finish_action success clean:app -fi +do_build() { + cp "$KEYMAN_ROOT/android/KMEA/app/build/outputs/aar/keyman-engine-${BUILDER_CONFIGURATION}.aar" "$KEYMAN_ROOT/android/Samples/KMSample2/app/libs/keyman-engine.aar" + ./gradlew $GRADLE_DAEMON $GRADLE_TARGET +} -if builder_start_action configure:app; then - - builder_finish_action success configure:app -fi - -# Building KMSample2 -if builder_start_action build:app; then - # Copy Keyman Engine for Android - cp "$KEYMAN_ROOT/android/KMEA/app/build/outputs/aar/keyman-engine-${CONFIG}.aar" "$KEYMAN_ROOT/android/Samples/KMSample2/app/libs/keyman-engine.aar" - - ./gradlew clean $SAMPLE_FLAGS - - builder_finish_action success build:app -fi - -if builder_start_action test:app; then - # TODO: define tests - builder_finish_action success test:app -fi +builder_run_action clean:app rm -rf build app/build +builder_run_action build:app do_build diff --git a/android/Tests/KeyboardHarness/app/build.gradle b/android/Tests/KeyboardHarness/app/build.gradle index d9502cfa64..070ee5c63b 100644 --- a/android/Tests/KeyboardHarness/app/build.gradle +++ b/android/Tests/KeyboardHarness/app/build.gradle @@ -29,10 +29,11 @@ android { minSdkVersion 21 targetSdkVersion 35 - // KEYMAN_VERSION_CODE and KEYMAN_VERSION_NAME from version.gradle + // KEYMAN_VERSION_CODE, KEYMAN_VERSION_NAME, KEYMAN_VERSION_FOR_FILENAME from version.gradle versionCode KEYMAN_VERSION_CODE as Integer versionName KEYMAN_VERSION_NAME buildConfigField "String", "KEYMAN_VERSION_ENVIRONMENT", "\""+KEYMAN_VERSION_ENVIRONMENT+"\"" + archivesBaseName = "KeyboardHarness-$KEYMAN_VERSION_FOR_FILENAME" } buildTypes { release { diff --git a/android/Tests/KeyboardHarness/build.sh b/android/Tests/KeyboardHarness/build.sh index ccb7c99bc2..2eb9a2f35f 100755 --- a/android/Tests/KeyboardHarness/build.sh +++ b/android/Tests/KeyboardHarness/build.sh @@ -1,21 +1,16 @@ #!/usr/bin/env bash +# Keyman is copyright (C) SIL Global. MIT License. # -# Build Test app: KeyboardHarness ## START STANDARD BUILD SCRIPT INCLUDE # adjust relative paths as necessary THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" . "${THIS_SCRIPT%/*}/../../../resources/build/builder-full.inc.sh" ## END STANDARD BUILD SCRIPT INCLUDE -. "$KEYMAN_ROOT/resources/build/utils.inc.sh" +. "${KEYMAN_ROOT}/android/android-utils.inc.sh" ################################ Main script ################################ -# Definition of global compile constants -CONFIG="release" -BUILD_FLAGS="aR -x lint -x test" # Gradle build w/o test -TEST_FLAGS="-x aR lintRelease testRelease" # Gradle test w/o build - builder_describe "Build KeyboardHarness test app for Android." \ "@/android/KMEA" \ "clean" \ @@ -24,66 +19,19 @@ builder_describe "Build KeyboardHarness test app for Android." \ "test" \ ":app KeyboardHarness" -# parse before describe outputs to check debug flags builder_parse "$@" -ARTIFACT="app-release-unsigned.apk" - -if builder_is_debug_build; then - builder_heading "### Debug config ####" - CONFIG="debug" - BUILD_FLAGS="assembleDebug -x lintDebug -x testDebug" - TEST_FLAGS="-x assembleDebug lintDebug testDebug" - ARTIFACT="app-$CONFIG.apk" -fi - +android_set_gradle_environment builder_describe_outputs \ - build:app /android/Tests/KeyboardHarness/app/build/outputs/apk/$CONFIG/${ARTIFACT} - -#### Build - - -# -# Prevents 'clear' on exit of mingw64 bash shell -# -SHLVL=0 - - -# Parse args - -# Build flags that apply to all targets -if builder_is_ci_build; then - BUILD_FLAGS="$BUILD_FLAGS -no-daemon" - TEST_FLAGS="$TEST_FLAGS -no-daemon" -fi + build:app "/android/Tests/KeyboardHarness/app/build/outputs/apk/${BUILDER_CONFIGURATION}/KeyboardHarness-${KEYMAN_VERSION_FOR_FILENAME}-${ARCHIVE_TARGET}.apk" #### Build action definitions #### -# Check about cleaning artifact paths -if builder_start_action clean:app; then - rm -rf "$KEYMAN_ROOT/android/Tests/KeyboardHarness/app/build/outputs" - builder_finish_action success clean:app -fi +do_build() { + cp "$KEYMAN_ROOT/android/KMEA/app/build/outputs/aar/keyman-engine-${BUILDER_CONFIGURATION}.aar" ./app/libs/keyman-engine.aar + ./gradlew $GRADLE_DAEMON $GRADLE_TARGET +} -if builder_start_action configure:app; then - - builder_finish_action success configure:app -fi - -# Building KeyboardHarness -if builder_start_action build:app; then - - # Copy Keyman Engine for Android - cp "$KEYMAN_ROOT/android/KMEA/app/build/outputs/aar/keyman-engine-${CONFIG}.aar" "$KEYMAN_ROOT/android/Tests/KeyboardHarness/app/libs/keyman-engine.aar" - - echo "BUILD_FLAGS: $BUILD_FLAGS" - ./gradlew clean $BUILD_FLAGS - builder_finish_action success build:app -fi - -if builder_start_action test:app; then - echo "TEST_FLAGS $TEST_FLAGS" - # TODO: define tests - builder_finish_action success test:app -fi +builder_run_action clean:app rm -rf build app/build +builder_run_action build:app do_build diff --git a/android/android-utils.inc.sh b/android/android-utils.inc.sh new file mode 100644 index 0000000000..2e790b6e4e --- /dev/null +++ b/android/android-utils.inc.sh @@ -0,0 +1,20 @@ +# shellcheck shell=bash +# Keyman is copyright (C) SIL Global. MIT License. + +function android_set_gradle_environment() { + if builder_is_debug_build; then + GRADLE_TARGET=assembleDebug + ARCHIVE_TARGET=debug + else + GRADLE_TARGET=build + ARCHIVE_TARGET=release-unsigned + fi + + if builder_is_ci_build; then + GRADLE_DAEMON=-no-daemon + else + GRADLE_DAEMON= + fi + + export GRADLE_TARGET GRADLE_DAEMON ARCHIVE_TARGET +} \ No newline at end of file diff --git a/android/build.sh b/android/build.sh index 54e61dd9a5..926549101d 100755 --- a/android/build.sh +++ b/android/build.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Keyman is copyright (C) SIL Global. MIT License. # # Build Keyman Engine for Android, Keyman for Android, OEM FirstVoices Android app, # Samples: KMsample1 and KMSample2, Test - KeyboardHarness @@ -50,11 +51,6 @@ builder_describe \ builder_parse "$@" -function do_clean() { - builder_heading "Cleanup /android/upload" - rm -rf "${KEYMAN_ROOT}/android/upload" -} - function do_test_help() { check-markdown "${KEYMAN_ROOT}/android/docs/help" check-markdown "${KEYMAN_ROOT}/android/docs/engine" @@ -62,7 +58,6 @@ function do_test_help() { function archive_artifacts() { local UPLOAD_PATH KEYMAN_ENGINE_ANDROID_ZIP KEYMAN_APK FIRSTVOICES_APK - local KEYMAN_FULLY_VERSIONED_APK FIRSTVOICES_FULLY_VERSIONED_APK UPLOAD_PATH="${KEYMAN_ROOT}/android/upload/${KEYMAN_VERSION}" KEYMAN_ENGINE_ANDROID_ZIP="keyman-engine-android-${KEYMAN_VERSION}.zip" @@ -124,12 +119,13 @@ android_set_java_home # This script also responsible for cleaning up /android/upload builder_run_child_actions clean -builder_run_action clean do_clean +builder_run_action clean rm -rf "${KEYMAN_ROOT}/android/upload" builder_run_child_actions configure build test -builder_run_action test:help do_test_help +builder_run_action test:help do_test_help +# TODO: merge publish-* and archive to publish action? builder_run_child_actions publish-symbols publish-play-store -builder_run_action archive archive_artifacts +builder_run_action archive archive_artifacts diff --git a/android/version.gradle b/android/version.gradle index ea7342166c..677f4926ef 100644 --- a/android/version.gradle +++ b/android/version.gradle @@ -88,6 +88,16 @@ def getVersionGitTag = { -> return "release@$KEYMAN_VERSION_NAME" } } + +def getVersionForFilename = { -> + String env_version_for_filename = System.getenv("KEYMAN_VERSION_FOR_FILENAME") + if(env_version_for_filename != null) { + return env_version_for_filename + } + // Building probably from IDE + return "$KEYMAN_VERSION_NAME" +} + ext { KEYMAN_VERSION_CODE=getVersionCode() KEYMAN_VERSION_MD=getVersionMD() @@ -95,6 +105,7 @@ ext { KEYMAN_VERSION_ENVIRONMENT=getVersionEnvironment() KEYMAN_VERSION_TAG=System.getenv("KEYMAN_VERSION_TAG") KEYMAN_VERSION_GIT_TAG=getVersionGitTag() + KEYMAN_VERSION_FOR_FILENAME=getVersionForFilename() } //println "version.gradle: KEYMAN_VERSION_TAG: " + KEYMAN_VERSION_TAG \ No newline at end of file diff --git a/oem/firstvoices/android/build.gradle b/oem/firstvoices/android/build.gradle index 3f20182cf6..f24c9cdc53 100644 --- a/oem/firstvoices/android/build.gradle +++ b/oem/firstvoices/android/build.gradle @@ -16,7 +16,6 @@ buildscript { allprojects { repositories { google() - jcenter() mavenCentral() } } diff --git a/resources/builder.inc.sh b/resources/builder.inc.sh index f316b5ade5..2b81b7f52f 100755 --- a/resources/builder.inc.sh +++ b/resources/builder.inc.sh @@ -1756,9 +1756,9 @@ _builder_parse_expanded_parameters() { fi if builder_is_debug_build; then - BUILDER_CONFIGURATION=debug + export BUILDER_CONFIGURATION=debug else - BUILDER_CONFIGURATION=release + export BUILDER_CONFIGURATION=release fi _builder_print_internal_debug_info From d295e0faf5bc41a78dc0846c8315e8ecd10cedb0 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 13 Jul 2026 16:47:48 +0200 Subject: [PATCH 130/148] maint(resources): add comment for debug printing Co-authored-by: Eberhard Beilharz --- resources/build/builder-basic.inc.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/build/builder-basic.inc.sh b/resources/build/builder-basic.inc.sh index 8e0954f5c5..c3108ede0e 100644 --- a/resources/build/builder-basic.inc.sh +++ b/resources/build/builder-basic.inc.sh @@ -182,6 +182,7 @@ function _builder_basic_print_version_utils_debug() { fi if [[ ${_builder_basic_environment_printed:-false} == true ]]; then + # report this only once per build return fi From 896387a15a536f95dad22e873d7a155de0065315 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 13 Jul 2026 18:21:35 +0200 Subject: [PATCH 131/148] chore(linux): remove questing, add stonking Ubuntu 25.10 Questing is EOL. Instead add upcoming Ubuntu 26.10 Stonking for Launchpad builds. Fixes: #16235 Build-bot: skip Test-bot: skip --- .github/workflows/deb-packaging.yml | 2 +- linux/scripts/launchpad.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 9a990634b2..6ce42cf6d5 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -137,7 +137,7 @@ jobs: strategy: fail-fast: true matrix: - dist: [jammy, noble, questing] + dist: [jammy, noble, resolute] steps: - name: Checkout diff --git a/linux/scripts/launchpad.sh b/linux/scripts/launchpad.sh index b516d1eb53..2789c9ec7b 100755 --- a/linux/scripts/launchpad.sh +++ b/linux/scripts/launchpad.sh @@ -98,7 +98,7 @@ function upload_with_retry() { return 1 } -distributions="${DIST:-jammy noble questing resolute}" +distributions="${DIST:-jammy noble resolute stonking}" packageversion="${PACKAGEVERSION:-1~sil1}" retries="${RETRIES:-5}" From b04e28a43824ecd48e8c4bd4215c46bea9cf72e8 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 13 Jul 2026 18:44:32 +0200 Subject: [PATCH 132/148] chore(android): log legacy cloud keyboards This adds a Sentry log if a user is still using legacy cloud keyboards. We do this so that we will learn if there are still users out there or if we can remove the code that deals with those keyboards. Test-bot: skip --- .../java/com/keyman/engine/data/Keyboard.java | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index 844959fb54..c0a275bbef 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -49,6 +49,9 @@ public class Keyboard extends LanguageResource implements Serializable { */ public Keyboard(JSONObject installedObj) { this.fromJSON(installedObj); + if (!FileUtils.hasFontExtension(this.font)) { + logLegacyKeyboard(this.font); + } } /** @@ -79,6 +82,10 @@ public class Keyboard extends LanguageResource implements Serializable { this.helpLink = keyboardJSON.optString(KMManager.KMKey_CustomHelpLink, KMString.format(HELP_URL_FORMATSTR, HELP_URL_HOST, this.resourceID, this.version)); + + if (!FileUtils.hasFontExtension(this.font)) { + logLegacyKeyboard(this.font); + } } catch (JSONException e) { KMLog.LogException(TAG, "Keyboard exception parsing JSON: ", e); } @@ -96,7 +103,11 @@ public class Keyboard extends LanguageResource implements Serializable { this.isNewKeyboard = isNewKeyboard; this.font = (font != null) ? font : ""; this.oskFont = (oskFont != null) ? oskFont : ""; - } + + if (!FileUtils.hasFontExtension(this.font)) { + logLegacyKeyboard(this.font); + } + } public Keyboard(Keyboard k) { super(k.getPackageID(), k.getKeyboardID(), k.getKeyboardName(), @@ -108,6 +119,23 @@ public class Keyboard extends LanguageResource implements Serializable { this.displayName = k.getDisplayName(); } + private void logLegacyKeyboard(String font) { + if (font == null || font.isEmpty()) { + return; + } + try { + // Create a Sentry log entry if there are still users out there that use + // legacy cloud keyboard. See KMKeyboard.makeFontObject(). + JSONObject fontObj = new JSONObject(font); + Object obj = fontObj.get(KMManager.KMKey_FontFiles); + if (obj instanceof String || obj instanceof JSONArray) { + KMLog.LogInfo(TAG, "Constructing legacy keyboard: " + this.packageID + "/" + this.resourceID); + } + } catch (JSONException e) { + // Not a JSON object, so it's not a legacy keyboard. + } + } + public String getKeyboardID() { return getResourceID(); } public String getKeyboardName() { return getResourceName(); } From 82ab6477e54b89851bd1f628449daabc8e5b380a Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 13 Jul 2026 19:15:10 +0200 Subject: [PATCH 133/148] chore(android): keep handling of legacy keyboards Reverts part of commit d0e16496304a877933cd78661e73fa17f9857df4 until we know if there are still users out there that are using legacy cloud keyboards. See #16237. Build-bot: skip build:android --- .../java/com/keyman/engine/KMKeyboard.java | 86 ++++++++++++++----- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index ca9edff90b..94c1aaa2ae 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -1036,39 +1036,81 @@ final class KMKeyboard extends WebView { } /** - * Create a JSON object consisting of the font family and the URL of the - * font file on the local device. + * Create a JSON object consisting of the font family and the URLs of the + * font files on the local device. * - * @param fontFileName A string containing the font filename (with an - * extension recognized as font) - * @param packageID The package ID of the keyboard + * The `font` parameter can either be the filename of the font (with an + * extension recognized as font), or a Font object or JSON string. + * In the former case a new JSON object is created with the font family + * derived from the filename, and the font filename prefixed with path + * to the fonts. + * In the latter case the legacy `sources` key is renamed to `files`. + * If `files` is a single string it will be prefixed with the path to the + * fonts. If `files` is an array, the array is iterated until finding + * the first file with a font extension which is then prefixed with the + * path to the fonts. * - * @return JSONObject of modified font information with full path. If fontFileName + * @param font A string containing either the font filename or a font + * JSON object as a string + * @param packageID The package ID of the keyboard + * + * @return JSONObject of modified font information with full paths. If font * is invalid, return `null`. */ - private JSONObject makeFontObject(String fontFileName, String packageID) { + private JSONObject makeFontObject(String font, String packageID) { - if(fontFileName == null || fontFileName.equals("")) { - return null; - } - - if (!FileUtils.hasFontExtension(fontFileName)) { - KMLog.LogInfo(TAG, "makeFontObject: Got font without font extension: " + fontFileName); + if(font == null || font.equals("")) { return null; } try { - JSONObject font = new JSONObject(); - font.put(KMManager.KMKey_FontFamily, fontFileName.substring(0, fontFileName.length()-4)); - JSONArray files = new JSONArray(); - String fontRoot = KMManager.isDefaultFont(fontFileName) ? getDataRootUrl() : getPackageRootUrl(packageID); - files.put(fontRoot + fontFileName); - font.put(KMManager.KMKey_FontFiles, files); - return font; + if (FileUtils.hasFontExtension(font)) { + JSONObject jfont = new JSONObject(); + jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length()-4)); + JSONArray jfiles = new JSONArray(); + String fontRoot = KMManager.isDefaultFont(font) ? getDataRootUrl() : getPackageRootUrl(packageID); + jfiles.put(fontRoot + font); + jfont.put(KMManager.KMKey_FontFiles, jfiles); + return jfont; + } + + // REVIEW: Why do we need the complicated code below? Can this still + // happen, or can we remove it? (see also getFontFilename) + KMLog.LogInfo(TAG, "Got font without font extension: " + font); + + JSONObject fontObj = new JSONObject(font); + + // Replace "sources" key with "files" + if (fontObj.has(KMManager.KMKey_FontSource)) { + fontObj.put(KMManager.KMKey_FontFiles, fontObj.get(KMManager.KMKey_FontSource)); + fontObj.remove(KMManager.KMKey_FontSource); + } + + Object obj = fontObj.get(KMManager.KMKey_FontFiles); + if (obj instanceof String) { + String fontFile = fontObj.getString(KMManager.KMKey_FontFiles); + String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRootUrl() : getPackageRootUrl(packageID); + fontObj.put(KMManager.KMKey_FontFiles, fontRoot + obj); + return fontObj; + } else if (obj instanceof JSONArray) { + JSONArray sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); + if (sourceArray != null) { + for (int i = 0; i < sourceArray.length(); i++) { + String fontFile = sourceArray.getString(i); + if (FileUtils.hasFontExtension(fontFile)) { + String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRootUrl() : getPackageRootUrl(packageID); + fontObj.put(KMManager.KMKey_FontFiles, fontRoot + fontFile); + fontObj.remove(KMManager.KMKey_FontSource); + return fontObj; + } + } + } + } } catch (JSONException e) { - KMLog.LogException(TAG, "Failed to make font for '"+fontFileName+"'", e); - return null; + KMLog.LogException(TAG, "Failed to make font for '"+font+"'", e); } + + return null; } protected void showHelpBubble() { From 88731e51fc6df7ffcff5d7334b737690d90322c6 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 13 Jul 2026 19:29:21 +0200 Subject: [PATCH 134/148] chore(android): more cleanup --- .../main/java/com/keyman/engine/KMKeyboard.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 94c1aaa2ae..af948f1a45 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -926,22 +926,22 @@ final class KMKeyboard extends WebView { /** * Return the full path to the font file. If the font is invalid, return empty string. - * @param font String - Font filename + * @param fontFilename String - Font filename * @param packageID String - Package ID - * @return String - Full local path to the font file. If font is invalid, return "". + * @return String - Full path to the font file. If fontFilename is invalid, return "". */ - private String getFontFilename(String font, String packageID) { - if(font == null || font.equals("")) { + private String getFontFilename(String fontFilename, String packageID) { + if(fontFilename == null || fontFilename.equals("")) { return ""; } - if (!FileUtils.hasFontExtension(font)) { + if (!FileUtils.hasFontExtension(fontFilename)) { // QUESTION: do we log this? return ""; } - String fontRoot = KMManager.isDefaultFont(font) ? getDataRootPath() : getPackageRootPath(packageID); - return fontRoot + font; + String fontRoot = KMManager.isDefaultFont(fontFilename) ? getDataRootPath() : getPackageRootPath(packageID); + return fontRoot + fontFilename; } @SuppressLint("InflateParams") From 1174752071fc1d6718fa8b37abfe6f331f9e8093 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 13 Jul 2026 13:01:55 -0500 Subject: [PATCH 135/148] auto: increment master version to 19.0.257 Test-bot: skip Build-bot: skip --- HISTORY.md | 5 +++++ VERSION.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 82497b52a9..0ef4503a94 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,10 @@ # Keyman Version History +## 19.0.256 alpha 2026-07-13 + +* fix(android): add permissions for Sentry and set default keyboard (#16216) +* maint(resources): add extra debug reporting for builder (#16230) + ## 19.0.255 alpha 2026-07-11 * maint(android): use KEYMAN_TIER instead of TIER.md (#16219) diff --git a/VERSION.md b/VERSION.md index 3b84a53908..83562dee3b 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.256 \ No newline at end of file +19.0.257 \ No newline at end of file From 9bdfe0eaa9cea0bc09b0733e4f427a56ebe0843f Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 14 Jul 2026 16:48:46 +0200 Subject: [PATCH 136/148] chore(android): address code review comments Legacy cloud installed keyboards don't have a package, so we can check for `KMDefault_UndefinedPackageID` to identify them. --- .../java/com/keyman/engine/data/Keyboard.java | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index c0a275bbef..896fb23e96 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -49,9 +49,7 @@ public class Keyboard extends LanguageResource implements Serializable { */ public Keyboard(JSONObject installedObj) { this.fromJSON(installedObj); - if (!FileUtils.hasFontExtension(this.font)) { - logLegacyKeyboard(this.font); - } + logIfLegacyKeyboard(); } /** @@ -83,9 +81,7 @@ public class Keyboard extends LanguageResource implements Serializable { this.helpLink = keyboardJSON.optString(KMManager.KMKey_CustomHelpLink, KMString.format(HELP_URL_FORMATSTR, HELP_URL_HOST, this.resourceID, this.version)); - if (!FileUtils.hasFontExtension(this.font)) { - logLegacyKeyboard(this.font); - } + logIfLegacyKeyboard(); } catch (JSONException e) { KMLog.LogException(TAG, "Keyboard exception parsing JSON: ", e); } @@ -104,10 +100,8 @@ public class Keyboard extends LanguageResource implements Serializable { this.font = (font != null) ? font : ""; this.oskFont = (oskFont != null) ? oskFont : ""; - if (!FileUtils.hasFontExtension(this.font)) { - logLegacyKeyboard(this.font); - } - } + logIfLegacyKeyboard(); + } public Keyboard(Keyboard k) { super(k.getPackageID(), k.getKeyboardID(), k.getKeyboardName(), @@ -119,20 +113,9 @@ public class Keyboard extends LanguageResource implements Serializable { this.displayName = k.getDisplayName(); } - private void logLegacyKeyboard(String font) { - if (font == null || font.isEmpty()) { - return; - } - try { - // Create a Sentry log entry if there are still users out there that use - // legacy cloud keyboard. See KMKeyboard.makeFontObject(). - JSONObject fontObj = new JSONObject(font); - Object obj = fontObj.get(KMManager.KMKey_FontFiles); - if (obj instanceof String || obj instanceof JSONArray) { - KMLog.LogInfo(TAG, "Constructing legacy keyboard: " + this.packageID + "/" + this.resourceID); - } - } catch (JSONException e) { - // Not a JSON object, so it's not a legacy keyboard. + private void logIfLegacyKeyboard() { + if (this.packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { + KMLog.LogInfo(TAG, "Constructing legacy keyboard: " + this.resourceID); } } From a93467a3a01e31a3225ac7fc9c924e217a966a51 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 14 Jul 2026 18:23:38 +0200 Subject: [PATCH 137/148] chore(android): remove doc for non-existing deprecated functions Several functions got deprecated and replaced with a function with same name and parameters but different return type. The original function no longer exists. This change removes the documentation for those no longer existing deprecated functions. Also update the documentation for two deprecated functions to show what should be used instead. Build-bot: skip Test-bot: skip --- android/docs/engine/KMManager/addKeyboard.md | 33 ------------------- .../KMManager/getCurrentKeyboardInfo.md | 28 ---------------- .../KMManager/getKeyboardFontFilename.md | 3 ++ .../KMManager/getKeyboardFontTypeface.md | 3 ++ .../docs/engine/KMManager/getKeyboardInfo.md | 29 ---------------- .../docs/engine/KMManager/getKeyboardsList.md | 27 --------------- 6 files changed, 6 insertions(+), 117 deletions(-) diff --git a/android/docs/engine/KMManager/addKeyboard.md b/android/docs/engine/KMManager/addKeyboard.md index f03048da47..7cbb355b7f 100644 --- a/android/docs/engine/KMManager/addKeyboard.md +++ b/android/docs/engine/KMManager/addKeyboard.md @@ -32,39 +32,6 @@ can be selected from the keyboards menu. If the keyboard with same keyboard ID and language ID exists, it updates the existing keyboard info. - - ------------------------------------------------------------------------- - -## Syntax (Deprecated) - -``` javascript -KMManager.addKeyboard(Context context, HashMap keyboardInfo) -``` - -### Parameters - -`context` -: The context. - -`keyboardInfo` -: A dictionary of keyboard information with keys and values defined as - `HashMap`. - -### Returns - -Returns `true` if the keyboard was added successfully, `false` -otherwise. - -## Description - -Use this method to include a keyboard in the keyboards list so that it -can be selected from the keyboards menu. If the keyboard with same -keyboard ID and language ID exists, it updates the existing keyboard -info. - - - ------------------------------------------------------------------------ ## Examples diff --git a/android/docs/engine/KMManager/getCurrentKeyboardInfo.md b/android/docs/engine/KMManager/getCurrentKeyboardInfo.md index 4eb789e736..9aceb3653b 100644 --- a/android/docs/engine/KMManager/getCurrentKeyboardInfo.md +++ b/android/docs/engine/KMManager/getCurrentKeyboardInfo.md @@ -28,34 +28,6 @@ Use this method to get details of the currently selected keyboard. Details include package ID, keyboard ID, language ID, keyboard name, language name and fonts. - - ------------------------------------------------------------------------- - -## Syntax (Deprecated) - -``` javascript -KMManager.getCurrentKeyboardInfo(Context context) -``` - -### Parameters - -`context` -: The context. - -### Returns - -(Deprecated) Returns an information dictionary of the current keyboard -with keys and values defined as `HashMap`. - -## Description - -Use this method to get details of the currently selected keyboard. -Details include keyboard ID, language ID, keyboard name, language name -and fonts. - - - ------------------------------------------------------------------------ ## Examples diff --git a/android/docs/engine/KMManager/getKeyboardFontFilename.md b/android/docs/engine/KMManager/getKeyboardFontFilename.md index 086922224d..005e6264a3 100644 --- a/android/docs/engine/KMManager/getKeyboardFontFilename.md +++ b/android/docs/engine/KMManager/getKeyboardFontFilename.md @@ -22,6 +22,8 @@ empty string otherwise. Use this method to get the font filename of the selected keyboard. +Deprecated. Use `getKeyboardTextFontFilename()` instead. + ## Examples ### Example: Using `getKeyboardFontFilename()` @@ -34,5 +36,6 @@ The following script illustrate the use of `getKeyboardFontFilename()`: ## See also +- [`getKeyboardTextFontFilename()`](getKeyboardTextFontFilename) - [`getKeyboardFontTypeface()` (Deprecated)](getKeyboardFontTypeface) - [`getFontTypeface()`](getFontTypeface) diff --git a/android/docs/engine/KMManager/getKeyboardFontTypeface.md b/android/docs/engine/KMManager/getKeyboardFontTypeface.md index 1f414ce14f..a2858141e0 100644 --- a/android/docs/engine/KMManager/getKeyboardFontTypeface.md +++ b/android/docs/engine/KMManager/getKeyboardFontTypeface.md @@ -28,6 +28,8 @@ exists, `null` otherwise. Use this method to create a new typeface from the selected keyboard's font if it has any. +Deprecated. Use `getKeyboardTextFontFilename()` instead. + ## Examples ### Example: Using `getKeyboardFontTypeface()` @@ -44,3 +46,4 @@ The following script illustrate the use of `getKeyboardFontTypeface()`: - [`getFontTypeface()`](getFontTypeface) - [`getKeyboardFontFilename()` (Deprecated)](getKeyboardFontFilename) +- [`getKeyboardTextFontFilename()`](getKeyboardTextFontFilename) diff --git a/android/docs/engine/KMManager/getKeyboardInfo.md b/android/docs/engine/KMManager/getKeyboardInfo.md index 5838e0ca23..859c4b3bb7 100644 --- a/android/docs/engine/KMManager/getKeyboardInfo.md +++ b/android/docs/engine/KMManager/getKeyboardInfo.md @@ -31,35 +31,6 @@ Use this method to get details of the keyboard at given position in keyboards list. Details include keyboard ID, language ID, keyboard name, language name and fonts. - - ------------------------------------------------------------------------- - -## Syntax (Deprecated) - -``` javascript -KMManager.getKeyboardInfo(Context context, int index) -``` - -### Parameters - -`context` -: The context. - -`index` -: 0-based position of the keyboard in keyboards list. - -### Returns - -(Deprecated) Returns an information dictionary of the specified keyboard -with keys and values defined as `HashMap`. - -## Description - -Use this method to get details of the keyboard at given position in -keyboards list. Details include keyboard ID, language ID, keyboard name, -language name and fonts. - ## Examples ### Example: Using `getKeyboardInfo()` diff --git a/android/docs/engine/KMManager/getKeyboardsList.md b/android/docs/engine/KMManager/getKeyboardsList.md index 26c520953b..4f38761665 100644 --- a/android/docs/engine/KMManager/getKeyboardsList.md +++ b/android/docs/engine/KMManager/getKeyboardsList.md @@ -26,33 +26,6 @@ otherwise. Use this method to get details of all keyboard's in keyboards menu. - - ------------------------------------------------------------------------- - -## Syntax (Deprecated) - -``` javascript -KMManager.getKeyboardsList(Context context) -``` - -### Parameters - -`context` -: The context. - -### Returns - -(Deprecated) Returns keyboards list as -`ArrayList>` if it exists, `null` -otherwise. - -## Description - -Use this method to get details of all keyboard's in keyboards menu. - - - ------------------------------------------------------------------------ ## Examples From b0a4b441639d20c635d99ba0ba569b087d93757f Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 14 Jul 2026 13:02:40 -0500 Subject: [PATCH 138/148] auto: increment master version to 19.0.258 Test-bot: skip Build-bot: skip --- HISTORY.md | 9 +++++++++ VERSION.md | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 0ef4503a94..01941648ba 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,14 @@ # Keyman Version History +## 19.0.257 alpha 2026-07-14 + +* chore(linux): remove questing, add stonking (#16236) +* chore(android): cleanup Android build scripts and artifact filenames (#16234) +* chore(web): declare scope for stubAndKeyboardCache members (#16221) +* fix(android): improve clarity of keyboard script error popup (#16228) +* chore(android): log legacy cloud keyboards (#16237) +* chore(android): cleanup font variable names, remove obsolete code (#16211) + ## 19.0.256 alpha 2026-07-13 * fix(android): add permissions for Sentry and set default keyboard (#16216) diff --git a/VERSION.md b/VERSION.md index 83562dee3b..24c70fd57c 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.257 \ No newline at end of file +19.0.258 \ No newline at end of file From 043e1cadd5a3a74115a22dcdab94b59d0c1a90f5 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Wed, 15 Jul 2026 13:01:29 -0500 Subject: [PATCH 139/148] auto: increment master version to 19.0.259 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 01941648ba..399e3077f2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.258 alpha 2026-07-15 + +* chore(android): remove doc for non-existing deprecated functions (#16244) + ## 19.0.257 alpha 2026-07-14 * chore(linux): remove questing, add stonking (#16236) diff --git a/VERSION.md b/VERSION.md index 24c70fd57c..48c4023fd8 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.258 \ No newline at end of file +19.0.259 \ No newline at end of file From 13af7821c3ec3a1742ffd2901b58ec938bbd4467 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 20 Jul 2026 13:17:05 -0500 Subject: [PATCH 140/148] fix(web): prevent backspace from clustering with output keys --- .../src/main/correction/tokenization-subsets.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-subsets.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-subsets.ts index bfbadef182..a8617a7c0f 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-subsets.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/tokenization-subsets.ts @@ -135,7 +135,8 @@ export function legacySubsetKeyer(tokenizationEdits: TokenizationTransitionEdits // Now, based on the transform tokenization. We want to force uniqueness for // all variations of result length on each tokenized transform resulting from // the precomputation's represented keystroke. - for(const {0: relativeIndex} of tokenizedTransform.entries()) { + for(const {0: relativeIndex, 1: transform} of tokenizedTransform.entries()) { + const insertLen = KMWString.length(transform.insert); if(relativeIndex > 0) { // The true boundary lie before the insert if the value is non-zero; // don't differentiate here! @@ -148,10 +149,10 @@ export function legacySubsetKeyer(tokenizationEdits: TokenizationTransitionEdits // // IMPORTANT: update unit tests manually if the BI marker here changes // or the use of SENTINEL_CODE_UNIT as a key component separator changes. - components.push(`BI@${relativeIndex}`); + components.push(`BI@${relativeIndex}-${boundaryTextLen + insertLen}`); boundaryTextLen = 0; } else { - components.push(`I@${relativeIndex}`); + components.push(`I@${relativeIndex}-${boundaryTextLen + insertLen}`); } } From 5a1e23d72711275122b5d303bccd83dd1978b84b Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 21 Jul 2026 13:39:11 -0500 Subject: [PATCH 141/148] fix(web): handle prediction whitespace-input, backspace-input edge cases better Fixes: #16271 Fixes: #16272 Build-bot: skip release:web,android,ios --- .../src/main/correction/context-tokenization.ts | 13 ++++++++++++- .../worker-thread/src/main/predict-helpers.ts | 14 +++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts index 52c365dd7e..4ccd8cb86d 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts @@ -708,8 +708,19 @@ export class ContextTokenization { affectedToken = null; } + // Backspace handling - emptying context via backspace or erasing _part_ of + // a whitespace token can erase the tokenization-final empty token usually + // used for word-initial suggestions. + // + // We re-add it here so that suggestions can be presented to the user as + // normal. + const tokenSequence = this.tokens.slice(0, sliceIndex).concat(tailTokenization); + if(tokenSequence.length == 0 || tokenSequence[tokenSequence.length - 1]?.isWhitespace) { + tokenSequence.push(new ContextToken(new LegacyQuotientRoot(lexicalModel))); + } + return new ContextTokenization( - this.tokens.slice(0, sliceIndex).concat(tailTokenization), + tokenSequence, null, determineTaillessTrueKeystroke(transitionEdge) ); diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index ac0de6c503..665db610ef 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -578,11 +578,6 @@ export async function correctAndEnumerate( // Corrections obtained: now to predict from them! const tokenization = tokenizations.find(t => t.spaceId == match.spaceId); - // If our 'match' results in fully deleting the new token, reject it and try again. - if(match.matchSequence.length == 0 && match.inputSequence.length != 0) { - continue; - } - // If our 'match' fully replaces the token, reject it and try again. if(match.matchSequence.length != 0 && match.matchSequence.length == match.knownCost) { continue; @@ -609,6 +604,12 @@ export async function correctAndEnumerate( const predictions = buildAndMapPredictions(transition, tokenization, match, costFactor); + // Backspaces that cause the whole context to become empty do not reflect the backspace transform + // within the search space. We correct for that here. + if(tokenization.tail.searchModule.codepointLength == 0 && inputTransform.deleteLeft > 0) { + predictions.forEach((p) => p.prediction.sample.transform.deleteLeft += inputTransform.deleteLeft); + } + // Only set 'best correction' cost when a correction ACTUALLY YIELDS predictions. if(predictions.length > 0 && bestCorrectionCost === undefined) { bestCorrectionCost = match.totalCost * costFactor; @@ -1077,6 +1078,9 @@ export function finalizeSuggestions( if(presDL > 0) { mergedTransform.deleteLeft -= presDL; } + if(prediction.sample.transform.id !== undefined) { + mergedTransform.id = prediction.sample.transform.id; + } // Temporarily and locally drops 'readonly' semantics so that we can reassign the transform. // See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#improved-control-over-mapped-type-modifiers From b9d0201d92c1fc4b8d948e2c805527ddb2ebc735 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 14 Apr 2026 14:42:12 -0500 Subject: [PATCH 142/148] feat(web): add prepareTokenizationSearch helper method This method is designed to determine the appropriate range of tokens, within each context variant, should be eligible for correction when generating predictions and corrections. Build-bot: skip build:web Test-bot: skip --- .../worker-thread/src/main/predict-helpers.ts | 76 +++- .../prepare-tokenization-search.tests.ts | 329 ++++++++++++++++++ 2 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepare-tokenization-search.tests.ts diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 41bc99acf5..c784b5f41d 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -5,7 +5,7 @@ import { searchForProperty, WordBreakProperty } from '@keymanapp/models-wordbrea import { TransformUtils } from './transformUtils.js'; import { detectCurrentCasing, determineModelTokenizer, determineModelWordbreaker, determinePunctuationFromModel } from './model-helpers.js'; -import { ContextTokenLike } from './correction/context-token.js'; +import { ContextToken, ContextTokenLike } from './correction/context-token.js'; import { ContextTokenization } from './correction/context-tokenization.js'; import { ContextTracker } from './correction/context-tracker.js'; import { ContextState, determineContextSlideTransform } from './correction/context-state.js'; @@ -603,6 +603,80 @@ export function determineTokenizedCorrectionSequence( }; } +/** + * Given the base 'display' tokenization and an array of target tokenizations + * for a context transition, this method determines the range needed for + * correction-search processes and builds the appropriate + * `TokenizationCorrector` instances for the search. + * @param transition + * @param tokenizations + * @param configuration Allows custom configuration for selecting correctable + * tokens within each TokenizationCorrector. Intended for use with unit + * testing. + * @returns + */ +export function prepareTokenizationSearch( + transition: ContextTransition, + tokenizations: ContextTokenization[], + configuration?: { + /** + * Should return true if the input index is within the appropriate range for + * correction. When called, the index of the first token in correction + * range will be provided to `rangeStart`. + * @param index + * @param rangeStart + */ + rangeValidator?: (index: number, rangeStart: number) => boolean, + /** + * Should return true if the token represents text valid for text correction + * processes, regardless of position. + * @param token + * @returns + */ + correctableValidator?: (token: ContextToken) => boolean + } +) { + // Goal - determine what parts of each tokenization are searchable & prep them for correcion-search. + const tokenizationAnalyses = tokenizations.map((tokenization) => { + return { + tokenization: tokenization, + analysis: determineSuggestionRange(transition.base.displayTokenization.tokens, tokenization.tokens, (a, b) => a.spaceId == b.spaceId) + }; + }); + + const biggestCommonRemoval = tokenizationAnalyses.reduce( + (biggest, current) => biggest.length > current.analysis.tokensToRemove.length ? biggest : current.analysis.tokensToRemove, + [] as ContextTokenLike[] + ); + + configuration ??= {}; + // If a custom range validator is set, just use that one. + const rangeValidator = configuration.rangeValidator; + + configuration.correctableValidator ??= (token) => (token.codepointLength == 0 || correctionValidForAutoSelect(token.exampleInput)); + const tokenizationSetup = tokenizationAnalyses.map((tuple) => { + // These tokens are unaffected by the input whatsoever, though their + // probability may affect thresholding for the non-locked tokens. + const unaffectedTokenCount = biggestCommonRemoval.length - tuple.analysis.tokensToRemove.length; + // Unaffected tokens should still be part of the correction range; they'll just + // be marked noncorrectable. + const mutatedLength = tuple.analysis.tokensToPredict.length + unaffectedTokenCount; + + configuration.rangeValidator = rangeValidator ?? ((index, rangeStart) => { + return index >= rangeStart // is a modified token + && index == mutatedLength - 1 // TEMP: adjacent to the caret (TO BE REMOVED) + }); + return new TokenizationCorrector(tuple.tokenization, mutatedLength, (token, index) => { + // is within range for correction + return configuration.rangeValidator(index, unaffectedTokenCount) + // and is eligible for text-correction + && configuration.correctableValidator(token); + }); + }); + + return tokenizationSetup; +} + /** * This method performs the correction-search and model-lookup operations for * prediction generation by using the user's context state and potential diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepare-tokenization-search.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepare-tokenization-search.tests.ts new file mode 100644 index 0000000000..67ea7cf5bd --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepare-tokenization-search.tests.ts @@ -0,0 +1,329 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-07-21 + * + * This file unit tests against the `prepareTokenizationSearch` + * prediction-helper function, which uses results from + * `determineSuggestionRange` to build phrase-level correctors for multi-token + * correction. + */ + +import { assert } from 'chai'; + +import { LexicalModelTypes } from '@keymanapp/common-types'; +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; + +import { ContextState, ContextTokenization, ContextTransition, models, prepareTokenizationSearch, ContextToken, LegacyQuotientRoot, LegacyQuotientSpur } from "@keymanapp/lm-worker/test-index"; + +import Context = LexicalModelTypes.Context; +import Distribution = LexicalModelTypes.Distribution; +import Transform = LexicalModelTypes.Transform; +import TrieModel = models.TrieModel; + +const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); + +describe.only('prepareTokenizationSearch', () => { + it('handles simple-case, single tokenization transitions well', () => { + const baseContext: Context = { + left: '', + startOfBuffer: true, + endOfBuffer: true + }; + + const transition = new ContextTransition(new ContextState(baseContext, testModel), 0); + + const targetContext: Context = { + left: 'a', + startOfBuffer: true, + endOfBuffer: true + }; + + const targetTokenization = new ContextTokenization([ContextToken.fromRawText(testModel, 'a')]); + + const nextState = new ContextState(targetContext, testModel, targetTokenization, [targetTokenization]); + + transition.finalize(nextState, [ + { sample: { insert: 'a', deleteLeft: 0, id: 1}, p: 1 } + ]); + + const correctorsForSearch = prepareTokenizationSearch(transition, [targetTokenization]); + assert.equal(correctorsForSearch.length, 1); + + const corrector = correctorsForSearch[0]; + assert.deepEqual(corrector.orderedTokens, targetTokenization.tokens); + assert.deepEqual(corrector.correctableTokens, []); + assert.deepEqual(corrector.uncorrectableTokens, []); + assert.deepEqual(corrector.predictableToken, targetTokenization.tail); + + assert.equal(corrector.correctableCodepoints, 1); + assert.isTrue(corrector.modelsCorrectables) + assert.equal(corrector.tokenization, targetTokenization); + }); + + it('handles single-tokenization transitions from whitespace well', () => { + const baseContext: Context = { + left: 'space', + startOfBuffer: true, + endOfBuffer: true + }; + + const transition = new ContextTransition(new ContextState(baseContext, testModel), 0); + + const targetContext: Context = { + left: 'space ', + startOfBuffer: true, + endOfBuffer: true + }; + + const targetTokenization = new ContextTokenization([ + transition.base.displayTokenization.tail, + ContextToken.fromRawText(testModel, ' '), + ContextToken.fromRawText(testModel, '', true) + ]); + + const nextState = new ContextState(targetContext, testModel, targetTokenization, [targetTokenization]); + + transition.finalize(nextState, [ + { sample: { insert: ' ', deleteLeft: 0, id: 1}, p: 1 } + ]); + + const correctorsForSearch = prepareTokenizationSearch(transition, [targetTokenization]); + assert.equal(correctorsForSearch.length, 1); + + const corrector = correctorsForSearch[0]; + assert.deepEqual(corrector.orderedTokens, targetTokenization.tokens.slice(1)); + assert.deepEqual(corrector.correctableTokens, []); + assert.deepEqual(corrector.uncorrectableTokens, [targetTokenization.tokens[1]]); + assert.deepEqual(corrector.predictableToken, targetTokenization.tail); + + assert.equal(corrector.correctableCodepoints, 0); + assert.isTrue(corrector.modelsCorrectables) + assert.equal(corrector.tokenization, targetTokenization); + }); + + it('handles divergent transitions with possible wordbreaks', () => { + const baseContext: Context = { + left: 'space', + startOfBuffer: true, + endOfBuffer: true + }; + + const transition = new ContextTransition(new ContextState(baseContext, testModel), 0); + + const targetContext: Context = { + left: 'spaced', + startOfBuffer: true, + endOfBuffer: true + }; + + const distribution: Distribution = [ + { sample: { insert: 'd', deleteLeft: 0, id: 1}, p: .8 }, + { sample: { insert: ' ', deleteLeft: 0, id: 1}, p: .2 } + ]; + + + const targetTokenization1 = new ContextTokenization([ + new ContextToken(new LegacyQuotientSpur(transition.base.displayTokenization.tail.searchModule, [distribution[0]], distribution[0])) + ]); + + const targetTokenization2 = new ContextTokenization([ + transition.base.displayTokenization.tail, + new ContextToken(new LegacyQuotientSpur(new LegacyQuotientRoot(testModel), [distribution[1]], distribution[0])), + ContextToken.fromRawText(testModel, '', true) + ]); + + const tokenizations = [targetTokenization1, targetTokenization2]; + + const nextState = new ContextState(targetContext, testModel, targetTokenization1, tokenizations); + + transition.finalize(nextState, distribution); + + const correctorsForSearch = prepareTokenizationSearch(transition, tokenizations); + assert.equal(correctorsForSearch.length, 2); + + const corrector1 = correctorsForSearch[0]; + assert.deepEqual(corrector1.orderedTokens, targetTokenization1.tokens); + assert.deepEqual(corrector1.correctableTokens, []); + assert.deepEqual(corrector1.uncorrectableTokens, []); + assert.deepEqual(corrector1.predictableToken, targetTokenization1.tail); + + assert.equal(corrector1.correctableCodepoints, 6); + assert.isTrue(corrector1.modelsCorrectables) + assert.equal(corrector1.tokenization, targetTokenization1); + + const corrector2 = correctorsForSearch[1]; + assert.deepEqual(corrector2.orderedTokens, targetTokenization2.tokens); + assert.deepEqual(corrector2.correctableTokens, []); + assert.deepEqual(corrector2.uncorrectableTokens, targetTokenization2.tokens.slice(0, 2)); + assert.deepEqual(corrector2.predictableToken, targetTokenization2.tail); + + assert.equal(corrector2.correctableCodepoints, 0); + assert.isTrue(corrector2.modelsCorrectables) + assert.equal(corrector2.tokenization, targetTokenization2); + }); + + it('handles simple-case, backspace tokenization transitions well', () => { + const baseContext: Context = { + left: 'apples', + startOfBuffer: true, + endOfBuffer: true + }; + + const transition = new ContextTransition(new ContextState(baseContext, testModel), 0); + + const targetContext: Context = { + left: 'apple', + startOfBuffer: true, + endOfBuffer: true + }; + + const targetTokenization = new ContextTokenization([ContextToken.fromRawText(testModel, 'apple')]); + + const nextState = new ContextState(targetContext, testModel, targetTokenization, [targetTokenization]); + + transition.finalize(nextState, [ + { sample: { insert: '', deleteLeft: 1, id: 1}, p: 1 } + ]); + + const correctorsForSearch = prepareTokenizationSearch(transition, [targetTokenization]); + assert.equal(correctorsForSearch.length, 1); + + const corrector = correctorsForSearch[0]; + assert.deepEqual(corrector.orderedTokens, targetTokenization.tokens); + assert.deepEqual(corrector.correctableTokens, []); + assert.deepEqual(corrector.uncorrectableTokens, []); + assert.deepEqual(corrector.predictableToken, targetTokenization.tail); + + assert.equal(corrector.correctableCodepoints, 5); + assert.isTrue(corrector.modelsCorrectables) + assert.equal(corrector.tokenization, targetTokenization); + }); + + it('handles whitespace-token deletion transitions well', () => { + const baseContext: Context = { + left: 'apples ', + startOfBuffer: true, + endOfBuffer: true + }; + + const transition = new ContextTransition(new ContextState(baseContext, testModel), 0); + + const targetContext: Context = { + left: 'apples', + startOfBuffer: true, + endOfBuffer: true + }; + + const targetTokenization = new ContextTokenization([transition.base.displayTokenization.tokens[0]]); + + const nextState = new ContextState(targetContext, testModel, targetTokenization, [targetTokenization]); + + transition.finalize(nextState, [ + { sample: { insert: '', deleteLeft: 1, id: 1}, p: 1 } + ]); + + const correctorsForSearch = prepareTokenizationSearch(transition, [targetTokenization]); + assert.equal(correctorsForSearch.length, 1); + + const corrector = correctorsForSearch[0]; + assert.deepEqual(corrector.orderedTokens, targetTokenization.tokens); + assert.deepEqual(corrector.correctableTokens, []); + assert.deepEqual(corrector.uncorrectableTokens, []); + assert.deepEqual(corrector.predictableToken, targetTokenization.tail); + + assert.equal(corrector.correctableCodepoints, 6); + assert.isTrue(corrector.modelsCorrectables) + assert.equal(corrector.tokenization, targetTokenization); + }); + + // TODO: a more complex transition set. + it('handles complicated dictionary-style wordbreaking transitions', () => { + const baseContext: Context = { + left: 'myapplesandsour', + startOfBuffer: true, + endOfBuffer: true + }; + + // To be safe, make sure the spaces are constructed in proper left-to-right order, + // no matter the variation. + const my = ContextToken.fromRawText(testModel, 'my') + const apples = ContextToken.fromRawText(testModel, 'apples'); + const apple = ContextToken.fromRawText(testModel, 'apple'); + + const and = ContextToken.fromRawText(testModel, 'and'); + const sand = ContextToken.fromRawText(testModel, 'sand'); + const sands = ContextToken.fromRawText(testModel, 'sands'); + + const sour = ContextToken.fromRawText(testModel, 'sour'); + const our = ContextToken.fromRawText(testModel, 'our'); + + const baseTokenization = new ContextTokenization(([my, apples, and, sour])); + + const baseTokenizationVariants: ContextTokenization[] = [ + new ContextTokenization(([my, apple, sand, sour])), + new ContextTokenization(([my, apple, sands, our])) + ] + const transition = new ContextTransition(new ContextState(baseContext, testModel, baseTokenization, baseTokenizationVariants), 0); + + // ---- + const targetContext: Context = { + left: 'myapplesandsourg', + startOfBuffer: true, + endOfBuffer: true + }; + + const distribution: Distribution = [ + { sample: { insert: 'g', deleteLeft: 0, id: 1}, p: 1 } + ]; + + const g = () => new ContextToken(new LegacyQuotientSpur(new LegacyQuotientRoot(testModel), distribution, distribution[0])); + const ourg = () => new ContextToken(new LegacyQuotientSpur(our.searchModule, distribution, distribution[0])); + const sourg = () => new ContextToken(new LegacyQuotientSpur(sour.searchModule, distribution, distribution[0])); + + const targetTokenization = new ContextTokenization([my, apples, and, sour, g()]); + + const targetTokenizationVariants: ContextTokenization[] = [ + new ContextTokenization([my, apples, and, sourg()]), + new ContextTokenization([my, apple, sand, sour, g()]), + new ContextTokenization([my, apple, sand, sourg()]), + new ContextTokenization([my, apple, sands, our, g()]), + new ContextTokenization([my, apple, sands, ourg()]) + ]; + + const nextState = new ContextState(targetContext, testModel, targetTokenization, [targetTokenization, ...targetTokenizationVariants]); + transition.finalize(nextState, distribution); + + const variationStartIndex: Map = new Map(); + for(let variant of nextState.tokenizations) { + let i = 0; + while(variant.tokens[i]?.exampleInput == baseTokenization.tokens[i]?.exampleInput) { + i++; + } + variationStartIndex.set(variant, i); + } + + const correctorsForSearch = prepareTokenizationSearch(transition, nextState.tokenizations, { + rangeValidator: (index, rs) => index >= rs + }); + assert.equal(correctorsForSearch.length, nextState.tokenizations.length); + + correctorsForSearch.forEach((corrector, index) => { + const tokenization = nextState.tokenizations.find((t) => corrector.tokenization == t); + assert.isOk(tokenization); + + assert.deepEqual(corrector.orderedTokens, tokenization.tokens.slice(1), `Error for variant at index ${index}`); + const correctables = tokenization.tokens.slice(variationStartIndex.get(tokenization), -1) + assert.deepEqual(corrector.correctableTokens, correctables, `Error for variant at index ${index}`); + assert.deepEqual(corrector.uncorrectableTokens, tokenization.tokens.slice(1, variationStartIndex.get(tokenization)), `Error for variant at index ${index}`); + assert.deepEqual(corrector.predictableToken, tokenization.tail, `Error for variant at index ${index}`); + + assert.equal( + corrector.correctableCodepoints, + correctables.reduce((accum, curr) => accum + curr.codepointLength, 0) + tokenization.tail.codepointLength, + `Error for variant at index ${index}` + ); + assert.isTrue(corrector.modelsCorrectables, `Error for variant at index ${index}`); + }); + }); +}); \ No newline at end of file From 26703a4e6d097264dd64033600be9b638e6e2728 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 22 Jul 2026 15:39:53 -0500 Subject: [PATCH 143/148] change(web): double the permitted transcription-cache size This will help in cases where a user wishes to revert a suggestion after numerous other edits. --- web/src/engine/src/main/headless/transcriptionCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/engine/src/main/headless/transcriptionCache.ts b/web/src/engine/src/main/headless/transcriptionCache.ts index 4a3e6408a6..7ff3832b9b 100644 --- a/web/src/engine/src/main/headless/transcriptionCache.ts +++ b/web/src/engine/src/main/headless/transcriptionCache.ts @@ -1,7 +1,7 @@ import { Transcription } from "keyman/engine/keyboard"; import { RewindableCache } from "keyman/common/web-utils"; -const TRANSCRIPTION_BUFFER_SIZE = 10; +const TRANSCRIPTION_BUFFER_SIZE = 20; export class TranscriptionCache extends RewindableCache { constructor() { From e225b26f10b7c2a0bc9fcbd6a429d4ff978e6bfd Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 23 Jul 2026 15:52:53 -0500 Subject: [PATCH 144/148] change(web): add unit tests for issues fixed by this PR --- .../worker-thread/src/main/predict-helpers.ts | 17 +- .../context/context-tokenization.tests.ts | 37 +++ .../context/tokenization-subsets.tests.ts | 51 +++- .../build-and-map-predictions.tests.ts | 220 ++++++++++++++++++ 4 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 665db610ef..5aa73200ee 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -13,7 +13,6 @@ import { ContextTransition } from './correction/context-transition.js'; import { ExecutionTimer } from './correction/execution-timer.js'; import { ModelCompositor } from './model-compositor.js'; import { getBestTokenMatches } from './correction/distance-modeler.js'; -import { TokenResultMapping } from './correction/token-result-mapping.js'; import CasingForm = LexicalModelTypes.CasingForm; import Context = LexicalModelTypes.Context; @@ -459,7 +458,8 @@ export function determineSuggestionRange( export function buildAndMapPredictions( transition: ContextTransition, tokenization: ContextTokenization, - match: Readonly, + // Originally, Readonly - but we only need these two components here. + match: Readonly<{matchString: string, totalCost: number}>, costFactor: number ): CorrectionPredictionTuple[] { const model = transition.final.model; @@ -493,6 +493,13 @@ export function buildAndMapPredictions( // entry.baseTokenization = transition.final.tokenizationSourceMap.get(tokenization); }); + // Backspaces that shorten a multi-codepoint whitespace token are not handled well by default. + // As a new empty token is placed at the end for such cases, we can detect and handle such cases. + const inputTransform = transition.inputDistribution?.[0].sample ?? { insert: '', deleteLeft: 0 }; + if(tokenization.tokens.length > 1 && tokenization.tail.searchModule.codepointLength == 0 && inputTransform.deleteLeft > 0) { + predictions.forEach((p) => p.prediction.sample.transform.deleteLeft += inputTransform.deleteLeft); + } + return predictions; } @@ -604,12 +611,6 @@ export async function correctAndEnumerate( const predictions = buildAndMapPredictions(transition, tokenization, match, costFactor); - // Backspaces that cause the whole context to become empty do not reflect the backspace transform - // within the search space. We correct for that here. - if(tokenization.tail.searchModule.codepointLength == 0 && inputTransform.deleteLeft > 0) { - predictions.forEach((p) => p.prediction.sample.transform.deleteLeft += inputTransform.deleteLeft); - } - // Only set 'best correction' cost when a correction ACTUALLY YIELDS predictions. if(predictions.length > 0 && bestCorrectionCost === undefined) { bestCorrectionCost = match.totalCost * costFactor; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts index dcef6f8f61..8f93bd20f4 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts @@ -397,6 +397,43 @@ describe('ContextTokenization', function() { ); }); + it('handles simple case - deletion of final context content via backspace', () => { + const baseTokens = ['a']; + const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t))); + + const targetTokens = [''].map((t) => ({text: t, isWhitespace: t == ' '})); + const inputTransform = { insert: '', deleteLeft: 1, deleteRight: 0, id: 42 }; + const inputTransformMap: Map = new Map(); + inputTransformMap.set(0, { insert: '', deleteLeft: 1, id: 42 }); + + const edgeWindow = buildEdgeWindow(baseTokenization.tokens, inputTransform, false, testEdgeWindowSpec); + const tokenization = baseTokenization.evaluateTransition({ + alignment: { + merges: [], + splits: [], + unmappedEdits: [], + edgeWindow: { + ...edgeWindow, + // The range within the window constructed by the prior call for its parameterization. + // Any adjustments on the boundary token itself are included here. + retokenization: [...targetTokens.slice(edgeWindow.sliceIndex).map(t => t.text)] + }, + removedTokenCount: 1 + }, + inputs: [{ sample: inputTransformMap, p: 1 }], + inputSubsetId: generateSubsetId() + }, + inputTransform.id, + 1 + ); + + assert.isOk(tokenization); + assert.equal(tokenization.tokens.length, targetTokens.length); + assert.deepEqual(tokenization.tokens.map((t) => ({text: t.exampleInput, isWhitespace: t.isWhitespace})), + targetTokens + ); + }); + it('handles simple case - new character added to last token', () => { const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'da']; const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t))); diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/tokenization-subsets.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/tokenization-subsets.tests.ts index 04647123c0..b924d9422c 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/tokenization-subsets.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/tokenization-subsets.tests.ts @@ -21,6 +21,7 @@ import { ContextToken, ContextTokenization, generateSubsetId, + legacySubsetKeyer, models, precomputationSubsetKeyer, TokenizationTransitionEdits, @@ -31,7 +32,7 @@ import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; import TrieModel = models.TrieModel; -var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'), +const plainModel = new TrieModel(jsonFixture('models/tries/english-1000'), {wordBreaker: defaultBreaker}); function toToken(text: string) { @@ -41,6 +42,54 @@ function toToken(text: string) { return token; } +describe('legacySubsetKeyer', () => { + it('does not map backspace inputs to same result as standard key inputs', () => { + const appleToken = ContextToken.fromRawText(plainModel, 'apple', true); + + const bksp = { insert: '', deleteLeft: 1, deleteRight: 0, id: 3 }; + const bkspKey = legacySubsetKeyer({ + alignment: { + merges: [], + splits: [], + unmappedEdits: [], + edgeWindow: { + ...buildEdgeWindow([appleToken], bksp, false), + retokenization: ['appl'], + retokenizationText: 'appl' + }, + removedTokenCount: 0 + }, + tokenizedTransform: (() => { + const map: Map = new Map(); + map.set(0, bksp); + return map; + })() + }); + + const input = { insert: 's', deleteLeft: 0, deleteRight: 0, id: bksp.id }; + const inputKey = legacySubsetKeyer({ + alignment: { + merges: [], + splits: [], + unmappedEdits: [], + edgeWindow: { + ...buildEdgeWindow([appleToken], input, false), + retokenization: ['apples'], + retokenizationText: 'apples' + }, + removedTokenCount: 0 + }, + tokenizedTransform: (() => { + const map: Map = new Map(); + map.set(0, input); + return map; + })() + }); + + assert.notEqual(bkspKey, inputKey); + }); +}); + describe('precomputationSubsetKeyer', function() { it("safely generates keys for empty transition + empty contexts", () => { const rawTextTokens = ['']; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts new file mode 100644 index 0000000000..73d8276e13 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts @@ -0,0 +1,220 @@ + +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-07-23 + * + * This file contains tests designed to validate the behavior of the + * buildAndMapPredictions helper function class and its integration with the + * lower-level predictive-text helpers. + */ + + +import { assert } from 'chai'; + +import { default as defaultBreaker } from '@keymanapp/models-wordbreakers'; +import { LexicalModelTypes } from '@keymanapp/common-types'; +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; +import { TrieModel } from '@keymanapp/models-templates'; + +import { buildAndMapPredictions, buildEdgeWindow, ContextState, ContextToken, ContextTokenization, ContextTransition, generateSubsetId, LegacyQuotientRoot, LegacyQuotientSpur, models, predictFromCorrections } from "@keymanapp/lm-worker/test-index"; + +import Context = LexicalModelTypes.Context; +import Distribution = LexicalModelTypes.Distribution; +import ProbabilityMass = LexicalModelTypes.ProbabilityMass; +import Transform = LexicalModelTypes.Transform; + +const plainModel = new TrieModel(jsonFixture('models/tries/english-1000'), + {wordBreaker: defaultBreaker}); + +describe('buildAndMapPredictions', () => { + it('adds the preservation transform to all generated predictions', () => { + const context: Context = { + left: 'th', + right: '', + startOfBuffer: true, + endOfBuffer: true + }; + + const correctionDistribution: Distribution = [{ + sample: { + insert: 'e', + deleteLeft: 0 + }, + p: 0.6 + } + ]; + + const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context); + basePredictions.forEach((entry) => assert.isNotOk(entry.preservationTransform)); + + // must construct the taillessTrueKeystroke appropriately. + const tailless = { insert: 'TEST', deleteLeft: 0 }; + const tokenization = new ContextTokenization([ContextToken.fromRawText(plainModel, 'th', true)], null, tailless); + const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0); + + const targetTokenization = new ContextTokenization([new ContextToken(new LegacyQuotientSpur(tokenization.tail.searchModule, correctionDistribution, correctionDistribution[0]))]); + transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution); + + const mappedPredictions = buildAndMapPredictions( + transition, + transition.base.displayTokenization, + {matchString: 'the', totalCost: 0}, + 1 + ); + + assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction)); + mappedPredictions.forEach((tuple) => assert.isOk(tuple.preservationTransform)); + mappedPredictions.forEach((tuple) => tuple.preservationTransform == tailless); + }); + + it('properly handles empty prediction roots from deleted same-token codepoints', () => { + const context: Context = { + left: 'the a', + right: '', + startOfBuffer: true, + endOfBuffer: true + }; + + const correctionDistribution: Distribution = [{ + sample: { + insert: '', + deleteLeft: 1 + }, + p: 1 + } + ]; + + const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context); + + // must construct the taillessTrueKeystroke appropriately. + const tokenization = new ContextTokenization([ + ContextToken.fromRawText(plainModel, 'the', false), + ContextToken.fromRawText(plainModel, ' ', false), + ContextToken.fromRawText(plainModel, 'a', true) + ]); + const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0); + + const targetTokenization = new ContextTokenization([ + tokenization.tokens[0], + tokenization.tokens[1], + new ContextToken(new LegacyQuotientRoot(plainModel)) + ]); + transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution); + + const mappedPredictions = buildAndMapPredictions( + transition, + transition.base.displayTokenization, + {matchString: '', totalCost: 0}, + 1 + ); + + assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction)); + }); + + it('properly handles empty prediction roots caused by backspacing one of multiple spaces', () => { + const context: Context = { + left: 'the ', + right: '', + startOfBuffer: true, + endOfBuffer: true + }; + + const correctionDistribution: Distribution = [{ + sample: { + insert: '', + deleteLeft: 1 + }, + p: 1 + } + ]; + + const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context); + + // must construct the taillessTrueKeystroke appropriately. + const tokenization = new ContextTokenization([ + ContextToken.fromRawText(plainModel, 'the', false), + ContextToken.fromRawText(plainModel, ' ', false), + ContextToken.fromRawText(plainModel, '', true) + ]); + const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0); + + const targetTokenization = new ContextTokenization([ + tokenization.tokens[0], + new ContextToken(new LegacyQuotientSpur(tokenization.tokens[1].searchModule, correctionDistribution, correctionDistribution[0])), + new ContextToken(new LegacyQuotientRoot(plainModel)) + ]); + transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution); + + const mappedPredictions = buildAndMapPredictions( + transition, + transition.base.displayTokenization, + {matchString: '', totalCost: 0}, + 1 + ); + + assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction)); + }); + + it('properly handles contexts made empty by input backspace', () => { + const context: Context = { + left: 't', + right: '', + startOfBuffer: true, + endOfBuffer: true + }; + + const correctionDistribution = [{ + sample: { + insert: '', + deleteLeft: 1, + deleteRight: 0 + }, + p: 1 + } + ]; + + // must construct the taillessTrueKeystroke appropriately. + const tokenization = new ContextTokenization([ + ContextToken.fromRawText(plainModel, 't', true) + ]); + const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0); + + const targetTokenization = new ContextTokenization([ + new ContextToken(new LegacyQuotientRoot(plainModel)) + ], { + alignment: { + merges: [], + splits: [], + unmappedEdits: [], + edgeWindow: { + ...buildEdgeWindow(tokenization.tokens, correctionDistribution[0].sample, false), + retokenization: [''], + retokenizationText: '' + }, + removedTokenCount: 0 + }, + inputs: (() => { + const val: ProbabilityMass>[] = [{ + sample: new Map(), + p: correctionDistribution[0].p + }]; + + val[0].sample.set(0, correctionDistribution[0].sample); + + return val; + })(), + inputSubsetId: generateSubsetId() + }, null); + transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution); + + const mappedPredictions = buildAndMapPredictions( + transition, + transition.final.displayTokenization, + {matchString: '', totalCost: 0}, + 1 + ); + + mappedPredictions.forEach((tuple) => assert.equal(tuple.prediction.sample.transform.deleteLeft, 1)); + }); +}); \ No newline at end of file From f864bf293a473b3eeccf0b0d23af10cecfac3375 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 27 Jul 2026 12:11:50 -0500 Subject: [PATCH 145/148] change(web): spin off method, add unit tests to verify handling of keep vs revert suggestions --- .../src/main/correction/context-transition.ts | 9 ++ .../src/main/model-compositor.ts | 20 +--- .../worker-thread/src/main/predict-helpers.ts | 33 +++++- .../worker-thread/src/main/test-index.ts | 2 +- .../prepend-reversion.tests.ts | 101 ++++++++++++++++++ 5 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepend-reversion.tests.ts diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts index c65f348996..ede7125a9c 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts @@ -19,6 +19,15 @@ import Reversion = LexicalModelTypes.Reversion; import Suggestion = LexicalModelTypes.Suggestion; import Transform = LexicalModelTypes.Transform; +export interface TransitionReversionView extends Pick { + /** + * Gets the context state resulting from the context transition event, + * including any generated suggestions and data regarding potential + * application thereof. + */ + final: Pick +} + /** * Represents the transition between two context states as triggered * by input keystrokes or applied suggestions. diff --git a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts index 8e12b96ed4..1a514cad64 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts @@ -2,7 +2,7 @@ import * as models from '@keymanapp/models-templates'; import { LexicalModelTypes } from '@keymanapp/common-types'; import { TransformUtils } from './transformUtils.js'; -import { applySuggestionCasing, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js'; +import { applySuggestionCasing, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, prependReversion, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js'; import { detectCurrentCasing, determineModelTokenizer, determineModelWordbreaker, determinePunctuationFromModel } from './model-helpers.js'; import { ContextTracker } from './correction/context-tracker.js'; @@ -206,18 +206,8 @@ export class ModelCompositor { this.SUGGESTION_ID_SEED++; }); - if(revertableTransitionId) { - const reversion = this.contextTracker.peek(revertableTransitionId)?.reversion; - if(reversion) { - if(suggestions[0]?.tag == 'keep') { - const keep = suggestions.shift(); - suggestions.unshift(reversion); - suggestions.unshift(keep); - } else { - suggestions.unshift(reversion) - } - } - } + const transitionToRevert = this.contextTracker?.peek(revertableTransitionId); + prependReversion(suggestions, transitionToRevert); // Store the suggestions on the final token of the current context state (if it exists). // Or, once phrase-level suggestions are possible, on whichever token serves as each prediction's root. @@ -239,8 +229,8 @@ export class ModelCompositor { // Step 1: re-use the original input Transform as the reversion's Transform. // The Web engine will restore the original state of the context before accepting // and before reverting; all we need to do is put the original keystroke back in place. - let reversionTransform: Transform = originalInput - ? { ...originalInput } + let reversionTransform: Transform = originalInput + ? { ...originalInput } : { insert: '', deleteLeft: 0, id: suggestion.transform.id }; // Step 2: building the proper 'displayAs' string for the Reversion diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 5aa73200ee..9047e80a83 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -9,7 +9,7 @@ import { ContextTokenization } from './correction/context-tokenization.js'; import { ContextTracker } from './correction/context-tracker.js'; import { ContextToken } from './correction/context-token.js'; import { ContextState, determineContextSlideTransform } from './correction/context-state.js'; -import { ContextTransition } from './correction/context-transition.js'; +import { ContextTransition, TransitionReversionView } from './correction/context-transition.js'; import { ExecutionTimer } from './correction/execution-timer.js'; import { ModelCompositor } from './model-compositor.js'; import { getBestTokenMatches } from './correction/distance-modeler.js'; @@ -1196,4 +1196,35 @@ export function toAnnotatedSuggestion( } return result; +} + +/** + * For applicable scenarios, this mutates the passed-in suggestion array by + * prepending a predictive-text reversion that restores the context to a prior + * state. Otherwise, it leaves the suggestion array unaltered. + * @param suggestions + * @param transitionToRevert + * @returns + */ +export function prependReversion(suggestions: Suggestion[], transitionToRevert: TransitionReversionView) { + if(transitionToRevert) { + const reversion = transitionToRevert.reversion; + if(reversion) { + if(suggestions[0]?.tag == 'keep') { + const appliedId = -reversion.id; + const appliedSuggestion = transitionToRevert.final.suggestions.find((s) => s.id == appliedId); + // If the selected suggestion was itself a `keep`, we don't need a + // reversion. They'd do the same thing. + if(appliedSuggestion.tag != 'keep') { + const keep = suggestions.shift(); + suggestions.unshift(reversion); + suggestions.unshift(keep); + } + } else { + suggestions.unshift(reversion); + } + } + } + + return suggestions; } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts index e0c5d513f9..bcaf4692bf 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts @@ -3,7 +3,7 @@ export * from './correction/context-state.js'; export * from './correction/context-token.js'; export * from './correction/context-tokenization.js'; export { ContextTracker } from './correction/context-tracker.js'; -export { ContextTransition } from './correction/context-transition.js'; +export * from './correction/context-transition.js'; export * from './correction/correction-searchable.js'; export * from './correction/correction-result-mapping.js'; export * from './correction/distance-modeler.js'; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepend-reversion.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepend-reversion.tests.ts new file mode 100644 index 0000000000..5215705ed6 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepend-reversion.tests.ts @@ -0,0 +1,101 @@ + +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-07-27 + * + * This file contains tests designed to ensure predictive text does not + * provide matching 'keep' and 'revert' suggestions in any context. + */ + + +import { assert } from 'chai'; + +import { LexicalModelTypes } from '@keymanapp/common-types'; +import { prependReversion, type TransitionReversionView } from "@keymanapp/lm-worker/test-index"; + +import Suggestion = LexicalModelTypes.Suggestion; + +describe('prependReversion', () => { + it(`prepends reversions when reverting a non-'keep' suggestion`, () => { + // context: Original was appl+u, corrected to apply. Reached via bksp. + const suggestions: Suggestion[] = [{ + tag: 'keep', + transform: { insert: 'apply', deleteLeft: 6, id: 3 }, + displayAs: '"apply"', + id: 5, + matchesModel: false + } as Suggestion]; + + const revertable: TransitionReversionView = { + reversion: { + tag: 'revert', + transform: { insert: 'u', deleteLeft: 0, id: 3 }, + id: -3, + displayAs: '"applu"' + }, + final: { + suggestions: [{ + tag: 'keep', + transform: { insert: 'applu', deleteLeft: 4, id: 3 }, + displayAs: '"applu"', + id: 2, + matchesModel: false + } as Suggestion, { + transform: { insert: 'apply', deleteLeft: 4, id: 3 }, + displayAs: 'apply', + id: 3 + }, { + transform: { insert: 'applied', deleteLeft: 4, id: 3 }, + displayAs: 'applied', + id: 4 + }] + } + }; + + prependReversion(suggestions, revertable); + + assert.includeMembers(suggestions, [revertable.reversion]); + }); + + it(`does not prepend reversions when reverting a 'keep' suggestion`, () => { + // context: Original was appl+u, corrected to apply + const suggestions: Suggestion[] = [{ + tag: 'keep', + transform: { insert: 'applu', deleteLeft: 5, id: 3 }, + displayAs: '"applu"', + id: 5, + matchesModel: false + } as Suggestion]; + + const revertable: TransitionReversionView = { + reversion: { + tag: 'revert', + transform: { insert: 'u', deleteLeft: 0, id: 3 }, + id: -2, + displayAs: '"applu"' + }, + final: { + suggestions: [{ + tag: 'keep', + transform: { insert: 'applu', deleteLeft: 4, id: 3 }, + displayAs: '"applu"', + id: 2, + matchesModel: false + } as Suggestion, { + transform: { insert: 'apply', deleteLeft: 4, id: 3 }, + displayAs: 'apply', + id: 3 + }, { + transform: { insert: 'applied', deleteLeft: 4, id: 3 }, + displayAs: 'applied', + id: 4 + }] + } + }; + + prependReversion(suggestions, revertable); + + assert.notIncludeMembers(suggestions, [revertable.reversion]); + }); +}); \ No newline at end of file From 7db38f93ec303f0c07774964b42237090b0c9792 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 28 Jul 2026 08:40:44 -0500 Subject: [PATCH 146/148] fix(web): do not remove applied transition ID from original token when editing --- .../src/main/correction/context-tokenization.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts index 4ccd8cb86d..61b06092ab 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts @@ -673,9 +673,6 @@ export class ContextTokenization { tailTokenization.splice(tokenIndex, 1, affectedToken); } - affectedToken.isPartial = true; - delete affectedToken.appliedTransitionId; - // If we are completely replacing a token via delete left, erase the deleteLeft; // that part applied to a _previous_ token that no longer exists. // We start at index 0 in the insert string for the "new" token. @@ -699,6 +696,11 @@ export class ContextTokenization { affectedToken = new ContextToken(affectedToken); affectedToken.addInput(inputSource, distribution); + // Do not adjust the original token, as it may be used by other transitions. + // Only adjust the new, extended token. + affectedToken.isPartial = true; + delete affectedToken.appliedTransitionId; + const tokenize = determineModelTokenizer(lexicalModel); affectedToken.isWhitespace = tokenize({left: affectedToken.exampleInput, startOfBuffer: false, endOfBuffer: false}).left[0]?.isWhitespace ?? false; // Do not re-use the previous token; the mutation may have unexpected From f2e160c232d5ecb6b600c2e86f5961096f7d3f17 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 28 Jul 2026 12:01:03 -0500 Subject: [PATCH 147/148] feat(web): add unit tests for prior commit --- .../context/context-tokenization.tests.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts index 8f93bd20f4..0b10f90c89 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-tokenization.tests.ts @@ -860,6 +860,198 @@ describe('ContextTokenization', function() { assert.equal(preTail.exampleInput, '\''); assert.equal(tail.exampleInput, '.'); }); + + describe('properly handles previously-applied transition IDs', () => { + it('does not preserve applied transition IDs on edited tokens', () => { + const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can']; + const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t))); + + const REVERTABLE_TRANSITION_ID = 31415; + baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID; + const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1; + + const dist = [ + { + sample: { insert: 't', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID }, + p: 1 + } + ]; + + const resultTokenization = baseTokenization.evaluateTransition( + { + alignment: { + merges: [], + splits: [], + unmappedEdits: [], + edgeWindow: { + ...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false), + retokenization: baseTokenization.tokens.map((t) => t.exampleInput), + retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '') + }, + removedTokenCount: 0 + }, + inputs: (() => { + const map: Map = new Map(); + map.set(0, dist[0].sample); + + return [ + {sample: map, p: 1} + ]; + })(), + inputSubsetId: 0 + }, + NEW_TRANSITION_ID, + 1 + ) + + resultTokenization.tokens.forEach((t) => { + assert.isUndefined(t.appliedTransitionId); + }); + }); + + it('preserves applied transition IDs on applicable tokens', () => { + const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can']; + const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t))); + + const REVERTABLE_TRANSITION_ID = 31415; + baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID; + const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1; + + + const dist = [ + { + sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID }, + p: 1 + } + ]; + + const resultTokenization = baseTokenization.evaluateTransition( + { + alignment: { + merges: [], + splits: [], + unmappedEdits: [], + edgeWindow: { + ...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false), + retokenization: baseTokenization.tokens.map((t) => t.exampleInput), + retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '') + }, + removedTokenCount: 0 + }, + inputs: (() => { + const map: Map = new Map(); + map.set(1, dist[0].sample); + + return [ + {sample: map, p: 1} + ]; + })(), + inputSubsetId: 0 + }, + NEW_TRANSITION_ID, + 1 + ) + + const resultTokenLength = resultTokenization.tokens.length; + + resultTokenization.tokens.forEach((t, i) => { + // The space will add TWO tokens. + if(i == resultTokenLength - 3) { + assert.equal(t.appliedTransitionId, REVERTABLE_TRANSITION_ID); + } else { + assert.isUndefined(t.appliedTransitionId); + } + }); + }); + + // Performs the two above in sequence in a manner that could cause cross-effects + // if implemented incorrectly. + it('does not conflate effects between different tokenization transitions', () => { + const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can']; + const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t))); + + const REVERTABLE_TRANSITION_ID = 31415; + baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID; + const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1; + + const dist = [ + { + sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID }, + p: .8 + }, { + sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID }, + p: .2 + } + ]; + + const baseTransitionEdge: TransitionEdge = { + alignment: { + merges: [], + splits: [], + unmappedEdits: [], + edgeWindow: { + ...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false), + retokenization: baseTokenization.tokens.map((t) => t.exampleInput), + retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '') + }, + removedTokenCount: 0 + }, + inputs: (() => { + const map: Map = new Map(); + map.set(1, dist[0].sample); + + return [ + {sample: map, p: dist[0].p} + ]; + })(), + inputSubsetId: 0 + }; + + // We don't care about the results here. What we care about is that + // this call doesn't remove the appliedTransitionId from the source + // token, preventing it from being marked on later tokenization + // transitions. + baseTokenization.evaluateTransition( + { + alignment: { + ...baseTransitionEdge.alignment, + edgeWindow: { + ...baseTransitionEdge.alignment.edgeWindow, + ...buildEdgeWindow(baseTokenization.tokens, dist[1].sample, false) + } + }, + inputs: (() => { + const map: Map = new Map(); + map.set(0, dist[1].sample); + + return [ + {sample: map, p: dist[1].p} + ]; + })(), + inputSubsetId: 0 + }, + NEW_TRANSITION_ID, + dist[1].p + ) + + const resultTokenization = baseTokenization.evaluateTransition( + baseTransitionEdge, + NEW_TRANSITION_ID, + dist[0].p + ); + + const resultTokenLength = resultTokenization.tokens.length; + + resultTokenization.tokens.forEach((t, i) => { + // The space will add TWO tokens. + if(i == resultTokenLength - 3) { + assert.equal(t.appliedTransitionId, REVERTABLE_TRANSITION_ID); + } else { + assert.isUndefined(t.appliedTransitionId); + } + }); + }); + }); }); describe('buildEdgeWindow', () => { From d2b5ac583264de321364010ec2eaacaf778b1479 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 31 Jul 2026 16:29:58 -0500 Subject: [PATCH 148/148] fix(web): fix broken unit test --- web/src/engine/predictive-text/templates/src/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/engine/predictive-text/templates/src/common.ts b/web/src/engine/predictive-text/templates/src/common.ts index d141f1f9ae..77d4826174 100644 --- a/web/src/engine/predictive-text/templates/src/common.ts +++ b/web/src/engine/predictive-text/templates/src/common.ts @@ -60,7 +60,7 @@ export function buildMergedTransform(first: Transform, second: Transform): Trans deleteLeft: first.deleteLeft + mergedSecondDelete } - if(first.id && first.id == second.id) { + if(first.id !== undefined && first.id == second.id) { returnedObj.id = first.id; }