Skip to content

Commit 714a2a4

Browse files
committed
improving validation and snap dealing with assets
1 parent d05a706 commit 714a2a4

4 files changed

Lines changed: 402 additions & 31 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Merge translated text from a Snap .sps/.spb into the original database
5+
* while preserving original assets (images/audio/layout metadata).
6+
*
7+
* Usage:
8+
* node scripts/translation/merge-snap-assets.js \
9+
* original.sps translated.sps \
10+
* merged-with-assets.sps
11+
*/
12+
13+
const fs = require('fs');
14+
const path = require('path');
15+
const Database = require('better-sqlite3');
16+
17+
function printUsage() {
18+
console.log(
19+
'Usage: node scripts/translation/merge-snap-assets.js <original.sps> <translated.sps> [output.sps]'
20+
);
21+
console.log(
22+
'If no output path is supplied, the script will append "-with-assets" to the translated file name.'
23+
);
24+
}
25+
26+
function getTableColumns(db, tableName) {
27+
try {
28+
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
29+
return new Set(rows.map((row) => row.name));
30+
} catch {
31+
return new Set();
32+
}
33+
}
34+
35+
async function main() {
36+
const [, , originalPath, translatedPath, outputArg] = process.argv;
37+
38+
if (!originalPath || !translatedPath) {
39+
printUsage();
40+
process.exit(1);
41+
}
42+
43+
if (!fs.existsSync(originalPath)) {
44+
console.error(`Original file not found: ${originalPath}`);
45+
process.exit(1);
46+
}
47+
48+
if (!fs.existsSync(translatedPath)) {
49+
console.error(`Translated file not found: ${translatedPath}`);
50+
process.exit(1);
51+
}
52+
53+
const outputPath =
54+
outputArg ||
55+
path.join(
56+
path.dirname(translatedPath),
57+
`${path.basename(translatedPath, path.extname(translatedPath))}-with-assets${path.extname(translatedPath)}`
58+
);
59+
60+
if (fs.existsSync(outputPath)) {
61+
fs.unlinkSync(outputPath);
62+
}
63+
fs.copyFileSync(originalPath, outputPath);
64+
65+
const outDb = new Database(outputPath, { readonly: false });
66+
const transDb = new Database(translatedPath, { readonly: true });
67+
68+
try {
69+
const outPageCols = getTableColumns(outDb, 'Page');
70+
const transPageCols = getTableColumns(transDb, 'Page');
71+
const outButtonCols = getTableColumns(outDb, 'Button');
72+
const transButtonCols = getTableColumns(transDb, 'Button');
73+
const outPropsCols = getTableColumns(outDb, 'PageSetProperties');
74+
const transPropsCols = getTableColumns(transDb, 'PageSetProperties');
75+
76+
const canUpdatePage =
77+
outPageCols.size > 0 &&
78+
transPageCols.size > 0 &&
79+
outPageCols.has('Name') &&
80+
transPageCols.has('Name');
81+
const canUpdatePageTitle = outPageCols.has('Title') && transPageCols.has('Title');
82+
const canUpdatePageByUniqueId = outPageCols.has('UniqueId') && transPageCols.has('UniqueId');
83+
84+
const canUpdateButtonLabel =
85+
outButtonCols.has('Label') && transButtonCols.has('Label') && outButtonCols.has('Id');
86+
const canUpdateButtonMessage =
87+
outButtonCols.has('Message') && transButtonCols.has('Message') && outButtonCols.has('Id');
88+
89+
const tx = outDb.transaction(() => {
90+
if (canUpdatePage) {
91+
if (canUpdatePageByUniqueId) {
92+
const translatedPages = transDb
93+
.prepare('SELECT UniqueId, Name, Title FROM Page')
94+
.all();
95+
const updatePage = outDb.prepare(
96+
'UPDATE Page SET Name = ?, Title = ? WHERE UniqueId = ?'
97+
);
98+
translatedPages.forEach((row) => {
99+
const name = row.Name ?? '';
100+
const title = canUpdatePageTitle ? row.Title ?? name : name;
101+
updatePage.run(name, title, row.UniqueId);
102+
});
103+
} else {
104+
const translatedPages = transDb.prepare('SELECT Id, Name, Title FROM Page').all();
105+
const updatePage = outDb.prepare('UPDATE Page SET Name = ?, Title = ? WHERE Id = ?');
106+
translatedPages.forEach((row) => {
107+
const name = row.Name ?? '';
108+
const title = canUpdatePageTitle ? row.Title ?? name : name;
109+
updatePage.run(name, title, row.Id);
110+
});
111+
}
112+
}
113+
114+
if (canUpdateButtonLabel) {
115+
const translatedButtons = transDb.prepare('SELECT Id, Label FROM Button').all();
116+
const updateLabel = outDb.prepare('UPDATE Button SET Label = ? WHERE Id = ?');
117+
translatedButtons.forEach((row) => {
118+
updateLabel.run(row.Label ?? '', row.Id);
119+
});
120+
}
121+
122+
if (canUpdateButtonMessage) {
123+
const translatedButtons = transDb.prepare('SELECT Id, Message FROM Button').all();
124+
const updateMessage = outDb.prepare('UPDATE Button SET Message = ? WHERE Id = ?');
125+
translatedButtons.forEach((row) => {
126+
updateMessage.run(row.Message ?? '', row.Id);
127+
});
128+
}
129+
130+
if (outPropsCols.size > 0 && transPropsCols.size > 0) {
131+
const props = transDb.prepare('SELECT * FROM PageSetProperties LIMIT 1').get();
132+
if (props) {
133+
const updates = [];
134+
const values = [];
135+
['Name', 'Description', 'Author', 'Locale'].forEach((key) => {
136+
if (outPropsCols.has(key) && transPropsCols.has(key) && props[key] !== undefined) {
137+
updates.push(`${key} = ?`);
138+
values.push(props[key]);
139+
}
140+
});
141+
if (updates.length > 0) {
142+
outDb.prepare(`UPDATE PageSetProperties SET ${updates.join(', ')}`).run(...values);
143+
}
144+
}
145+
}
146+
});
147+
148+
tx();
149+
} finally {
150+
outDb.close();
151+
transDb.close();
152+
}
153+
154+
console.log(`Merged Snap file written to ${outputPath}`);
155+
}
156+
157+
main().catch((error) => {
158+
console.error('Failed to merge Snap assets:', error);
159+
process.exit(1);
160+
});

