Skip to content

Commit e0c6351

Browse files
committed
replace keyboards in a pageset with worldalphabets. Sketchy this. Dont trust it..
1 parent 0189c14 commit e0c6351

2 files changed

Lines changed: 259 additions & 0 deletions

File tree

scripts/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ scripts/
1313
├── audio/ # Audio enhancement and integration
1414
├── conversion/ # File format conversion utilities
1515
├── translation/ # Translation workflows and tools
16+
├── keyboard/ # Keyboard layout utilities
1617
└── utilities/ # General utility scripts
1718
```
1819

@@ -67,6 +68,11 @@ Translation and localization tools.
6768
- **punjabi/** - Punjabi language translation scripts
6869
- `translate_to_punjabi.js` - Translate pagesets to Punjabi
6970

71+
### ⌨️ keyboard/
72+
Keyboard layout utilities.
73+
74+
- **replace-keyboard-layout.js** - Replace single-key buttons using a target keyboard layout
75+
7076
### 🛠️ utilities/
7177
General-purpose utility scripts.
7278

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
const path = require('path');
2+
const { Command } = require('commander');
3+
const { XMLParser, XMLBuilder } = require('fast-xml-parser');
4+
const AdmZip = require('adm-zip');
5+
6+
function normalizeChar(value) {
7+
if (typeof value !== 'string') return '';
8+
return value.normalize('NFC');
9+
}
10+
11+
function isVisibleChar(value) {
12+
if (!value) return false;
13+
if (value.trim().length === 0) return false;
14+
if (/[\p{Cf}\p{Mn}]/u.test(value)) return false;
15+
return true;
16+
}
17+
18+
function asArray(value) {
19+
if (!value) return [];
20+
return Array.isArray(value) ? value : [value];
21+
}
22+
23+
function getAttr(obj, keys) {
24+
for (const key of keys) {
25+
if (obj && Object.prototype.hasOwnProperty.call(obj, key)) return obj[key];
26+
}
27+
return undefined;
28+
}
29+
30+
function setAttr(obj, key, value) {
31+
obj[key] = value;
32+
}
33+
34+
function buildLayerMaps(layout, extractLayers) {
35+
const layers = extractLayers(layout, ['base']);
36+
const charToCodes = new Map();
37+
const baseLayer = layers.base || {};
38+
39+
for (const code of Object.keys(baseLayer)) {
40+
const raw = baseLayer[code];
41+
if (typeof raw !== 'string' || raw.length === 0) continue;
42+
const char = normalizeChar(raw);
43+
if (!charToCodes.has(char)) charToCodes.set(char, []);
44+
charToCodes.get(char).push({ code, layer: 'base' });
45+
}
46+
47+
return { layers, charToCodes };
48+
}
49+
50+
function buildPositionMap(layout) {
51+
const map = new Map();
52+
if (!layout || !Array.isArray(layout.keys)) return map;
53+
for (const key of layout.keys) {
54+
if (!key || key.pos === undefined || key.row === undefined || key.col === undefined) continue;
55+
const id = `${key.row}:${key.col}`;
56+
map.set(id, key.pos);
57+
}
58+
return map;
59+
}
60+
61+
function resolveCodeByLabel(label, sourceLayers, sourceCharMap) {
62+
if (!label || label.length !== 1) return null;
63+
64+
const normalized = normalizeChar(label);
65+
const lookup = /^[A-Za-z]$/.test(normalized) ? normalized.toLowerCase() : normalized;
66+
const baseLayer = sourceLayers.base || {};
67+
for (const code of Object.keys(baseLayer)) {
68+
if (normalizeChar(baseLayer[code]) === lookup) {
69+
return { code, layer: 'base' };
70+
}
71+
}
72+
73+
const entries = sourceCharMap.get(normalized);
74+
if (!entries || entries.length === 0) return null;
75+
return entries[0];
76+
}
77+
78+
async function main() {
79+
const program = new Command();
80+
program
81+
.name('replace-keyboard-layout')
82+
.usage('<gridset> [options]')
83+
.argument('<gridset>', 'Path to .gridset file')
84+
.option('--page-name <substring>', 'Only update pages whose name includes this substring')
85+
.option('--layout <layoutId>', 'Target keyboard layout id', 'ar-arabic-101')
86+
.option('--source-layout <layoutId>', 'Source keyboard layout id', 'en-us')
87+
.option('--output <path>', 'Output .gridset path (default: add -keyboard-replaced)')
88+
.option('--flip-keys-for-grid-rl', 'Mirror key positions before mapping')
89+
.option('--dry-run', 'Report changes without writing output')
90+
.parse(process.argv);
91+
92+
const [gridsetPath] = program.args;
93+
const options = program.opts();
94+
95+
if (!gridsetPath) {
96+
program.help();
97+
return;
98+
}
99+
100+
if (!options.pageName) {
101+
console.error('Error: --page-name is required to avoid accidental global changes.');
102+
process.exit(1);
103+
}
104+
105+
const outputPath =
106+
options.output || gridsetPath.replace(/\.gridset$/i, '-keyboard-replaced.gridset');
107+
108+
let loadKeyboard;
109+
let extractLayers;
110+
try {
111+
({ loadKeyboard, extractLayers } = require('worldalphabets'));
112+
} catch (error) {
113+
console.error('Missing dependency: worldalphabets');
114+
console.error('Install it for this script with: npm install --no-save worldalphabets');
115+
process.exit(1);
116+
}
117+
118+
console.log('Loading layouts...');
119+
const sourceLayout = await loadKeyboard(options.sourceLayout);
120+
const targetLayout = await loadKeyboard(options.layout);
121+
const { layers: sourceLayers, charToCodes } = buildLayerMaps(sourceLayout, extractLayers);
122+
const { layers: targetLayers } = buildLayerMaps(targetLayout, extractLayers);
123+
const positionMap = buildPositionMap(sourceLayout);
124+
125+
console.log('Loading gridset...');
126+
const zip = new AdmZip(gridsetPath);
127+
const entries = zip.getEntries();
128+
const nameFilter = String(options.pageName).toLowerCase();
129+
const parser = new XMLParser({ ignoreAttributes: false });
130+
const builder = new XMLBuilder({ ignoreAttributes: false, suppressEmptyNode: true });
131+
132+
let pagesMatched = 0;
133+
let buttonsUpdated = 0;
134+
let buttonsSkipped = 0;
135+
136+
for (const entry of entries) {
137+
if (entry.isDirectory) continue;
138+
if (!entry.entryName.startsWith('Grids/') || !entry.entryName.endsWith('/grid.xml')) continue;
139+
const pageName = entry.entryName.slice('Grids/'.length, -'/grid.xml'.length);
140+
if (!pageName.toLowerCase().includes(nameFilter)) continue;
141+
142+
pagesMatched += 1;
143+
144+
const xmlText = entry.getData().toString('utf8');
145+
const parsed = parser.parse(xmlText);
146+
const grid = parsed.Grid || parsed.grid;
147+
if (!grid || !grid.Cells || !grid.Cells.Cell) continue;
148+
149+
const cells = asArray(grid.Cells.Cell);
150+
let gridCols = 0;
151+
if (grid.ColumnDefinitions && grid.ColumnDefinitions.ColumnDefinition) {
152+
const cols = asArray(grid.ColumnDefinitions.ColumnDefinition);
153+
gridCols = cols.length;
154+
}
155+
156+
let gridUpdated = 0;
157+
158+
for (const cell of cells) {
159+
const content = cell.Content || cell.content;
160+
if (!content) continue;
161+
const commands = asArray(content.Commands?.Command || content.commands?.command);
162+
if (commands.length === 0) continue;
163+
164+
let targetCommand = null;
165+
for (const cmd of commands) {
166+
const id = String(getAttr(cmd, ['@_ID', '@_Id', '@_id']) || '');
167+
if (id === 'Action.Letter') {
168+
targetCommand = cmd;
169+
break;
170+
}
171+
}
172+
if (!targetCommand) continue;
173+
174+
const params = asArray(targetCommand.Parameter);
175+
let letterParam = null;
176+
for (const param of params) {
177+
const key = String(getAttr(param, ['@_Key', '@_key']) || '').toLowerCase();
178+
if (key === 'letter') {
179+
letterParam = param;
180+
break;
181+
}
182+
}
183+
if (!letterParam) continue;
184+
185+
const original = normalizeChar(getAttr(letterParam, ['#text']) || '');
186+
if (!original || original.length !== 1) {
187+
buttonsSkipped += 1;
188+
continue;
189+
}
190+
191+
let mapping = null;
192+
if (options.flipKeysForGridRl) {
193+
const x = parseInt(getAttr(cell, ['@_X', '@_x']), 10);
194+
const y = parseInt(getAttr(cell, ['@_Y', '@_y']), 10);
195+
if (!Number.isNaN(x) && !Number.isNaN(y) && gridCols > 0) {
196+
const mirroredCol = gridCols - 1 - x;
197+
const posKey = `${y}:${mirroredCol}`;
198+
const code = positionMap.get(posKey);
199+
if (code) mapping = { code, layer: 'base' };
200+
}
201+
}
202+
203+
if (!mapping) {
204+
mapping = resolveCodeByLabel(original, sourceLayers, charToCodes);
205+
}
206+
207+
if (!mapping) {
208+
buttonsSkipped += 1;
209+
continue;
210+
}
211+
212+
const targetLayer = targetLayers[mapping.layer] || targetLayers.base || {};
213+
const updated = normalizeChar(targetLayer[mapping.code]);
214+
if (!isVisibleChar(updated)) {
215+
buttonsSkipped += 1;
216+
continue;
217+
}
218+
219+
setAttr(letterParam, '#text', updated);
220+
if (content.CaptionAndImage && content.CaptionAndImage.Caption !== undefined) {
221+
const caption = normalizeChar(String(content.CaptionAndImage.Caption || ''));
222+
if (caption.length === 1 || caption === original) {
223+
content.CaptionAndImage.Caption = updated;
224+
}
225+
}
226+
227+
gridUpdated += 1;
228+
buttonsUpdated += 1;
229+
}
230+
231+
if (gridUpdated > 0) {
232+
const rebuilt = builder.build(parsed);
233+
zip.updateFile(entry.entryName, Buffer.from(rebuilt, 'utf8'));
234+
}
235+
}
236+
237+
console.log(`Pages matched: ${pagesMatched}`);
238+
console.log(`Buttons updated: ${buttonsUpdated}`);
239+
console.log(`Buttons skipped: ${buttonsSkipped}`);
240+
241+
if (options.dryRun) {
242+
console.log('Dry run enabled. No output written.');
243+
return;
244+
}
245+
246+
zip.writeZip(outputPath);
247+
console.log(`Saved updated gridset: ${outputPath}`);
248+
}
249+
250+
main().catch((error) => {
251+
console.error('Error:', error.message || error);
252+
process.exit(1);
253+
});

0 commit comments

Comments
 (0)