pabal-resource-mcp 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_APP_SLUG
4
4
  } from "../chunk-DLCIXAUB.js";
5
5
  import {
6
+ getGeminiApiKey,
6
7
  getKeywordResearchDir,
7
8
  getProductsDir,
8
9
  getPublicDir,
@@ -10,7 +11,7 @@ import {
10
11
  getPushDataDir,
11
12
  loadAsoFromConfig,
12
13
  saveAsoToAsoDir
13
- } from "../chunk-W62HB2ZL.js";
14
+ } from "../chunk-YKUBCCJA.js";
14
15
  import {
15
16
  DEFAULT_LOCALE,
16
17
  appStoreToUnified,
@@ -3384,25 +3385,626 @@ Context around ${pos}: ${context}`
3384
3385
  };
3385
3386
  }
3386
3387
 
3387
- // src/tools/apps/init.ts
3388
- import fs9 from "fs";
3389
- import path9 from "path";
3388
+ // src/tools/aso/localize-screenshots.ts
3390
3389
  import { z as z7 } from "zod";
3391
3390
  import { zodToJsonSchema as zodToJsonSchema7 } from "zod-to-json-schema";
3391
+ import fs12 from "fs";
3392
+ import path11 from "path";
3393
+
3394
+ // src/tools/aso/utils/localize-screenshots/scan-screenshots.util.ts
3395
+ import fs9 from "fs";
3396
+ import path9 from "path";
3397
+ function getScreenshotsDir(slug) {
3398
+ const productsDir = getProductsDir();
3399
+ return path9.join(productsDir, slug, "screenshots");
3400
+ }
3401
+ function scanLocaleScreenshots(slug, locale) {
3402
+ const screenshotsDir = getScreenshotsDir(slug);
3403
+ const localeDir = path9.join(screenshotsDir, locale);
3404
+ if (!fs9.existsSync(localeDir)) {
3405
+ return [];
3406
+ }
3407
+ const screenshots = [];
3408
+ const deviceTypes = ["phone", "tablet"];
3409
+ for (const deviceType of deviceTypes) {
3410
+ const deviceDir = path9.join(localeDir, deviceType);
3411
+ if (!fs9.existsSync(deviceDir)) {
3412
+ continue;
3413
+ }
3414
+ const files = fs9.readdirSync(deviceDir).filter((file) => /\.(png|jpg|jpeg|webp)$/i.test(file)).sort((a, b) => {
3415
+ const numA = parseInt(a.match(/\d+/)?.[0] || "0");
3416
+ const numB = parseInt(b.match(/\d+/)?.[0] || "0");
3417
+ return numA - numB;
3418
+ });
3419
+ for (const filename of files) {
3420
+ screenshots.push({
3421
+ type: deviceType,
3422
+ filename,
3423
+ fullPath: path9.join(deviceDir, filename)
3424
+ });
3425
+ }
3426
+ }
3427
+ return screenshots;
3428
+ }
3429
+
3430
+ // src/tools/aso/utils/localize-screenshots/gemini-image-translator.util.ts
3431
+ import { GoogleGenAI, Modality } from "@google/genai";
3432
+ import fs10 from "fs";
3433
+ import path10 from "path";
3434
+ var LANGUAGE_NAMES = {
3435
+ "en-US": "English (US)",
3436
+ "en-GB": "English (UK)",
3437
+ "en-AU": "English (Australia)",
3438
+ "en-CA": "English (Canada)",
3439
+ "ko-KR": "Korean",
3440
+ "ja-JP": "Japanese",
3441
+ "zh-Hans": "Simplified Chinese",
3442
+ "zh-Hant": "Traditional Chinese",
3443
+ "zh-CN": "Simplified Chinese",
3444
+ "zh-TW": "Traditional Chinese",
3445
+ "fr-FR": "French",
3446
+ "fr-CA": "French (Canada)",
3447
+ "de-DE": "German",
3448
+ "es-ES": "Spanish (Spain)",
3449
+ "es-419": "Spanish (Latin America)",
3450
+ "es-MX": "Spanish (Mexico)",
3451
+ "pt-BR": "Portuguese (Brazil)",
3452
+ "pt-PT": "Portuguese (Portugal)",
3453
+ "it-IT": "Italian",
3454
+ "nl-NL": "Dutch",
3455
+ "ru-RU": "Russian",
3456
+ "ar": "Arabic",
3457
+ "ar-SA": "Arabic",
3458
+ "hi-IN": "Hindi",
3459
+ "th-TH": "Thai",
3460
+ "vi-VN": "Vietnamese",
3461
+ "id-ID": "Indonesian",
3462
+ "ms-MY": "Malay",
3463
+ "tr-TR": "Turkish",
3464
+ "pl-PL": "Polish",
3465
+ "uk-UA": "Ukrainian",
3466
+ "cs-CZ": "Czech",
3467
+ "el-GR": "Greek",
3468
+ "ro-RO": "Romanian",
3469
+ "hu-HU": "Hungarian",
3470
+ "sv-SE": "Swedish",
3471
+ "da-DK": "Danish",
3472
+ "fi-FI": "Finnish",
3473
+ "no-NO": "Norwegian",
3474
+ "he-IL": "Hebrew",
3475
+ "sk-SK": "Slovak",
3476
+ "bg-BG": "Bulgarian",
3477
+ "hr-HR": "Croatian",
3478
+ "ca-ES": "Catalan"
3479
+ };
3480
+ function getLanguageName(locale) {
3481
+ if (LANGUAGE_NAMES[locale]) {
3482
+ return LANGUAGE_NAMES[locale];
3483
+ }
3484
+ const baseCode = locale.split("-")[0];
3485
+ const matchingKey = Object.keys(LANGUAGE_NAMES).find(
3486
+ (key) => key.startsWith(baseCode + "-") || key === baseCode
3487
+ );
3488
+ if (matchingKey) {
3489
+ return LANGUAGE_NAMES[matchingKey];
3490
+ }
3491
+ return locale;
3492
+ }
3493
+ function getGeminiClient() {
3494
+ const apiKey = getGeminiApiKey();
3495
+ return new GoogleGenAI({ apiKey });
3496
+ }
3497
+ function readImageAsBase64(imagePath) {
3498
+ const buffer = fs10.readFileSync(imagePath);
3499
+ const base64 = buffer.toString("base64");
3500
+ const ext = path10.extname(imagePath).toLowerCase();
3501
+ let mimeType = "image/png";
3502
+ if (ext === ".jpg" || ext === ".jpeg") {
3503
+ mimeType = "image/jpeg";
3504
+ } else if (ext === ".webp") {
3505
+ mimeType = "image/webp";
3506
+ }
3507
+ return { data: base64, mimeType };
3508
+ }
3509
+ async function translateImage(sourcePath, sourceLocale, targetLocale, outputPath) {
3510
+ try {
3511
+ const client = getGeminiClient();
3512
+ const sourceLanguage = getLanguageName(sourceLocale);
3513
+ const targetLanguage = getLanguageName(targetLocale);
3514
+ const { data: imageData, mimeType } = readImageAsBase64(sourcePath);
3515
+ const prompt = `This is an app screenshot with text in ${sourceLanguage}.
3516
+ Please translate ONLY the text/words in this image to ${targetLanguage}.
3517
+
3518
+ IMPORTANT INSTRUCTIONS:
3519
+ - Keep the EXACT same layout, design, colors, and visual elements
3520
+ - Only translate the visible text content to ${targetLanguage}
3521
+ - Maintain the same font style and text positioning as much as possible
3522
+ - Do NOT add any new elements or remove existing design elements
3523
+ - The output should look identical except the text language is ${targetLanguage}
3524
+ - Preserve all icons, images, and graphical elements exactly as they are`;
3525
+ const response = await client.models.generateContent({
3526
+ model: "imagen-3.0-generate-002",
3527
+ contents: [
3528
+ {
3529
+ role: "user",
3530
+ parts: [
3531
+ { text: prompt },
3532
+ {
3533
+ inlineData: {
3534
+ mimeType,
3535
+ data: imageData
3536
+ }
3537
+ }
3538
+ ]
3539
+ }
3540
+ ],
3541
+ config: {
3542
+ responseModalities: [Modality.TEXT, Modality.IMAGE]
3543
+ }
3544
+ });
3545
+ const candidates = response.candidates;
3546
+ if (!candidates || candidates.length === 0) {
3547
+ return {
3548
+ success: false,
3549
+ error: "No response from Gemini API"
3550
+ };
3551
+ }
3552
+ const parts = candidates[0].content?.parts;
3553
+ if (!parts) {
3554
+ return {
3555
+ success: false,
3556
+ error: "No content parts in response"
3557
+ };
3558
+ }
3559
+ for (const part of parts) {
3560
+ if (part.inlineData?.data) {
3561
+ const imageBuffer = Buffer.from(part.inlineData.data, "base64");
3562
+ const outputDir = path10.dirname(outputPath);
3563
+ if (!fs10.existsSync(outputDir)) {
3564
+ fs10.mkdirSync(outputDir, { recursive: true });
3565
+ }
3566
+ fs10.writeFileSync(outputPath, imageBuffer);
3567
+ return {
3568
+ success: true,
3569
+ outputPath
3570
+ };
3571
+ }
3572
+ }
3573
+ return {
3574
+ success: false,
3575
+ error: "No image data in Gemini response"
3576
+ };
3577
+ } catch (error) {
3578
+ const message = error instanceof Error ? error.message : String(error);
3579
+ return {
3580
+ success: false,
3581
+ error: message
3582
+ };
3583
+ }
3584
+ }
3585
+ async function translateImagesWithProgress(translations, onProgress) {
3586
+ let successful = 0;
3587
+ let failed = 0;
3588
+ const errors = [];
3589
+ for (const translation of translations) {
3590
+ const progress = {
3591
+ sourceLocale: translation.sourceLocale,
3592
+ targetLocale: translation.targetLocale,
3593
+ deviceType: translation.deviceType,
3594
+ filename: translation.filename,
3595
+ status: "translating"
3596
+ };
3597
+ onProgress?.(progress);
3598
+ const result = await translateImage(
3599
+ translation.sourcePath,
3600
+ translation.sourceLocale,
3601
+ translation.targetLocale,
3602
+ translation.outputPath
3603
+ );
3604
+ if (result.success) {
3605
+ successful++;
3606
+ progress.status = "completed";
3607
+ } else {
3608
+ failed++;
3609
+ progress.status = "failed";
3610
+ progress.error = result.error;
3611
+ errors.push({ path: translation.sourcePath, error: result.error || "Unknown error" });
3612
+ }
3613
+ onProgress?.(progress);
3614
+ await new Promise((resolve) => setTimeout(resolve, 500));
3615
+ }
3616
+ return { successful, failed, errors };
3617
+ }
3618
+
3619
+ // src/tools/aso/utils/localize-screenshots/image-resizer.util.ts
3620
+ import sharp from "sharp";
3621
+ import fs11 from "fs";
3622
+ async function getImageDimensions(imagePath) {
3623
+ const metadata = await sharp(imagePath).metadata();
3624
+ if (!metadata.width || !metadata.height) {
3625
+ throw new Error(`Unable to read dimensions from ${imagePath}`);
3626
+ }
3627
+ return {
3628
+ width: metadata.width,
3629
+ height: metadata.height
3630
+ };
3631
+ }
3632
+ async function resizeImage(inputPath, outputPath, targetDimensions) {
3633
+ await sharp(inputPath).resize(targetDimensions.width, targetDimensions.height, {
3634
+ fit: "fill",
3635
+ // Exact resize to target dimensions
3636
+ withoutEnlargement: false
3637
+ // Allow enlargement if needed
3638
+ }).toFile(outputPath + ".tmp");
3639
+ fs11.renameSync(outputPath + ".tmp", outputPath);
3640
+ }
3641
+ async function validateAndResizeImage(sourcePath, translatedPath) {
3642
+ const sourceDimensions = await getImageDimensions(sourcePath);
3643
+ const translatedDimensions = await getImageDimensions(translatedPath);
3644
+ const needsResize = sourceDimensions.width !== translatedDimensions.width || sourceDimensions.height !== translatedDimensions.height;
3645
+ if (needsResize) {
3646
+ await resizeImage(translatedPath, translatedPath, sourceDimensions);
3647
+ }
3648
+ return {
3649
+ resized: needsResize,
3650
+ sourceDimensions,
3651
+ translatedDimensions,
3652
+ finalDimensions: sourceDimensions
3653
+ };
3654
+ }
3655
+ async function batchValidateAndResize(pairs) {
3656
+ let resizedCount = 0;
3657
+ const errors = [];
3658
+ for (const { sourcePath, translatedPath } of pairs) {
3659
+ try {
3660
+ if (!fs11.existsSync(translatedPath)) {
3661
+ continue;
3662
+ }
3663
+ const result = await validateAndResizeImage(sourcePath, translatedPath);
3664
+ if (result.resized) {
3665
+ resizedCount++;
3666
+ }
3667
+ } catch (error) {
3668
+ const message = error instanceof Error ? error.message : String(error);
3669
+ errors.push({ path: translatedPath, error: message });
3670
+ }
3671
+ }
3672
+ return {
3673
+ total: pairs.length,
3674
+ resized: resizedCount,
3675
+ errors
3676
+ };
3677
+ }
3678
+
3679
+ // src/tools/aso/localize-screenshots.ts
3680
+ var TOOL_NAME3 = "localize-screenshots";
3681
+ var localizeScreenshotsInputSchema = z7.object({
3682
+ appName: z7.string().describe(
3683
+ "App name, slug, bundleId, or packageName to search for. Will be validated using search-app."
3684
+ ),
3685
+ targetLocales: z7.array(z7.string()).optional().describe(
3686
+ "Specific target locales to translate to. If not provided, all supported locales from the product will be used."
3687
+ ),
3688
+ deviceTypes: z7.array(z7.enum(["phone", "tablet"])).optional().default(["phone", "tablet"]).describe("Device types to process (default: both phone and tablet)"),
3689
+ dryRun: z7.boolean().optional().default(false).describe("Preview mode - shows what would be translated without actually translating"),
3690
+ skipExisting: z7.boolean().optional().default(true).describe("Skip translation if target file already exists (default: true)")
3691
+ });
3692
+ var jsonSchema7 = zodToJsonSchema7(localizeScreenshotsInputSchema, {
3693
+ name: "LocalizeScreenshotsInput",
3694
+ $refStrategy: "none"
3695
+ });
3696
+ var inputSchema7 = jsonSchema7.definitions?.LocalizeScreenshotsInput || jsonSchema7;
3697
+ var localizeScreenshotsTool = {
3698
+ name: TOOL_NAME3,
3699
+ description: `Translate app screenshots to multiple languages using Gemini API.
3700
+
3701
+ **IMPORTANT:** This tool uses the search-app tool internally to validate the app. You can provide an approximate name, bundleId, or packageName.
3702
+
3703
+ This tool:
3704
+ 1. Validates the app exists in registered-apps.json
3705
+ 2. Reads supported locales from public/products/{slug}/locales/ directory
3706
+ 3. Scans screenshots from the primary locale's screenshots folder
3707
+ 4. Uses Gemini API (imagen-3.0-generate-002) to translate text in images
3708
+ 5. Validates output image dimensions match source and resizes if needed
3709
+
3710
+ **Requirements:**
3711
+ - GEMINI_API_KEY or GOOGLE_API_KEY environment variable must be set
3712
+ - Screenshots must be in: public/products/{slug}/screenshots/{locale}/phone/ and /tablet/
3713
+ - Locale files must exist in: public/products/{slug}/locales/
3714
+
3715
+ **Example structure:**
3716
+ \`\`\`
3717
+ public/products/my-app/
3718
+ \u251C\u2500\u2500 config.json
3719
+ \u251C\u2500\u2500 locales/
3720
+ \u2502 \u251C\u2500\u2500 en-US.json (primary)
3721
+ \u2502 \u251C\u2500\u2500 ko-KR.json
3722
+ \u2502 \u2514\u2500\u2500 ja-JP.json
3723
+ \u2514\u2500\u2500 screenshots/
3724
+ \u2514\u2500\u2500 en-US/
3725
+ \u251C\u2500\u2500 phone/
3726
+ \u2502 \u251C\u2500\u2500 1.png
3727
+ \u2502 \u2514\u2500\u2500 2.png
3728
+ \u2514\u2500\u2500 tablet/
3729
+ \u2514\u2500\u2500 1.png
3730
+ \`\`\``,
3731
+ inputSchema: inputSchema7
3732
+ };
3733
+ function validateApp(appName) {
3734
+ const { app } = findRegisteredApp(appName);
3735
+ if (!app) {
3736
+ throw new Error(
3737
+ `App not found: "${appName}". Use search-app tool to find the correct app name.`
3738
+ );
3739
+ }
3740
+ return {
3741
+ slug: app.slug,
3742
+ name: app.name || app.slug
3743
+ };
3744
+ }
3745
+ function getSupportedLocales(slug) {
3746
+ const { config, locales } = loadProductLocales(slug);
3747
+ const allLocales = Object.keys(locales);
3748
+ if (allLocales.length === 0) {
3749
+ throw new Error(`No locale files found for ${slug}`);
3750
+ }
3751
+ const primaryLocale = resolvePrimaryLocale(config, locales);
3752
+ return {
3753
+ primaryLocale,
3754
+ allLocales
3755
+ };
3756
+ }
3757
+ function getTargetLocales(allLocales, primaryLocale, requestedTargets) {
3758
+ let targets = allLocales.filter((locale) => locale !== primaryLocale);
3759
+ if (requestedTargets && requestedTargets.length > 0) {
3760
+ const validTargets = requestedTargets.filter((t) => allLocales.includes(t));
3761
+ const invalidTargets = requestedTargets.filter(
3762
+ (t) => !allLocales.includes(t)
3763
+ );
3764
+ if (invalidTargets.length > 0) {
3765
+ console.warn(
3766
+ `Warning: Some requested locales are not supported: ${invalidTargets.join(", ")}`
3767
+ );
3768
+ }
3769
+ targets = validTargets.filter((t) => t !== primaryLocale);
3770
+ }
3771
+ return targets;
3772
+ }
3773
+ function buildTranslationTasks(slug, screenshots, primaryLocale, targetLocales, skipExisting) {
3774
+ const tasks = [];
3775
+ const screenshotsDir = getScreenshotsDir(slug);
3776
+ for (const targetLocale of targetLocales) {
3777
+ for (const screenshot of screenshots) {
3778
+ const outputPath = path11.join(
3779
+ screenshotsDir,
3780
+ targetLocale,
3781
+ screenshot.type,
3782
+ screenshot.filename
3783
+ );
3784
+ if (skipExisting && fs12.existsSync(outputPath)) {
3785
+ continue;
3786
+ }
3787
+ tasks.push({
3788
+ sourcePath: screenshot.fullPath,
3789
+ sourceLocale: primaryLocale,
3790
+ targetLocale,
3791
+ outputPath,
3792
+ deviceType: screenshot.type,
3793
+ filename: screenshot.filename
3794
+ });
3795
+ }
3796
+ }
3797
+ return tasks;
3798
+ }
3799
+ async function handleLocalizeScreenshots(input) {
3800
+ const {
3801
+ appName,
3802
+ targetLocales: requestedTargetLocales,
3803
+ deviceTypes = ["phone", "tablet"],
3804
+ dryRun = false,
3805
+ skipExisting = true
3806
+ } = input;
3807
+ const results = [];
3808
+ let appInfo;
3809
+ try {
3810
+ appInfo = validateApp(appName);
3811
+ results.push(`\u2705 App found: ${appInfo.name} (${appInfo.slug})`);
3812
+ } catch (error) {
3813
+ return {
3814
+ content: [
3815
+ {
3816
+ type: "text",
3817
+ text: `\u274C ${error instanceof Error ? error.message : String(error)}`
3818
+ }
3819
+ ]
3820
+ };
3821
+ }
3822
+ let primaryLocale;
3823
+ let allLocales;
3824
+ try {
3825
+ const localeInfo = getSupportedLocales(appInfo.slug);
3826
+ primaryLocale = localeInfo.primaryLocale;
3827
+ allLocales = localeInfo.allLocales;
3828
+ results.push(`\u{1F4CD} Primary locale: ${primaryLocale}`);
3829
+ results.push(`\u{1F310} Supported locales: ${allLocales.join(", ")}`);
3830
+ } catch (error) {
3831
+ return {
3832
+ content: [
3833
+ {
3834
+ type: "text",
3835
+ text: `\u274C ${error instanceof Error ? error.message : String(error)}`
3836
+ }
3837
+ ]
3838
+ };
3839
+ }
3840
+ const targetLocales = getTargetLocales(
3841
+ allLocales,
3842
+ primaryLocale,
3843
+ requestedTargetLocales
3844
+ );
3845
+ if (targetLocales.length === 0) {
3846
+ return {
3847
+ content: [
3848
+ {
3849
+ type: "text",
3850
+ text: `\u274C No target locales to translate to. Primary locale: ${primaryLocale}, Available: ${allLocales.join(", ")}`
3851
+ }
3852
+ ]
3853
+ };
3854
+ }
3855
+ results.push(`\u{1F3AF} Target locales: ${targetLocales.join(", ")}`);
3856
+ const sourceScreenshots = scanLocaleScreenshots(appInfo.slug, primaryLocale);
3857
+ const filteredScreenshots = sourceScreenshots.filter(
3858
+ (s) => deviceTypes.includes(s.type)
3859
+ );
3860
+ if (filteredScreenshots.length === 0) {
3861
+ const screenshotsDir2 = getScreenshotsDir(appInfo.slug);
3862
+ return {
3863
+ content: [
3864
+ {
3865
+ type: "text",
3866
+ text: `\u274C No screenshots found in ${screenshotsDir2}/${primaryLocale}/
3867
+
3868
+ Expected structure:
3869
+ ${screenshotsDir2}/${primaryLocale}/phone/1.png, 2.png, ...
3870
+ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
3871
+ }
3872
+ ]
3873
+ };
3874
+ }
3875
+ const phoneCount = filteredScreenshots.filter((s) => s.type === "phone").length;
3876
+ const tabletCount = filteredScreenshots.filter((s) => s.type === "tablet").length;
3877
+ results.push(`\u{1F4F8} Source screenshots: ${phoneCount} phone, ${tabletCount} tablet`);
3878
+ const tasks = buildTranslationTasks(
3879
+ appInfo.slug,
3880
+ filteredScreenshots,
3881
+ primaryLocale,
3882
+ targetLocales,
3883
+ skipExisting
3884
+ );
3885
+ if (tasks.length === 0) {
3886
+ results.push(`
3887
+ \u2705 All screenshots already translated (skipExisting=true)`);
3888
+ return {
3889
+ content: [
3890
+ {
3891
+ type: "text",
3892
+ text: results.join("\n")
3893
+ }
3894
+ ]
3895
+ };
3896
+ }
3897
+ results.push(`
3898
+ \u{1F4CB} Translation tasks: ${tasks.length} images to translate`);
3899
+ if (dryRun) {
3900
+ results.push(`
3901
+ \u{1F50D} DRY RUN - No actual translations will be performed
3902
+ `);
3903
+ const tasksByLocale = {};
3904
+ for (const task of tasks) {
3905
+ if (!tasksByLocale[task.targetLocale]) {
3906
+ tasksByLocale[task.targetLocale] = [];
3907
+ }
3908
+ tasksByLocale[task.targetLocale].push(task);
3909
+ }
3910
+ for (const [locale, localeTasks] of Object.entries(tasksByLocale)) {
3911
+ results.push(`
3912
+ \u{1F4C1} ${locale}:`);
3913
+ for (const task of localeTasks) {
3914
+ results.push(` - ${task.deviceType}/${task.filename}`);
3915
+ }
3916
+ }
3917
+ return {
3918
+ content: [
3919
+ {
3920
+ type: "text",
3921
+ text: results.join("\n")
3922
+ }
3923
+ ]
3924
+ };
3925
+ }
3926
+ results.push(`
3927
+ \u{1F680} Starting translations...`);
3928
+ const translationResult = await translateImagesWithProgress(
3929
+ tasks,
3930
+ (progress) => {
3931
+ if (progress.status === "completed") {
3932
+ console.log(
3933
+ `\u2705 ${progress.targetLocale}/${progress.deviceType}/${progress.filename}`
3934
+ );
3935
+ } else if (progress.status === "failed") {
3936
+ console.log(
3937
+ `\u274C ${progress.targetLocale}/${progress.deviceType}/${progress.filename}: ${progress.error}`
3938
+ );
3939
+ }
3940
+ }
3941
+ );
3942
+ results.push(`
3943
+ \u{1F4CA} Translation Results:`);
3944
+ results.push(` \u2705 Successful: ${translationResult.successful}`);
3945
+ results.push(` \u274C Failed: ${translationResult.failed}`);
3946
+ if (translationResult.errors.length > 0) {
3947
+ results.push(`
3948
+ \u26A0\uFE0F Errors:`);
3949
+ for (const err of translationResult.errors.slice(0, 5)) {
3950
+ results.push(` - ${path11.basename(err.path)}: ${err.error}`);
3951
+ }
3952
+ if (translationResult.errors.length > 5) {
3953
+ results.push(` ... and ${translationResult.errors.length - 5} more errors`);
3954
+ }
3955
+ }
3956
+ if (translationResult.successful > 0) {
3957
+ results.push(`
3958
+ \u{1F50D} Validating image dimensions...`);
3959
+ const successfulTasks = tasks.filter((t) => fs12.existsSync(t.outputPath));
3960
+ const resizePairs = successfulTasks.map((t) => ({
3961
+ sourcePath: t.sourcePath,
3962
+ translatedPath: t.outputPath
3963
+ }));
3964
+ const resizeResult = await batchValidateAndResize(resizePairs);
3965
+ if (resizeResult.resized > 0) {
3966
+ results.push(` \u{1F527} Resized ${resizeResult.resized} images to match source dimensions`);
3967
+ } else {
3968
+ results.push(` \u2705 All image dimensions match source`);
3969
+ }
3970
+ if (resizeResult.errors.length > 0) {
3971
+ results.push(` \u26A0\uFE0F Resize errors: ${resizeResult.errors.length}`);
3972
+ }
3973
+ }
3974
+ const screenshotsDir = getScreenshotsDir(appInfo.slug);
3975
+ results.push(`
3976
+ \u{1F4C1} Output location: ${screenshotsDir}/`);
3977
+ results.push(`
3978
+ \u2705 Screenshot localization complete!`);
3979
+ return {
3980
+ content: [
3981
+ {
3982
+ type: "text",
3983
+ text: results.join("\n")
3984
+ }
3985
+ ]
3986
+ };
3987
+ }
3988
+
3989
+ // src/tools/apps/init.ts
3990
+ import fs13 from "fs";
3991
+ import path12 from "path";
3992
+ import { z as z8 } from "zod";
3993
+ import { zodToJsonSchema as zodToJsonSchema8 } from "zod-to-json-schema";
3392
3994
  var listSlugDirs = (dir) => {
3393
- if (!fs9.existsSync(dir)) return [];
3394
- return fs9.readdirSync(dir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
3995
+ if (!fs13.existsSync(dir)) return [];
3996
+ return fs13.readdirSync(dir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
3395
3997
  };
3396
- var initProjectInputSchema = z7.object({
3397
- slug: z7.string().trim().optional().describe(
3998
+ var initProjectInputSchema = z8.object({
3999
+ slug: z8.string().trim().optional().describe(
3398
4000
  "Optional product slug to focus on. Defaults to all slugs in .aso/pullData/products/"
3399
4001
  )
3400
4002
  });
3401
- var jsonSchema7 = zodToJsonSchema7(initProjectInputSchema, {
4003
+ var jsonSchema8 = zodToJsonSchema8(initProjectInputSchema, {
3402
4004
  name: "InitProjectInput",
3403
4005
  $refStrategy: "none"
3404
4006
  });
3405
- var inputSchema7 = jsonSchema7.definitions?.InitProjectInput || jsonSchema7;
4007
+ var inputSchema8 = jsonSchema8.definitions?.InitProjectInput || jsonSchema8;
3406
4008
  var initProjectTool = {
3407
4009
  name: "init-project",
3408
4010
  description: `Guides the initialization flow: run pabal-store-api-mcp Init, then convert ASO pullData into public/products/[slug]/.
@@ -3413,10 +4015,10 @@ Steps:
3413
4015
  1) Ensure pabal-store-api-mcp 'init' ran and .aso/pullData/products/[slug]/ exists (path from ~/.config/pabal-mcp/config.json dataDir)
3414
4016
  2) Convert pulled ASO data -> public/products/[slug]/ using pabal-resource-mcp tools (aso-to-public, public-to-aso dry run)
3415
4017
  3) Validate outputs and next actions`,
3416
- inputSchema: inputSchema7
4018
+ inputSchema: inputSchema8
3417
4019
  };
3418
4020
  async function handleInitProject(input) {
3419
- const pullDataDir = path9.join(getPullDataDir(), "products");
4021
+ const pullDataDir = path12.join(getPullDataDir(), "products");
3420
4022
  const publicDir = getProductsDir();
3421
4023
  const pullDataSlugs = listSlugDirs(pullDataDir);
3422
4024
  const publicSlugs = listSlugDirs(publicDir);
@@ -3494,14 +4096,14 @@ async function handleInitProject(input) {
3494
4096
  }
3495
4097
 
3496
4098
  // src/tools/content/create-blog-html.ts
3497
- import fs11 from "fs";
3498
- import path11 from "path";
3499
- import { z as z8 } from "zod";
3500
- import { zodToJsonSchema as zodToJsonSchema8 } from "zod-to-json-schema";
4099
+ import fs15 from "fs";
4100
+ import path14 from "path";
4101
+ import { z as z9 } from "zod";
4102
+ import { zodToJsonSchema as zodToJsonSchema9 } from "zod-to-json-schema";
3501
4103
 
3502
4104
  // src/utils/blog.util.ts
3503
- import fs10 from "fs";
3504
- import path10 from "path";
4105
+ import fs14 from "fs";
4106
+ import path13 from "path";
3505
4107
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
3506
4108
  var BLOG_ROOT = "blogs";
3507
4109
  var removeDiacritics = (value) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
@@ -3589,13 +4191,13 @@ function resolveTargetLocales(input) {
3589
4191
  return fallback ? [fallback] : [];
3590
4192
  }
3591
4193
  function getBlogOutputPaths(options) {
3592
- const baseDir = path10.join(
4194
+ const baseDir = path13.join(
3593
4195
  options.publicDir,
3594
4196
  BLOG_ROOT,
3595
4197
  options.appSlug,
3596
4198
  options.slug
3597
4199
  );
3598
- const filePath = path10.join(baseDir, `${options.locale}.html`);
4200
+ const filePath = path13.join(baseDir, `${options.locale}.html`);
3599
4201
  const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
3600
4202
  return { baseDir, filePath, publicBasePath };
3601
4203
  }
@@ -3620,18 +4222,18 @@ function findExistingBlogPosts({
3620
4222
  publicDir,
3621
4223
  limit = 2
3622
4224
  }) {
3623
- const blogAppDir = path10.join(publicDir, BLOG_ROOT, appSlug);
3624
- if (!fs10.existsSync(blogAppDir)) {
4225
+ const blogAppDir = path13.join(publicDir, BLOG_ROOT, appSlug);
4226
+ if (!fs14.existsSync(blogAppDir)) {
3625
4227
  return [];
3626
4228
  }
3627
4229
  const posts = [];
3628
- const subdirs = fs10.readdirSync(blogAppDir, { withFileTypes: true });
4230
+ const subdirs = fs14.readdirSync(blogAppDir, { withFileTypes: true });
3629
4231
  for (const subdir of subdirs) {
3630
4232
  if (!subdir.isDirectory()) continue;
3631
- const localeFile = path10.join(blogAppDir, subdir.name, `${locale}.html`);
3632
- if (!fs10.existsSync(localeFile)) continue;
4233
+ const localeFile = path13.join(blogAppDir, subdir.name, `${locale}.html`);
4234
+ if (!fs14.existsSync(localeFile)) continue;
3633
4235
  try {
3634
- const htmlContent = fs10.readFileSync(localeFile, "utf-8");
4236
+ const htmlContent = fs14.readFileSync(localeFile, "utf-8");
3635
4237
  const { meta, body } = parseBlogHtml(htmlContent);
3636
4238
  if (meta && meta.locale === locale) {
3637
4239
  posts.push({
@@ -3658,43 +4260,43 @@ function findExistingBlogPosts({
3658
4260
  }
3659
4261
 
3660
4262
  // src/tools/content/create-blog-html.ts
3661
- var toJsonSchema5 = zodToJsonSchema8;
4263
+ var toJsonSchema5 = zodToJsonSchema9;
3662
4264
  var DATE_REGEX2 = /^\d{4}-\d{2}-\d{2}$/;
3663
- var createBlogHtmlInputSchema = z8.object({
3664
- appSlug: z8.string().trim().min(1).default(DEFAULT_APP_SLUG).describe(
4265
+ var createBlogHtmlInputSchema = z9.object({
4266
+ appSlug: z9.string().trim().min(1).default(DEFAULT_APP_SLUG).describe(
3665
4267
  `Product/app slug used for paths and CTAs. Defaults to "${DEFAULT_APP_SLUG}" when not provided.`
3666
4268
  ),
3667
- title: z8.string().trim().optional().describe(
4269
+ title: z9.string().trim().optional().describe(
3668
4270
  "English title used for slug (kebab-case). Falls back to topic when omitted."
3669
4271
  ),
3670
- topic: z8.string().trim().min(1, "topic is required").describe("Topic/angle to write about in the blog body"),
3671
- locale: z8.string().trim().min(1, "locale is required").describe(
4272
+ topic: z9.string().trim().min(1, "topic is required").describe("Topic/angle to write about in the blog body"),
4273
+ locale: z9.string().trim().min(1, "locale is required").describe(
3672
4274
  "Primary locale (e.g., 'en-US', 'ko-KR'). Required to determine the language for blog content generation."
3673
4275
  ),
3674
- locales: z8.array(z8.string().trim().min(1)).optional().describe(
4276
+ locales: z9.array(z9.string().trim().min(1)).optional().describe(
3675
4277
  "Optional list of locales to generate. Each locale gets its own HTML file. If provided, locale parameter is ignored."
3676
4278
  ),
3677
- content: z8.string().trim().min(1, "content is required").describe(
4279
+ content: z9.string().trim().min(1, "content is required").describe(
3678
4280
  "HTML content for the blog body. You (the LLM) must generate this HTML content based on the topic and locale. Structure should follow the pattern in public/en-US.html: paragraphs (<p>), headings (<h2>, <h3>), images (<img>), lists (<ul>, <li>), horizontal rules (<hr>), etc. The content should be written in the language corresponding to the locale."
3679
4281
  ),
3680
- description: z8.string().trim().min(1, "description is required").describe(
4282
+ description: z9.string().trim().min(1, "description is required").describe(
3681
4283
  "Meta description for the blog post. You (the LLM) must generate this based on the topic and locale. Should be a concise summary of the blog content in the language corresponding to the locale."
3682
4284
  ),
3683
- tags: z8.array(z8.string().trim().min(1)).optional().describe(
4285
+ tags: z9.array(z9.string().trim().min(1)).optional().describe(
3684
4286
  "Optional tags for BLOG_META. Defaults to tags derived from topic."
3685
4287
  ),
3686
- coverImage: z8.string().trim().optional().describe(
4288
+ coverImage: z9.string().trim().optional().describe(
3687
4289
  "Cover image path. Relative paths rewrite to /blogs/<app>/<slug>/..., default is /products/<appSlug>/og-image.png."
3688
4290
  ),
3689
- publishedAt: z8.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
3690
- modifiedAt: z8.string().trim().regex(DATE_REGEX2, "modifiedAt must use YYYY-MM-DD").optional().describe("Last modified date (YYYY-MM-DD). Defaults to publishedAt."),
3691
- overwrite: z8.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
4291
+ publishedAt: z9.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
4292
+ modifiedAt: z9.string().trim().regex(DATE_REGEX2, "modifiedAt must use YYYY-MM-DD").optional().describe("Last modified date (YYYY-MM-DD). Defaults to publishedAt."),
4293
+ overwrite: z9.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
3692
4294
  }).describe("Generate static HTML blog posts with BLOG_META headers.");
3693
- var jsonSchema8 = toJsonSchema5(createBlogHtmlInputSchema, {
4295
+ var jsonSchema9 = toJsonSchema5(createBlogHtmlInputSchema, {
3694
4296
  name: "CreateBlogHtmlInput",
3695
4297
  $refStrategy: "none"
3696
4298
  });
3697
- var inputSchema8 = jsonSchema8.definitions?.CreateBlogHtmlInput || jsonSchema8;
4299
+ var inputSchema9 = jsonSchema9.definitions?.CreateBlogHtmlInput || jsonSchema9;
3698
4300
  var createBlogHtmlTool = {
3699
4301
  name: "create-blog-html",
3700
4302
  description: `Generate HTML blog posts under public/blogs/<appSlug>/<slug>/<locale>.html with a BLOG_META block.
@@ -3731,7 +4333,7 @@ Supports multiple locales when locales[] is provided. Each locale gets its own H
3731
4333
  1. Read existing posts in that locale to understand the writing style
3732
4334
  2. Generate appropriate content in that locale's language
3733
4335
  3. Match the writing style and format of existing posts`,
3734
- inputSchema: inputSchema8
4336
+ inputSchema: inputSchema9
3735
4337
  };
3736
4338
  async function handleCreateBlogHtml(input) {
3737
4339
  const publicDir = getPublicDir();
@@ -3774,7 +4376,7 @@ async function handleCreateBlogHtml(input) {
3774
4376
  }
3775
4377
  const output = {
3776
4378
  slug,
3777
- baseDir: path11.join(publicDir, "blogs", appSlug, slug),
4379
+ baseDir: path14.join(publicDir, "blogs", appSlug, slug),
3778
4380
  files: [],
3779
4381
  coverImage: coverImage && coverImage.trim().length > 0 ? coverImage.trim() : `/products/${appSlug}/og-image.png`,
3780
4382
  metaByLocale: {}
@@ -3788,7 +4390,7 @@ async function handleCreateBlogHtml(input) {
3788
4390
  })
3789
4391
  );
3790
4392
  const existing = plannedFiles.filter(
3791
- ({ filePath }) => fs11.existsSync(filePath)
4393
+ ({ filePath }) => fs15.existsSync(filePath)
3792
4394
  );
3793
4395
  if (existing.length > 0 && !overwrite) {
3794
4396
  const existingList = existing.map((f) => f.filePath).join("\n- ");
@@ -3797,7 +4399,7 @@ async function handleCreateBlogHtml(input) {
3797
4399
  - ${existingList}`
3798
4400
  );
3799
4401
  }