scripts/translation/translate.js

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,79 @@
1-
const { TouchChatProcessor } = require('../dist/index');
1+
const path = require('path');
2+
const axios = require('axios');
3+
const { getProcessor, isExtensionSupported } = require('../../dist/index');
4+
5+
const GOOGLE_TRANSLATE_KEY = process.env.GOOGLE_TRANSLATE_KEY;
6+
const GOOGLE_TRANSLATE_ENDPOINT = 'https://translation.googleapis.com/language/translate/v2';
7+
8+
async function googleTranslateBatch(texts, targetLanguage, sourceLanguage) {
9+
if (!GOOGLE_TRANSLATE_KEY) {
10+
throw new Error('Google Translate key not set. Set GOOGLE_TRANSLATE_KEY environment variable.');
11+
}
12+
13+
const params = { key: GOOGLE_TRANSLATE_KEY };
14+
const data = {
15+
q: texts,
16+
target: targetLanguage,
17+
format: 'text',
18+
};
19+
if (sourceLanguage) data.source = sourceLanguage;
20+
21+
const response = await axios.post(GOOGLE_TRANSLATE_ENDPOINT, data, { params });
22+
const translations = response?.data?.data?.translations;
23+
if (!Array.isArray(translations)) {
24+
throw new Error('Unexpected response from Google Translate API.');
25+
}
26+
return translations.map((t) => t.translatedText);
27+
}
28+
29+
async function translateTexts(texts, targetLanguage, sourceLanguage) {
30+
const batchSize = 50;
31+
const allTranslations = [];
32+
33+
for (let i = 0; i < texts.length; i += batchSize) {
34+
const batch = texts.slice(i, i + batchSize);
35+
console.log(`Translating batch ${Math.floor(i / batchSize) + 1} (${batch.length} items)...`);
36+
const translated = await googleTranslateBatch(batch, targetLanguage, sourceLanguage);
37+
allTranslations.push(...translated);
38+
}
39+
40+
return allTranslations;
41+
}
242

343
async function main() {
444
const filePath = process.argv[2];
45+
const targetLanguage = process.argv[3] || 'en';
46+
const sourceLanguage = process.argv[4] || '';
47+
548
if (!filePath) {
6-
console.error('Please provide a TouchChat .ce file path');
49+
console.error('Usage: node scripts/translation/translate.js <file> [targetLang] [sourceLang]');
750
process.exit(1);
851
}
952

10-
const processor = new TouchChatProcessor();
53+
const ext = path.extname(filePath).toLowerCase();
54+
if (!isExtensionSupported(ext)) {
55+
console.error(`Unsupported file extension: ${ext}`);
56+
process.exit(1);
57+
}
58+
59+
const processor = getProcessor(filePath);
1160
const texts = await processor.extractTexts(filePath);
1261
console.log('Found texts:', texts.length);
13-
14-
// Group texts by length to help identify patterns
15-
const lengthGroups = {};
16-
texts.forEach(text => {
17-
const len = text.length;
18-
if (!lengthGroups[len]) lengthGroups[len] = [];
19-
lengthGroups[len].push(text);
62+
63+
const uniqueTexts = Array.from(new Set(texts.filter((t) => typeof t === 'string' && t.length > 0)));
64+
console.log('Unique texts:', uniqueTexts.length);
65+
66+
const translations = await translateTexts(uniqueTexts, targetLanguage, sourceLanguage);
67+
const translationMap = new Map();
68+
uniqueTexts.forEach((text, i) => {
69+
translationMap.set(text, translations[i] || text);
2070
});
2171

22-
console.log('\nTexts grouped by length:');
23-
Object.entries(lengthGroups)
24-
.sort(([a], [b]) => parseInt(a) - parseInt(b))
25-
.forEach(([len, group]) => {
26-
if (group.length > 0) {
27-
console.log(`\nLength ${len} (${group.length} items):`);
28-
// Show first 5 examples
29-
console.log(group.slice(0, 5));
30-
}
31-
});
32-
33-
// Show unique texts to identify duplicates
34-
const unique = new Set(texts);
35-
console.log('\nUnique texts:', unique.size);
36-
console.log('Duplicate texts:', texts.length - unique.size);
72+
const parsed = path.parse(filePath);
73+
const outputPath = path.join(parsed.dir, `${parsed.name}-${targetLanguage}${parsed.ext}`);
74+
75+
await processor.processTexts(filePath, translationMap, outputPath);
76+
console.log(`Translated file saved to: ${outputPath}`);
3777
}
3878

3979
main().catch(console.error);

src/processors/snapProcessor.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -722,20 +722,105 @@ class SnapProcessor extends BaseProcessor {
722722
if (!isNodeRuntime()) {
723723
throw new Error('processTexts is only supported in Node.js environments for Snap files.');
724724
}
725-
// Load the tree, apply translations, and save to new file
725+
const fs = getFs();
726+
const path = getPath();
727+
728+
if (typeof filePathOrBuffer === 'string') {
729+
const inputPath = filePathOrBuffer;
730+
const outputDir = path.dirname(outputPath);
731+
if (!fs.existsSync(outputDir)) {
732+
fs.mkdirSync(outputDir, { recursive: true });
733+
}
734+
if (fs.existsSync(outputPath)) {
735+
fs.unlinkSync(outputPath);
736+
}
737+
fs.copyFileSync(inputPath, outputPath);
738+
739+
const Database = requireBetterSqlite3();
740+
const db = new Database(outputPath, { readonly: false });
741+
try {
742+
const getColumns = (tableName: string): Set<string> => {
743+
try {
744+
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
745+
name: string;
746+
}>;
747+
return new Set(rows.map((row) => row.name));
748+
} catch {
749+
return new Set();
750+
}
751+
};
752+
753+
const pageColumns = getColumns('Page');
754+
const buttonColumns = getColumns('Button');
755+
756+
const pageUpdates: string[] = [];
757+
const pageWhere: string[] = [];
758+
const pageColumnsToUse: Array<'Name' | 'Title'> = [];
759+
760+
if (pageColumns.has('Name')) {
761+
pageUpdates.push('Name = ?');
762+
pageWhere.push('Name = ?');
763+
pageColumnsToUse.push('Name');
764+
}
765+
if (pageColumns.has('Title')) {
766+
pageUpdates.push('Title = ?');
767+
pageWhere.push('Title = ?');
768+
pageColumnsToUse.push('Title');
769+
}
770+
771+
const updatePage =
772+
pageUpdates.length > 0
773+
? db.prepare(
774+
`UPDATE Page SET ${pageUpdates.join(', ')} WHERE ${pageWhere.join(' OR ')}`
775+
)
776+
: null;
777+
778+
const updateLabel = buttonColumns.has('Label')
779+
? db.prepare('UPDATE Button SET Label = ? WHERE Label = ?')
780+
: null;
781+
const updateMessage = buttonColumns.has('Message')
782+
? db.prepare('UPDATE Button SET Message = ? WHERE Message = ?')
783+
: null;
784+
785+
const entries = Array.from(translations.entries());
786+
const applyUpdates = db.transaction(() => {
787+
entries.forEach(([original, translated]) => {
788+
if (!translated || translated === original) {
789+
return;
790+
}
791+
if (updatePage) {
792+
const updateValues: string[] = [];
793+
pageColumnsToUse.forEach(() => updateValues.push(translated));
794+
pageColumnsToUse.forEach(() => updateValues.push(original));
795+
updatePage.run(...updateValues);
796+
}
797+
if (updateLabel) {
798+
updateLabel.run(translated, original);
799+
}
800+
if (updateMessage) {
801+
updateMessage.run(translated, original);
802+
}
803+
});
804+
});
805+
applyUpdates();
806+
} finally {
807+
db.close();
808+
}
809+
810+
return fs.readFileSync(outputPath);
811+
}
812+
813+
// Fallback for buffer inputs: rebuild from tree (may drop Snap assets)
726814
const tree = await this.loadIntoTree(filePathOrBuffer);
727815

728-
// Apply translations to all text content
729816
Object.values(tree.pages).forEach((page) => {
730-
// Translate page names
731817
if (page.name && translations.has(page.name)) {
732818
const translatedName = translations.get(page.name);
733819
if (translatedName !== undefined) {
734820
page.name = translatedName;
735821
}
736822
}
737823

738-
// Translate button labels and messages
739824
page.buttons.forEach((button) => {
740825
if (button.label && translations.has(button.label)) {
741826
const translatedLabel = translations.get(button.label);
@@ -752,9 +837,7 @@ class SnapProcessor extends BaseProcessor {
752837
});
753838
});
754839

755-
// Save the translated tree and return its content
756840
await this.saveFromTree(tree, outputPath);
757-
const fs = getFs();
758841
return fs.readFileSync(outputPath);
759842
}
760843

0 commit comments

Comments
 (0)