3800
- fs11.mkdirSync(output.baseDir, { recursive: true });
4402
+ fs15.mkdirSync(output.baseDir, { recursive: true });
3801
4403
  for (const locale of targetLocales) {
3802
4404
  const { filePath } = getBlogOutputPaths({
3803
4405
  appSlug,
@@ -3823,7 +4425,7 @@ async function handleCreateBlogHtml(input) {
3823
4425
  meta,
3824
4426
  content
3825
4427
  });
3826
- fs11.writeFileSync(filePath, html, "utf-8");
4428
+ fs15.writeFileSync(filePath, html, "utf-8");
3827
4429
  output.files.push({ locale, path: filePath });
3828
4430
  }
3829
4431
  const summaryLines = [
@@ -3902,6 +4504,14 @@ var tools = [
3902
4504
  handler: handleKeywordResearch,
3903
4505
  category: "aso"
3904
4506
  },
4507
+ {
4508
+ name: localizeScreenshotsTool.name,
4509
+ description: localizeScreenshotsTool.description,
4510
+ inputSchema: localizeScreenshotsTool.inputSchema,
4511
+ zodSchema: localizeScreenshotsInputSchema,
4512
+ handler: handleLocalizeScreenshots,
4513
+ category: "aso"
4514
+ },
3905
4515
  // Apps Tools
3906
4516
  {
3907
4517
  name: initProjectTool.name,
@@ -3937,6 +4547,7 @@ function getToolDefinitions() {
3937
4547
  initProjectTool,
3938
4548
  createBlogHtmlTool,
3939
4549
  keywordResearchTool,
4550
+ localizeScreenshotsTool,
3940
4551
  searchAppTool,
3941
4552
  validateAsoTool
3942
4553
  ];
@@ -0,0 +1,386 @@
1
+ import {
2
+ DEFAULT_LOCALE,
3
+ isAppStoreLocale,
4
+ isGooglePlayLocale,
5
+ isSupportedLocale
6
+ } from "./chunk-BOWRBVVV.js";
7
+
8
+ // src/utils/config.util.ts
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import os from "os";
12
+ function getAsoDataDir() {
13
+ const configPath = path.join(
14
+ os.homedir(),
15
+ ".config",
16
+ "pabal-mcp",
17
+ "config.json"
18
+ );
19
+ if (!fs.existsSync(configPath)) {
20
+ throw new Error(
21
+ `Config file not found at ${configPath}. Please create the config file and set the 'dataDir' property to specify the ASO data directory.`
22
+ );
23
+ }
24
+ try {
25
+ const configContent = fs.readFileSync(configPath, "utf-8");
26
+ const config = JSON.parse(configContent);
27
+ if (!config.dataDir) {
28
+ throw new Error(
29
+ `'dataDir' property is not set in ${configPath}. Please set 'dataDir' to specify the ASO data directory.`
30
+ );
31
+ }
32
+ if (path.isAbsolute(config.dataDir)) {
33
+ return config.dataDir;
34
+ }
35
+ return path.resolve(os.homedir(), config.dataDir);
36
+ } catch (error) {
37
+ if (error instanceof Error && error.message.includes("dataDir")) {
38
+ throw error;
39
+ }
40
+ throw new Error(
41
+ `Failed to read config from ${configPath}: ${error instanceof Error ? error.message : String(error)}`
42
+ );
43
+ }
44
+ }
45
+ function getPullDataDir() {
46
+ return path.join(getAsoDataDir(), ".aso", "pullData");
47
+ }
48
+ function getPushDataDir() {
49
+ return path.join(getAsoDataDir(), ".aso", "pushData");
50
+ }
51
+ function getPublicDir() {
52
+ return path.join(getAsoDataDir(), "public");
53
+ }
54
+ function getKeywordResearchDir() {
55
+ return path.join(getAsoDataDir(), ".aso", "keywordResearch");
56
+ }
57
+ function getProductsDir() {
58
+ return path.join(getPublicDir(), "products");
59
+ }
60
+ function loadConfig() {
61
+ const configPath = path.join(
62
+ os.homedir(),
63
+ ".config",
64
+ "pabal-mcp",
65
+ "config.json"
66
+ );
67
+ if (!fs.existsSync(configPath)) {
68
+ return {};
69
+ }
70
+ try {
71
+ const configContent = fs.readFileSync(configPath, "utf-8");
72
+ return JSON.parse(configContent);
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+ function getGeminiApiKey() {
78
+ const config = loadConfig();
79
+ if (config.gemini?.apiKey) {
80
+ return config.gemini.apiKey;
81
+ }
82
+ const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
83
+ if (envKey) {
84
+ return envKey;
85
+ }
86
+ throw new Error(
87
+ `Gemini API key not found. Set it in ~/.config/pabal-mcp/config.json under "gemini.apiKey" or use GEMINI_API_KEY environment variable.`
88
+ );
89
+ }
90
+
91
+ // src/utils/aso-converter.ts
92
+ import fs2 from "fs";
93
+ import path2 from "path";
94
+ function generateFullDescription(localeData, metadata = {}) {
95
+ const { aso, landing } = localeData;
96
+ const template = aso?.template;
97
+ if (!template) {
98
+ return "";
99
+ }
100
+ const landingFeatures = landing?.features?.items || [];
101
+ const landingScreenshots = landing?.screenshots?.images || [];
102
+ const keyHeading = template.keyFeaturesHeading || "Key Features";
103
+ const featuresHeading = template.featuresHeading || "Additional Features";
104
+ const parts = [template.intro];
105
+ if (landingFeatures.length > 0) {
106
+ parts.push(
107
+ "",
108
+ keyHeading,
109
+ "",
110
+ ...landingFeatures.map(
111
+ (feature) => [`\u25B6\uFE0E ${feature.title}`, feature.body || ""].filter(Boolean).join("\n")
112
+ )
113
+ );
114
+ }
115
+ if (landingScreenshots.length > 0) {
116
+ parts.push("", featuresHeading, "");
117
+ parts.push(
118
+ ...landingScreenshots.map(
119
+ (screenshot) => [`\u25B6\uFE0E ${screenshot.title}`, screenshot.description || ""].filter(Boolean).join("\n")
120
+ )
121
+ );
122
+ }
123
+ parts.push("", template.outro);
124
+ const includeSupport = template.includeSupportLinks ?? true;
125
+ if (includeSupport) {
126
+ const contactLines = [
127
+ metadata.instagram ? `Instagram: ${metadata.instagram}` : null,
128
+ metadata.contactEmail ? `Email: ${metadata.contactEmail}` : null,
129
+ metadata.termsUrl ? `- Terms of Use: ${metadata.termsUrl}` : null,
130
+ metadata.privacyUrl ? `- Privacy Policy: ${metadata.privacyUrl}` : null
131
+ ].filter((line) => line !== null);
132
+ if (contactLines.length > 0) {
133
+ parts.push("", "[Contact & Support]", "", ...contactLines);
134
+ }
135
+ }
136
+ return parts.join("\n");
137
+ }
138
+ function loadAsoFromConfig(slug) {
139
+ const productsDir = getProductsDir();
140
+ const configPath = path2.join(productsDir, slug, "config.json");
141
+ console.debug(`[loadAsoFromConfig] Looking for ${slug}:`);
142
+ console.debug(` - productsDir: ${productsDir}`);
143
+ console.debug(` - configPath: ${configPath}`);
144
+ console.debug(` - configPath exists: ${fs2.existsSync(configPath)}`);
145
+ if (!fs2.existsSync(configPath)) {
146
+ console.warn(`[loadAsoFromConfig] Config file not found at ${configPath}`);
147
+ return {};
148
+ }
149
+ try {
150
+ const configContent = fs2.readFileSync(configPath, "utf-8");
151
+ const config = JSON.parse(configContent);
152
+ const localesDir = path2.join(productsDir, slug, "locales");
153
+ console.debug(` - localesDir: ${localesDir}`);
154
+ console.debug(` - localesDir exists: ${fs2.existsSync(localesDir)}`);
155
+ if (!fs2.existsSync(localesDir)) {
156
+ console.warn(
157
+ `[loadAsoFromConfig] Locales directory not found at ${localesDir}`
158
+ );
159
+ return {};
160
+ }
161
+ const localeFiles = fs2.readdirSync(localesDir).filter((f) => f.endsWith(".json"));
162
+ const locales = {};
163
+ for (const file of localeFiles) {
164
+ const localeCode = file.replace(".json", "");
165
+ const localePath = path2.join(localesDir, file);
166
+ const localeContent = fs2.readFileSync(localePath, "utf-8");
167
+ locales[localeCode] = JSON.parse(localeContent);
168
+ }
169
+ console.debug(
170
+ ` - Found ${Object.keys(locales).length} locale file(s): ${Object.keys(
171
+ locales
172
+ ).join(", ")}`
173
+ );
174
+ if (Object.keys(locales).length === 0) {
175
+ console.warn(
176
+ `[loadAsoFromConfig] No locale files found in ${localesDir}`
177
+ );
178
+ }
179
+ const defaultLocale = config.content?.defaultLocale || DEFAULT_LOCALE;
180
+ const asoData = {};
181
+ if (config.packageName) {
182
+ const googlePlayLocales = {};
183
+ const metadata = config.metadata || {};
184
+ const screenshots = metadata.screenshots || {};
185
+ for (const [locale, localeData] of Object.entries(locales)) {
186
+ if (!isSupportedLocale(locale)) {
187
+ console.debug(
188
+ `Skipping locale ${locale} - not a valid unified locale`
189
+ );
190
+ continue;
191
+ }
192
+ if (!isGooglePlayLocale(locale)) {
193
+ console.debug(
194
+ `Skipping locale ${locale} - not supported by Google Play`
195
+ );
196
+ continue;
197
+ }
198
+ const aso = localeData.aso || {};
199
+ if (!aso || !aso.title && !aso.shortDescription) {
200
+ console.warn(
201
+ `Locale ${locale} has no ASO data (title or shortDescription)`
202
+ );
203
+ }
204
+ const localeScreenshots = {
205
+ phone: screenshots.phone?.map(
206
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
207
+ ),
208
+ tablet: screenshots.tablet?.map(
209
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
210
+ )
211
+ };
212
+ googlePlayLocales[locale] = {
213
+ title: aso.title || "",
214
+ shortDescription: aso.shortDescription || "",
215
+ fullDescription: generateFullDescription(localeData, metadata),
216
+ packageName: config.packageName,
217
+ defaultLanguage: locale,
218
+ screenshots: {
219
+ phone: localeScreenshots.phone || [],
220
+ tablet: localeScreenshots.tablet
221
+ },
222
+ contactEmail: metadata.contactEmail
223
+ };
224
+ }
225
+ const googleLocaleKeys = Object.keys(googlePlayLocales);
226
+ if (googleLocaleKeys.length > 0) {
227
+ const hasConfigDefault = isGooglePlayLocale(defaultLocale) && Boolean(googlePlayLocales[defaultLocale]);
228
+ const resolvedDefault = hasConfigDefault ? defaultLocale : googlePlayLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : googleLocaleKeys[0];
229
+ asoData.googlePlay = {
230
+ locales: googlePlayLocales,
231
+ defaultLocale: resolvedDefault
232
+ };
233
+ }
234
+ }
235
+ if (config.bundleId) {
236
+ const appStoreLocales = {};
237
+ const metadata = config.metadata || {};
238
+ const screenshots = metadata.screenshots || {};
239
+ for (const [locale, localeData] of Object.entries(locales)) {
240
+ if (!isSupportedLocale(locale)) {
241
+ console.debug(
242
+ `Skipping locale ${locale} - not a valid unified locale`
243
+ );
244
+ continue;
245
+ }
246
+ if (!isAppStoreLocale(locale)) {
247
+ console.debug(
248
+ `Skipping locale ${locale} - not supported by App Store`
249
+ );
250
+ continue;
251
+ }
252
+ const aso = localeData.aso || {};
253
+ if (!aso || !aso.title && !aso.shortDescription) {
254
+ console.warn(
255
+ `Locale ${locale} has no ASO data (title or shortDescription)`
256
+ );
257
+ }
258
+ const localeScreenshots = {
259
+ phone: screenshots.phone?.map(
260
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
261
+ ),
262
+ tablet: screenshots.tablet?.map(
263
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
264
+ )
265
+ };
266
+ appStoreLocales[locale] = {
267
+ name: aso.title || "",
268
+ subtitle: aso.subtitle,
269
+ description: generateFullDescription(localeData, metadata),
270
+ keywords: Array.isArray(aso.keywords) ? aso.keywords.join(", ") : aso.keywords,
271
+ promotionalText: void 0,
272
+ bundleId: config.bundleId,
273
+ locale,
274
+ supportUrl: metadata.supportUrl,
275
+ marketingUrl: metadata.marketingUrl,
276
+ privacyPolicyUrl: metadata.privacyUrl,
277
+ screenshots: {
278
+ // 폰 스크린샷을 iphone65로 매핑
279
+ iphone65: localeScreenshots.phone || [],
280
+ // 태블릿 스크린샷을 ipadPro129로 매핑
281
+ ipadPro129: localeScreenshots.tablet
282
+ }
283
+ };
284
+ }
285
+ const appStoreLocaleKeys = Object.keys(appStoreLocales);
286
+ if (appStoreLocaleKeys.length > 0) {
287
+ const hasConfigDefault = isAppStoreLocale(defaultLocale) && Boolean(appStoreLocales[defaultLocale]);
288
+ const resolvedDefault = hasConfigDefault ? defaultLocale : appStoreLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : appStoreLocaleKeys[0];
289
+ asoData.appStore = {
290
+ locales: appStoreLocales,
291
+ defaultLocale: resolvedDefault
292
+ };
293
+ }
294
+ }
295
+ const hasGooglePlay = !!asoData.googlePlay;
296
+ const hasAppStore = !!asoData.appStore;
297
+ console.debug(`[loadAsoFromConfig] Result for ${slug}:`);
298
+ console.debug(
299
+ ` - Google Play data: ${hasGooglePlay ? "found" : "not found"}`
300
+ );
301
+ console.debug(` - App Store data: ${hasAppStore ? "found" : "not found"}`);
302
+ if (!hasGooglePlay && !hasAppStore) {
303
+ console.warn(`[loadAsoFromConfig] No ASO data generated for ${slug}`);
304
+ }
305
+ return asoData;
306
+ } catch (error) {
307
+ console.error(
308
+ `[loadAsoFromConfig] Failed to load ASO data from config for ${slug}:`,
309
+ error
310
+ );
311
+ return {};
312
+ }
313
+ }
314
+ function saveAsoToConfig(slug, config) {
315
+ const productsDir = getProductsDir();
316
+ const configPath = path2.join(productsDir, slug, "config.json");
317
+ fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
318
+ }
319
+ function saveAsoToAsoDir(slug, asoData) {
320
+ const rootDir = getPushDataDir();
321
+ if (asoData.googlePlay) {
322
+ const asoPath = path2.join(
323
+ rootDir,
324
+ "products",
325
+ slug,
326
+ "store",
327
+ "google-play",
328
+ "aso-data.json"
329
+ );
330
+ const dir = path2.dirname(asoPath);
331
+ if (!fs2.existsSync(dir)) {
332
+ fs2.mkdirSync(dir, { recursive: true });
333
+ }
334
+ const googlePlayData = asoData.googlePlay;
335
+ const multilingualData = "locales" in googlePlayData ? googlePlayData : {
336
+ locales: {
337
+ [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
338
+ },
339
+ defaultLocale: googlePlayData.defaultLanguage || DEFAULT_LOCALE
340
+ };
341
+ fs2.writeFileSync(
342
+ asoPath,
343
+ JSON.stringify({ googlePlay: multilingualData }, null, 2) + "\n",
344
+ "utf-8"
345
+ );
346
+ }
347
+ if (asoData.appStore) {
348
+ const asoPath = path2.join(
349
+ rootDir,
350
+ "products",
351
+ slug,
352
+ "store",
353
+ "app-store",
354
+ "aso-data.json"
355
+ );
356
+ const dir = path2.dirname(asoPath);
357
+ if (!fs2.existsSync(dir)) {
358
+ fs2.mkdirSync(dir, { recursive: true });
359
+ }
360
+ const appStoreData = asoData.appStore;
361
+ const multilingualData = "locales" in appStoreData ? appStoreData : {
362
+ locales: {
363
+ [appStoreData.locale || DEFAULT_LOCALE]: appStoreData
364
+ },
365
+ defaultLocale: appStoreData.locale || DEFAULT_LOCALE
366
+ };
367
+ fs2.writeFileSync(
368
+ asoPath,
369
+ JSON.stringify({ appStore: multilingualData }, null, 2) + "\n",
370
+ "utf-8"
371
+ );
372
+ }
373
+ }
374
+
375
+ export {
376
+ getAsoDataDir,
377
+ getPullDataDir,
378
+ getPushDataDir,
379
+ getPublicDir,
380
+ getKeywordResearchDir,
381
+ getProductsDir,
382
+ getGeminiApiKey,
383
+ loadAsoFromConfig,
384
+ saveAsoToConfig,
385
+ saveAsoToAsoDir
386
+ };
package/dist/index.d.ts CHANGED
@@ -48,5 +48,10 @@ declare function getKeywordResearchDir(): string;
48
48
  * Get the products directory path (dataDir/public/products)
49
49
  */
50
50
  declare function getProductsDir(): string;
51
+ /**
52
+ * Get the Gemini API key from config.json or environment variable
53
+ * Priority: config.json > GEMINI_API_KEY > GOOGLE_API_KEY
54
+ */
55
+ declare function getGeminiApiKey(): string;
51
56
 
52
- export { AsoData, ProductConfig, getAsoDataDir, getKeywordResearchDir, getProductsDir, getPublicDir, getPullDataDir, getPushDataDir, loadAsoFromConfig, saveAsoToAsoDir, saveAsoToConfig };
57
+ export { AsoData, ProductConfig, getAsoDataDir, getGeminiApiKey, getKeywordResearchDir, getProductsDir, getPublicDir, getPullDataDir, getPushDataDir, loadAsoFromConfig, saveAsoToAsoDir, saveAsoToConfig };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getAsoDataDir,
3
+ getGeminiApiKey,
3
4
  getKeywordResearchDir,
4
5
  getProductsDir,
5
6
  getPublicDir,
@@ -8,7 +9,7 @@ import {
8
9
  loadAsoFromConfig,
9
10
  saveAsoToAsoDir,
10
11
  saveAsoToConfig
11
- } from "./chunk-W62HB2ZL.js";
12
+ } from "./chunk-YKUBCCJA.js";
12
13
  import {
13
14
  APP_STORE_TO_UNIFIED,
14
15
  DEFAULT_LOCALE,
@@ -52,6 +53,7 @@ export {
52
53
  convertObjectToAppStore,
53
54
  convertObjectToGooglePlay,
54
55
  getAsoDataDir,
56
+ getGeminiApiKey,
55
57
  getKeywordResearchDir,
56
58
  getProductsDir,
57
59
  getPublicDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-resource-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",
@@ -61,14 +61,17 @@
61
61
  "prepublishOnly": "npm run build"
62
62
  },
63
63
  "dependencies": {
64
+ "@google/genai": "^1.38.0",
64
65
  "@googleapis/androidpublisher": "^33.2.0",
65
66
  "@modelcontextprotocol/sdk": "^1.22.0",
66
67
  "appstore-connect-sdk": "^1.3.2",
68
+ "sharp": "^0.34.5",
67
69
  "zod": "^3.25.76",
68
70
  "zod-to-json-schema": "^3.25.0"
69
71
  },
70
72
  "devDependencies": {
71
73
  "@types/node": "^20.11.30",
74
+ "@types/sharp": "^0.31.1",
72
75
  "tsup": "^8.0.0",
73
76
  "tsx": "^4.20.6",
74
77
  "typescript": "^5.4.5"