pabal-resource-mcp 1.5.0 → 1.5.2

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,661 @@ 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 } from "@google/genai";
3432
+ import fs10 from "fs";
3433
+ import path10 from "path";
3434
+ import sharp from "sharp";
3435
+ var LANGUAGE_NAMES = {
3436
+ "en-US": "English (US)",
3437
+ "en-GB": "English (UK)",
3438
+ "en-AU": "English (Australia)",
3439
+ "en-CA": "English (Canada)",
3440
+ "ko-KR": "Korean",
3441
+ "ja-JP": "Japanese",
3442
+ "zh-Hans": "Simplified Chinese",
3443
+ "zh-Hant": "Traditional Chinese",
3444
+ "zh-CN": "Simplified Chinese",
3445
+ "zh-TW": "Traditional Chinese",
3446
+ "fr-FR": "French",
3447
+ "fr-CA": "French (Canada)",
3448
+ "de-DE": "German",
3449
+ "es-ES": "Spanish (Spain)",
3450
+ "es-419": "Spanish (Latin America)",
3451
+ "es-MX": "Spanish (Mexico)",
3452
+ "pt-BR": "Portuguese (Brazil)",
3453
+ "pt-PT": "Portuguese (Portugal)",
3454
+ "it-IT": "Italian",
3455
+ "nl-NL": "Dutch",
3456
+ "ru-RU": "Russian",
3457
+ "ar": "Arabic",
3458
+ "ar-SA": "Arabic",
3459
+ "hi-IN": "Hindi",
3460
+ "th-TH": "Thai",
3461
+ "vi-VN": "Vietnamese",
3462
+ "id-ID": "Indonesian",
3463
+ "ms-MY": "Malay",
3464
+ "tr-TR": "Turkish",
3465
+ "pl-PL": "Polish",
3466
+ "uk-UA": "Ukrainian",
3467
+ "cs-CZ": "Czech",
3468
+ "el-GR": "Greek",
3469
+ "ro-RO": "Romanian",
3470
+ "hu-HU": "Hungarian",
3471
+ "sv-SE": "Swedish",
3472
+ "da-DK": "Danish",
3473
+ "fi-FI": "Finnish",
3474
+ "no-NO": "Norwegian",
3475
+ "he-IL": "Hebrew",
3476
+ "sk-SK": "Slovak",
3477
+ "bg-BG": "Bulgarian",
3478
+ "hr-HR": "Croatian",
3479
+ "ca-ES": "Catalan"
3480
+ };
3481
+ function getLanguageName(locale) {
3482
+ if (LANGUAGE_NAMES[locale]) {
3483
+ return LANGUAGE_NAMES[locale];
3484
+ }
3485
+ const baseCode = locale.split("-")[0];
3486
+ const matchingKey = Object.keys(LANGUAGE_NAMES).find(
3487
+ (key) => key.startsWith(baseCode + "-") || key === baseCode
3488
+ );
3489
+ if (matchingKey) {
3490
+ return LANGUAGE_NAMES[matchingKey];
3491
+ }
3492
+ return locale;
3493
+ }
3494
+ function getGeminiClient() {
3495
+ const apiKey = getGeminiApiKey();
3496
+ return new GoogleGenAI({ apiKey });
3497
+ }
3498
+ function readImageAsBase64(imagePath) {
3499
+ const buffer = fs10.readFileSync(imagePath);
3500
+ const base64 = buffer.toString("base64");
3501
+ const ext = path10.extname(imagePath).toLowerCase();
3502
+ let mimeType = "image/png";
3503
+ if (ext === ".jpg" || ext === ".jpeg") {
3504
+ mimeType = "image/jpeg";
3505
+ } else if (ext === ".webp") {
3506
+ mimeType = "image/webp";
3507
+ }
3508
+ return { data: base64, mimeType };
3509
+ }
3510
+ async function getImageDimensions(imagePath) {
3511
+ const metadata = await sharp(imagePath).metadata();
3512
+ return {
3513
+ width: metadata.width || 1080,
3514
+ height: metadata.height || 1920
3515
+ };
3516
+ }
3517
+ function calculateAspectRatio(width, height) {
3518
+ const ratio = width / height;
3519
+ if (Math.abs(ratio - 1) < 0.1) return "1:1";
3520
+ if (Math.abs(ratio - 9 / 16) < 0.1) return "9:16";
3521
+ if (Math.abs(ratio - 16 / 9) < 0.1) return "16:9";
3522
+ if (Math.abs(ratio - 3 / 4) < 0.1) return "3:4";
3523
+ if (Math.abs(ratio - 4 / 3) < 0.1) return "4:3";
3524
+ return ratio < 1 ? "9:16" : "16:9";
3525
+ }
3526
+ async function translateImage(sourcePath, sourceLocale, targetLocale, outputPath) {
3527
+ try {
3528
+ const client = getGeminiClient();
3529
+ const sourceLanguage = getLanguageName(sourceLocale);
3530
+ const targetLanguage = getLanguageName(targetLocale);
3531
+ const { width, height } = await getImageDimensions(sourcePath);
3532
+ const aspectRatio = calculateAspectRatio(width, height);
3533
+ const { data: imageData, mimeType } = readImageAsBase64(sourcePath);
3534
+ const prompt = `This is an app screenshot with text in ${sourceLanguage}.
3535
+ Please translate ONLY the text/words in this image to ${targetLanguage}.
3536
+
3537
+ IMPORTANT INSTRUCTIONS:
3538
+ - Keep the EXACT same layout, design, colors, and visual elements
3539
+ - Only translate the visible text content to ${targetLanguage}
3540
+ - Maintain the same font style and text positioning as much as possible
3541
+ - Do NOT add any new elements or remove existing design elements
3542
+ - The output should look identical except the text language is ${targetLanguage}
3543
+ - Preserve all icons, images, and graphical elements exactly as they are`;
3544
+ const chat = client.chats.create({
3545
+ model: "gemini-3-pro-image-preview",
3546
+ config: {
3547
+ responseModalities: ["TEXT", "IMAGE"]
3548
+ }
3549
+ });
3550
+ const response = await chat.sendMessage({
3551
+ message: [
3552
+ { text: prompt },
3553
+ {
3554
+ inlineData: {
3555
+ mimeType,
3556
+ data: imageData
3557
+ }
3558
+ }
3559
+ ],
3560
+ config: {
3561
+ responseModalities: ["TEXT", "IMAGE"],
3562
+ imageConfig: {
3563
+ aspectRatio
3564
+ }
3565
+ }
3566
+ });
3567
+ const candidates = response.candidates;
3568
+ if (!candidates || candidates.length === 0) {
3569
+ return {
3570
+ success: false,
3571
+ error: "No response from Gemini API"
3572
+ };
3573
+ }
3574
+ const parts = candidates[0].content?.parts;
3575
+ if (!parts) {
3576
+ return {
3577
+ success: false,
3578
+ error: "No content parts in response"
3579
+ };
3580
+ }
3581
+ for (const part of parts) {
3582
+ if (part.inlineData?.data) {
3583
+ const imageBuffer = Buffer.from(part.inlineData.data, "base64");
3584
+ const outputDir = path10.dirname(outputPath);
3585
+ if (!fs10.existsSync(outputDir)) {
3586
+ fs10.mkdirSync(outputDir, { recursive: true });
3587
+ }
3588
+ await sharp(imageBuffer).png().toFile(outputPath);
3589
+ return {
3590
+ success: true,
3591
+ outputPath
3592
+ };
3593
+ }
3594
+ }
3595
+ return {
3596
+ success: false,
3597
+ error: "No image data in Gemini response"
3598
+ };
3599
+ } catch (error) {
3600
+ const message = error instanceof Error ? error.message : String(error);
3601
+ return {
3602
+ success: false,
3603
+ error: message
3604
+ };
3605
+ }
3606
+ }
3607
+ async function translateImagesWithProgress(translations, onProgress) {
3608
+ let successful = 0;
3609
+ let failed = 0;
3610
+ const errors = [];
3611
+ const total = translations.length;
3612
+ for (let i = 0; i < translations.length; i++) {
3613
+ const translation = translations[i];
3614
+ const current = i + 1;
3615
+ const progress = {
3616
+ sourceLocale: translation.sourceLocale,
3617
+ targetLocale: translation.targetLocale,
3618
+ deviceType: translation.deviceType,
3619
+ filename: translation.filename,
3620
+ status: "translating",
3621
+ current,
3622
+ total
3623
+ };
3624
+ onProgress?.(progress);
3625
+ const result = await translateImage(
3626
+ translation.sourcePath,
3627
+ translation.sourceLocale,
3628
+ translation.targetLocale,
3629
+ translation.outputPath
3630
+ );
3631
+ if (result.success) {
3632
+ successful++;
3633
+ progress.status = "completed";
3634
+ } else {
3635
+ failed++;
3636
+ progress.status = "failed";
3637
+ progress.error = result.error;
3638
+ errors.push({
3639
+ path: translation.sourcePath,
3640
+ error: result.error || "Unknown error"
3641
+ });
3642
+ }
3643
+ onProgress?.(progress);
3644
+ await new Promise((resolve) => setTimeout(resolve, 500));
3645
+ }
3646
+ return { successful, failed, errors };
3647
+ }
3648
+
3649
+ // src/tools/aso/utils/localize-screenshots/image-resizer.util.ts
3650
+ import sharp2 from "sharp";
3651
+ import fs11 from "fs";
3652
+ async function getImageDimensions2(imagePath) {
3653
+ const metadata = await sharp2(imagePath).metadata();
3654
+ if (!metadata.width || !metadata.height) {
3655
+ throw new Error(`Unable to read dimensions from ${imagePath}`);
3656
+ }
3657
+ return {
3658
+ width: metadata.width,
3659
+ height: metadata.height
3660
+ };
3661
+ }
3662
+ async function resizeImage(inputPath, outputPath, targetDimensions) {
3663
+ await sharp2(inputPath).resize(targetDimensions.width, targetDimensions.height, {
3664
+ fit: "fill",
3665
+ // Exact resize to target dimensions
3666
+ withoutEnlargement: false
3667
+ // Allow enlargement if needed
3668
+ }).toFile(outputPath + ".tmp");
3669
+ fs11.renameSync(outputPath + ".tmp", outputPath);
3670
+ }
3671
+ async function validateAndResizeImage(sourcePath, translatedPath) {
3672
+ const sourceDimensions = await getImageDimensions2(sourcePath);
3673
+ const translatedDimensions = await getImageDimensions2(translatedPath);
3674
+ const needsResize = sourceDimensions.width !== translatedDimensions.width || sourceDimensions.height !== translatedDimensions.height;
3675
+ if (needsResize) {
3676
+ await resizeImage(translatedPath, translatedPath, sourceDimensions);
3677
+ }
3678
+ return {
3679
+ resized: needsResize,
3680
+ sourceDimensions,
3681
+ translatedDimensions,
3682
+ finalDimensions: sourceDimensions
3683
+ };
3684
+ }
3685
+ async function batchValidateAndResize(pairs) {
3686
+ let resizedCount = 0;
3687
+ const errors = [];
3688
+ for (const { sourcePath, translatedPath } of pairs) {
3689
+ try {
3690
+ if (!fs11.existsSync(translatedPath)) {
3691
+ continue;
3692
+ }
3693
+ const result = await validateAndResizeImage(sourcePath, translatedPath);
3694
+ if (result.resized) {
3695
+ resizedCount++;
3696
+ }
3697
+ } catch (error) {
3698
+ const message = error instanceof Error ? error.message : String(error);
3699
+ errors.push({ path: translatedPath, error: message });
3700
+ }
3701
+ }
3702
+ return {
3703
+ total: pairs.length,
3704
+ resized: resizedCount,
3705
+ errors
3706
+ };
3707
+ }
3708
+
3709
+ // src/tools/aso/localize-screenshots.ts
3710
+ var TOOL_NAME3 = "localize-screenshots";
3711
+ var localizeScreenshotsInputSchema = z7.object({
3712
+ appName: z7.string().describe(
3713
+ "App name, slug, bundleId, or packageName to search for. Will be validated using search-app."
3714
+ ),
3715
+ targetLocales: z7.array(z7.string()).optional().describe(
3716
+ "Specific target locales to translate to. If not provided, all supported locales from the product will be used."
3717
+ ),
3718
+ deviceTypes: z7.array(z7.enum(["phone", "tablet"])).optional().default(["phone", "tablet"]).describe("Device types to process (default: both phone and tablet)"),
3719
+ dryRun: z7.boolean().optional().default(false).describe("Preview mode - shows what would be translated without actually translating"),
3720
+ skipExisting: z7.boolean().optional().default(true).describe("Skip translation if target file already exists (default: true)")
3721
+ });
3722
+ var jsonSchema7 = zodToJsonSchema7(localizeScreenshotsInputSchema, {
3723
+ name: "LocalizeScreenshotsInput",
3724
+ $refStrategy: "none"
3725
+ });
3726
+ var inputSchema7 = jsonSchema7.definitions?.LocalizeScreenshotsInput || jsonSchema7;
3727
+ var localizeScreenshotsTool = {
3728
+ name: TOOL_NAME3,
3729
+ description: `Translate app screenshots to multiple languages using Gemini API.
3730
+
3731
+ **IMPORTANT:** This tool uses the search-app tool internally to validate the app. You can provide an approximate name, bundleId, or packageName.
3732
+
3733
+ This tool:
3734
+ 1. Validates the app exists in registered-apps.json
3735
+ 2. Reads supported locales from public/products/{slug}/locales/ directory
3736
+ 3. Scans screenshots from the primary locale's screenshots folder
3737
+ 4. Uses Gemini API (imagen-3.0-generate-002) to translate text in images
3738
+ 5. Validates output image dimensions match source and resizes if needed
3739
+
3740
+ **Requirements:**
3741
+ - GEMINI_API_KEY or GOOGLE_API_KEY environment variable must be set
3742
+ - Screenshots must be in: public/products/{slug}/screenshots/{locale}/phone/ and /tablet/
3743
+ - Locale files must exist in: public/products/{slug}/locales/
3744
+
3745
+ **Example structure:**
3746
+ \`\`\`
3747
+ public/products/my-app/
3748
+ \u251C\u2500\u2500 config.json
3749
+ \u251C\u2500\u2500 locales/
3750
+ \u2502 \u251C\u2500\u2500 en-US.json (primary)
3751
+ \u2502 \u251C\u2500\u2500 ko-KR.json
3752
+ \u2502 \u2514\u2500\u2500 ja-JP.json
3753
+ \u2514\u2500\u2500 screenshots/
3754
+ \u2514\u2500\u2500 en-US/
3755
+ \u251C\u2500\u2500 phone/
3756
+ \u2502 \u251C\u2500\u2500 1.png
3757
+ \u2502 \u2514\u2500\u2500 2.png
3758
+ \u2514\u2500\u2500 tablet/
3759
+ \u2514\u2500\u2500 1.png
3760
+ \`\`\``,
3761
+ inputSchema: inputSchema7
3762
+ };
3763
+ function validateApp(appName) {
3764
+ const { app } = findRegisteredApp(appName);
3765
+ if (!app) {
3766
+ throw new Error(
3767
+ `App not found: "${appName}". Use search-app tool to find the correct app name.`
3768
+ );
3769
+ }
3770
+ return {
3771
+ slug: app.slug,
3772
+ name: app.name || app.slug
3773
+ };
3774
+ }
3775
+ function getSupportedLocales(slug) {
3776
+ const { config, locales } = loadProductLocales(slug);
3777
+ const allLocales = Object.keys(locales);
3778
+ if (allLocales.length === 0) {
3779
+ throw new Error(`No locale files found for ${slug}`);
3780
+ }
3781
+ const primaryLocale = resolvePrimaryLocale(config, locales);
3782
+ return {
3783
+ primaryLocale,
3784
+ allLocales
3785
+ };
3786
+ }
3787
+ function getTargetLocales(allLocales, primaryLocale, requestedTargets) {
3788
+ let targets = allLocales.filter((locale) => locale !== primaryLocale);
3789
+ if (requestedTargets && requestedTargets.length > 0) {
3790
+ const validTargets = requestedTargets.filter((t) => allLocales.includes(t));
3791
+ const invalidTargets = requestedTargets.filter(
3792
+ (t) => !allLocales.includes(t)
3793
+ );
3794
+ if (invalidTargets.length > 0) {
3795
+ console.warn(
3796
+ `Warning: Some requested locales are not supported: ${invalidTargets.join(", ")}`
3797
+ );
3798
+ }
3799
+ targets = validTargets.filter((t) => t !== primaryLocale);
3800
+ }
3801
+ return targets;
3802
+ }
3803
+ function buildTranslationTasks(slug, screenshots, primaryLocale, targetLocales, skipExisting) {
3804
+ const tasks = [];
3805
+ const screenshotsDir = getScreenshotsDir(slug);
3806
+ for (const targetLocale of targetLocales) {
3807
+ for (const screenshot of screenshots) {
3808
+ const outputPath = path11.join(
3809
+ screenshotsDir,
3810
+ targetLocale,
3811
+ screenshot.type,
3812
+ screenshot.filename
3813
+ );
3814
+ if (skipExisting && fs12.existsSync(outputPath)) {
3815
+ continue;
3816
+ }
3817
+ tasks.push({
3818
+ sourcePath: screenshot.fullPath,
3819
+ sourceLocale: primaryLocale,
3820
+ targetLocale,
3821
+ outputPath,
3822
+ deviceType: screenshot.type,
3823
+ filename: screenshot.filename
3824
+ });
3825
+ }
3826
+ }
3827
+ return tasks;
3828
+ }
3829
+ async function handleLocalizeScreenshots(input) {
3830
+ const {
3831
+ appName,
3832
+ targetLocales: requestedTargetLocales,
3833
+ deviceTypes = ["phone", "tablet"],
3834
+ dryRun = false,
3835
+ skipExisting = true
3836
+ } = input;
3837
+ const results = [];
3838
+ let appInfo;
3839
+ try {
3840
+ appInfo = validateApp(appName);
3841
+ results.push(`\u2705 App found: ${appInfo.name} (${appInfo.slug})`);
3842
+ } catch (error) {
3843
+ return {
3844
+ content: [
3845
+ {
3846
+ type: "text",
3847
+ text: `\u274C ${error instanceof Error ? error.message : String(error)}`
3848
+ }
3849
+ ]
3850
+ };
3851
+ }
3852
+ let primaryLocale;
3853
+ let allLocales;
3854
+ try {
3855
+ const localeInfo = getSupportedLocales(appInfo.slug);
3856
+ primaryLocale = localeInfo.primaryLocale;
3857
+ allLocales = localeInfo.allLocales;
3858
+ results.push(`\u{1F4CD} Primary locale: ${primaryLocale}`);
3859
+ results.push(`\u{1F310} Supported locales: ${allLocales.join(", ")}`);
3860
+ } catch (error) {
3861
+ return {
3862
+ content: [
3863
+ {
3864
+ type: "text",
3865
+ text: `\u274C ${error instanceof Error ? error.message : String(error)}`
3866
+ }
3867
+ ]
3868
+ };
3869
+ }
3870
+ const targetLocales = getTargetLocales(
3871
+ allLocales,
3872
+ primaryLocale,
3873
+ requestedTargetLocales
3874
+ );
3875
+ if (targetLocales.length === 0) {
3876
+ return {
3877
+ content: [
3878
+ {
3879
+ type: "text",
3880
+ text: `\u274C No target locales to translate to. Primary locale: ${primaryLocale}, Available: ${allLocales.join(", ")}`
3881
+ }
3882
+ ]
3883
+ };
3884
+ }
3885
+ results.push(`\u{1F3AF} Target locales: ${targetLocales.join(", ")}`);
3886
+ const sourceScreenshots = scanLocaleScreenshots(appInfo.slug, primaryLocale);
3887
+ const filteredScreenshots = sourceScreenshots.filter(
3888
+ (s) => deviceTypes.includes(s.type)
3889
+ );
3890
+ if (filteredScreenshots.length === 0) {
3891
+ const screenshotsDir2 = getScreenshotsDir(appInfo.slug);
3892
+ return {
3893
+ content: [
3894
+ {
3895
+ type: "text",
3896
+ text: `\u274C No screenshots found in ${screenshotsDir2}/${primaryLocale}/
3897
+
3898
+ Expected structure:
3899
+ ${screenshotsDir2}/${primaryLocale}/phone/1.png, 2.png, ...
3900
+ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
3901
+ }
3902
+ ]
3903
+ };
3904
+ }
3905
+ const phoneCount = filteredScreenshots.filter((s) => s.type === "phone").length;
3906
+ const tabletCount = filteredScreenshots.filter((s) => s.type === "tablet").length;
3907
+ results.push(`\u{1F4F8} Source screenshots: ${phoneCount} phone, ${tabletCount} tablet`);
3908
+ const tasks = buildTranslationTasks(
3909
+ appInfo.slug,
3910
+ filteredScreenshots,
3911
+ primaryLocale,
3912
+ targetLocales,
3913
+ skipExisting
3914
+ );
3915
+ if (tasks.length === 0) {
3916
+ results.push(`
3917
+ \u2705 All screenshots already translated (skipExisting=true)`);
3918
+ return {
3919
+ content: [
3920
+ {
3921
+ type: "text",
3922
+ text: results.join("\n")
3923
+ }
3924
+ ]
3925
+ };
3926
+ }
3927
+ results.push(`
3928
+ \u{1F4CB} Translation tasks: ${tasks.length} images to translate`);
3929
+ if (dryRun) {
3930
+ results.push(`
3931
+ \u{1F50D} DRY RUN - No actual translations will be performed
3932
+ `);
3933
+ const tasksByLocale = {};
3934
+ for (const task of tasks) {
3935
+ if (!tasksByLocale[task.targetLocale]) {
3936
+ tasksByLocale[task.targetLocale] = [];
3937
+ }
3938
+ tasksByLocale[task.targetLocale].push(task);
3939
+ }
3940
+ for (const [locale, localeTasks] of Object.entries(tasksByLocale)) {
3941
+ results.push(`
3942
+ \u{1F4C1} ${locale}:`);
3943
+ for (const task of localeTasks) {
3944
+ results.push(` - ${task.deviceType}/${task.filename}`);
3945
+ }
3946
+ }
3947
+ return {
3948
+ content: [
3949
+ {
3950
+ type: "text",
3951
+ text: results.join("\n")
3952
+ }
3953
+ ]
3954
+ };
3955
+ }
3956
+ results.push(`
3957
+ \u{1F680} Starting translations...`);
3958
+ const translationResult = await translateImagesWithProgress(
3959
+ tasks,
3960
+ (progress) => {
3961
+ const progressPrefix = `[${progress.current}/${progress.total}]`;
3962
+ if (progress.status === "translating") {
3963
+ console.log(
3964
+ `\u{1F504} ${progressPrefix} Translating ${progress.targetLocale}/${progress.deviceType}/${progress.filename}...`
3965
+ );
3966
+ } else if (progress.status === "completed") {
3967
+ console.log(
3968
+ `\u2705 ${progressPrefix} ${progress.targetLocale}/${progress.deviceType}/${progress.filename}`
3969
+ );
3970
+ } else if (progress.status === "failed") {
3971
+ console.log(
3972
+ `\u274C ${progressPrefix} ${progress.targetLocale}/${progress.deviceType}/${progress.filename}: ${progress.error}`
3973
+ );
3974
+ }
3975
+ }
3976
+ );
3977
+ results.push(`
3978
+ \u{1F4CA} Translation Results:`);
3979
+ results.push(` \u2705 Successful: ${translationResult.successful}`);
3980
+ results.push(` \u274C Failed: ${translationResult.failed}`);
3981
+ if (translationResult.errors.length > 0) {
3982
+ results.push(`
3983
+ \u26A0\uFE0F Errors:`);
3984
+ for (const err of translationResult.errors.slice(0, 5)) {
3985
+ results.push(` - ${path11.basename(err.path)}: ${err.error}`);
3986
+ }
3987
+ if (translationResult.errors.length > 5) {
3988
+ results.push(` ... and ${translationResult.errors.length - 5} more errors`);
3989
+ }
3990
+ }
3991
+ if (translationResult.successful > 0) {
3992
+ results.push(`
3993
+ \u{1F50D} Validating image dimensions...`);
3994
+ const successfulTasks = tasks.filter((t) => fs12.existsSync(t.outputPath));
3995
+ const resizePairs = successfulTasks.map((t) => ({
3996
+ sourcePath: t.sourcePath,
3997
+ translatedPath: t.outputPath
3998
+ }));
3999
+ const resizeResult = await batchValidateAndResize(resizePairs);
4000
+ if (resizeResult.resized > 0) {
4001
+ results.push(` \u{1F527} Resized ${resizeResult.resized} images to match source dimensions`);
4002
+ } else {
4003
+ results.push(` \u2705 All image dimensions match source`);
4004
+ }
4005
+ if (resizeResult.errors.length > 0) {
4006
+ results.push(` \u26A0\uFE0F Resize errors: ${resizeResult.errors.length}`);
4007
+ }
4008
+ }
4009
+ const screenshotsDir = getScreenshotsDir(appInfo.slug);
4010
+ results.push(`
4011
+ \u{1F4C1} Output location: ${screenshotsDir}/`);
4012
+ results.push(`
4013
+ \u2705 Screenshot localization complete!`);
4014
+ return {
4015
+ content: [
4016
+ {
4017
+ type: "text",
4018
+ text: results.join("\n")
4019
+ }
4020
+ ]
4021
+ };
4022
+ }
4023
+
4024
+ // src/tools/apps/init.ts
4025
+ import fs13 from "fs";
4026
+ import path12 from "path";
4027
+ import { z as z8 } from "zod";
4028
+ import { zodToJsonSchema as zodToJsonSchema8 } from "zod-to-json-schema";
3392
4029
  var listSlugDirs = (dir) => {
3393
- if (!fs9.existsSync(dir)) return [];
3394
- return fs9.readdirSync(dir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
4030
+ if (!fs13.existsSync(dir)) return [];
4031
+ return fs13.readdirSync(dir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
3395
4032
  };
3396
- var initProjectInputSchema = z7.object({
3397
- slug: z7.string().trim().optional().describe(
4033
+ var initProjectInputSchema = z8.object({
4034
+ slug: z8.string().trim().optional().describe(
3398
4035
  "Optional product slug to focus on. Defaults to all slugs in .aso/pullData/products/"
3399
4036
  )
3400
4037
  });
3401
- var jsonSchema7 = zodToJsonSchema7(initProjectInputSchema, {
4038
+ var jsonSchema8 = zodToJsonSchema8(initProjectInputSchema, {
3402
4039
  name: "InitProjectInput",
3403
4040
  $refStrategy: "none"
3404
4041
  });
3405
- var inputSchema7 = jsonSchema7.definitions?.InitProjectInput || jsonSchema7;
4042
+ var inputSchema8 = jsonSchema8.definitions?.InitProjectInput || jsonSchema8;
3406
4043
  var initProjectTool = {
3407
4044
  name: "init-project",
3408
4045
  description: `Guides the initialization flow: run pabal-store-api-mcp Init, then convert ASO pullData into public/products/[slug]/.
@@ -3413,10 +4050,10 @@ Steps:
3413
4050
  1) Ensure pabal-store-api-mcp 'init' ran and .aso/pullData/products/[slug]/ exists (path from ~/.config/pabal-mcp/config.json dataDir)
3414
4051
  2) Convert pulled ASO data -> public/products/[slug]/ using pabal-resource-mcp tools (aso-to-public, public-to-aso dry run)
3415
4052
  3) Validate outputs and next actions`,
3416
- inputSchema: inputSchema7
4053
+ inputSchema: inputSchema8
3417
4054
  };
3418
4055
  async function handleInitProject(input) {
3419
- const pullDataDir = path9.join(getPullDataDir(), "products");
4056
+ const pullDataDir = path12.join(getPullDataDir(), "products");
3420
4057
  const publicDir = getProductsDir();
3421
4058
  const pullDataSlugs = listSlugDirs(pullDataDir);
3422
4059
  const publicSlugs = listSlugDirs(publicDir);
@@ -3494,14 +4131,14 @@ async function handleInitProject(input) {
3494
4131
  }
3495
4132
 
3496
4133
  // 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";
4134
+ import fs15 from "fs";
4135
+ import path14 from "path";
4136
+ import { z as z9 } from "zod";
4137
+ import { zodToJsonSchema as zodToJsonSchema9 } from "zod-to-json-schema";
3501
4138
 
3502
4139
  // src/utils/blog.util.ts
3503
- import fs10 from "fs";
3504
- import path10 from "path";
4140
+ import fs14 from "fs";
4141
+ import path13 from "path";
3505
4142
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
3506
4143
  var BLOG_ROOT = "blogs";
3507
4144
  var removeDiacritics = (value) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
@@ -3589,13 +4226,13 @@ function resolveTargetLocales(input) {
3589
4226
  return fallback ? [fallback] : [];
3590
4227
  }
3591
4228
  function getBlogOutputPaths(options) {
3592
- const baseDir = path10.join(
4229
+ const baseDir = path13.join(
3593
4230
  options.publicDir,
3594
4231
  BLOG_ROOT,
3595
4232
  options.appSlug,
3596
4233
  options.slug
3597
4234
  );
3598
- const filePath = path10.join(baseDir, `${options.locale}.html`);
4235
+ const filePath = path13.join(baseDir, `${options.locale}.html`);
3599
4236
  const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
3600
4237
  return { baseDir, filePath, publicBasePath };
3601
4238
  }
@@ -3620,18 +4257,18 @@ function findExistingBlogPosts({
3620
4257
  publicDir,
3621
4258
  limit = 2
3622
4259
  }) {
3623
- const blogAppDir = path10.join(publicDir, BLOG_ROOT, appSlug);
3624
- if (!fs10.existsSync(blogAppDir)) {
4260
+ const blogAppDir = path13.join(publicDir, BLOG_ROOT, appSlug);
4261
+ if (!fs14.existsSync(blogAppDir)) {
3625
4262
  return [];
3626
4263
  }
3627
4264
  const posts = [];
3628
- const subdirs = fs10.readdirSync(blogAppDir, { withFileTypes: true });
4265
+ const subdirs = fs14.readdirSync(blogAppDir, { withFileTypes: true });
3629
4266
  for (const subdir of subdirs) {
3630
4267
  if (!subdir.isDirectory()) continue;
3631
- const localeFile = path10.join(blogAppDir, subdir.name, `${locale}.html`);
3632
- if (!fs10.existsSync(localeFile)) continue;
4268
+ const localeFile = path13.join(blogAppDir, subdir.name, `${locale}.html`);
4269
+ if (!fs14.existsSync(localeFile)) continue;
3633
4270
  try {
3634
- const htmlContent = fs10.readFileSync(localeFile, "utf-8");
4271
+ const htmlContent = fs14.readFileSync(localeFile, "utf-8");
3635
4272
  const { meta, body } = parseBlogHtml(htmlContent);
3636
4273
  if (meta && meta.locale === locale) {
3637
4274
  posts.push({
@@ -3658,43 +4295,43 @@ function findExistingBlogPosts({
3658
4295
  }
3659
4296
 
3660
4297
  // src/tools/content/create-blog-html.ts
3661
- var toJsonSchema5 = zodToJsonSchema8;
4298
+ var toJsonSchema5 = zodToJsonSchema9;
3662
4299
  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(
4300
+ var createBlogHtmlInputSchema = z9.object({
4301
+ appSlug: z9.string().trim().min(1).default(DEFAULT_APP_SLUG).describe(
3665
4302
  `Product/app slug used for paths and CTAs. Defaults to "${DEFAULT_APP_SLUG}" when not provided.`
3666
4303
  ),
3667
- title: z8.string().trim().optional().describe(
4304
+ title: z9.string().trim().optional().describe(
3668
4305
  "English title used for slug (kebab-case). Falls back to topic when omitted."
3669
4306
  ),
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(
4307
+ topic: z9.string().trim().min(1, "topic is required").describe("Topic/angle to write about in the blog body"),
4308
+ locale: z9.string().trim().min(1, "locale is required").describe(
3672
4309
  "Primary locale (e.g., 'en-US', 'ko-KR'). Required to determine the language for blog content generation."
3673
4310
  ),
3674
- locales: z8.array(z8.string().trim().min(1)).optional().describe(
4311
+ locales: z9.array(z9.string().trim().min(1)).optional().describe(
3675
4312
  "Optional list of locales to generate. Each locale gets its own HTML file. If provided, locale parameter is ignored."
3676
4313
  ),
3677
- content: z8.string().trim().min(1, "content is required").describe(
4314
+ content: z9.string().trim().min(1, "content is required").describe(
3678
4315
  "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
4316
  ),
3680
- description: z8.string().trim().min(1, "description is required").describe(
4317
+ description: z9.string().trim().min(1, "description is required").describe(
3681
4318
  "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
4319
  ),
3683
- tags: z8.array(z8.string().trim().min(1)).optional().describe(
4320
+ tags: z9.array(z9.string().trim().min(1)).optional().describe(
3684
4321
  "Optional tags for BLOG_META. Defaults to tags derived from topic."
3685
4322
  ),
3686
- coverImage: z8.string().trim().optional().describe(
4323
+ coverImage: z9.string().trim().optional().describe(
3687
4324
  "Cover image path. Relative paths rewrite to /blogs/<app>/<slug>/..., default is /products/<appSlug>/og-image.png."
3688
4325
  ),
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).")
4326
+ publishedAt: z9.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
4327
+ modifiedAt: z9.string().trim().regex(DATE_REGEX2, "modifiedAt must use YYYY-MM-DD").optional().describe("Last modified date (YYYY-MM-DD). Defaults to publishedAt."),
4328
+ overwrite: z9.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
3692
4329
  }).describe("Generate static HTML blog posts with BLOG_META headers.");
3693
- var jsonSchema8 = toJsonSchema5(createBlogHtmlInputSchema, {
4330
+ var jsonSchema9 = toJsonSchema5(createBlogHtmlInputSchema, {
3694
4331
  name: "CreateBlogHtmlInput",
3695
4332
  $refStrategy: "none"
3696
4333
  });
3697
- var inputSchema8 = jsonSchema8.definitions?.CreateBlogHtmlInput || jsonSchema8;
4334
+ var inputSchema9 = jsonSchema9.definitions?.CreateBlogHtmlInput || jsonSchema9;
3698
4335
  var createBlogHtmlTool = {
3699
4336
  name: "create-blog-html",
3700
4337
  description: `Generate HTML blog posts under public/blogs/<appSlug>/<slug>/<locale>.html with a BLOG_META block.
@@ -3731,7 +4368,7 @@ Supports multiple locales when locales[] is provided. Each locale gets its own H
3731
4368
  1. Read existing posts in that locale to understand the writing style
3732
4369
  2. Generate appropriate content in that locale's language
3733
4370
  3. Match the writing style and format of existing posts`,
3734
- inputSchema: inputSchema8
4371
+ inputSchema: inputSchema9
3735
4372
  };
3736
4373
  async function handleCreateBlogHtml(input) {
3737
4374
  const publicDir = getPublicDir();
@@ -3774,7 +4411,7 @@ async function handleCreateBlogHtml(input) {
3774
4411
  }
3775
4412
  const output = {
3776
4413
  slug,
3777
- baseDir: path11.join(publicDir, "blogs", appSlug, slug),
4414
+ baseDir: path14.join(publicDir, "blogs", appSlug, slug),
3778
4415
  files: [],
3779
4416
  coverImage: coverImage && coverImage.trim().length > 0 ? coverImage.trim() : `/products/${appSlug}/og-image.png`,
3780
4417
  metaByLocale: {}
@@ -3788,7 +4425,7 @@ async function handleCreateBlogHtml(input) {
3788
4425
  })
3789
4426
  );
3790
4427
  const existing = plannedFiles.filter(
3791
- ({ filePath }) => fs11.existsSync(filePath)
4428
+ ({ filePath }) => fs15.existsSync(filePath)
3792
4429
  );
3793
4430
  if (existing.length > 0 && !overwrite) {
3794
4431
  const existingList = existing.map((f) => f.filePath).join("\n- ");
@@ -3797,7 +4434,7 @@ async function handleCreateBlogHtml(input) {
3797
4434
  - ${existingList}`
3798
4435
  );
3799
4436
  }
3800
- fs11.mkdirSync(output.baseDir, { recursive: true });
4437
+ fs15.mkdirSync(output.baseDir, { recursive: true });
3801
4438
  for (const locale of targetLocales) {
3802
4439
  const { filePath } = getBlogOutputPaths({
3803
4440
  appSlug,
@@ -3823,7 +4460,7 @@ async function handleCreateBlogHtml(input) {
3823
4460
  meta,
3824
4461
  content
3825
4462
  });
3826
- fs11.writeFileSync(filePath, html, "utf-8");
4463
+ fs15.writeFileSync(filePath, html, "utf-8");
3827
4464
  output.files.push({ locale, path: filePath });
3828
4465
  }
3829
4466
  const summaryLines = [
@@ -3902,6 +4539,14 @@ var tools = [
3902
4539
  handler: handleKeywordResearch,
3903
4540
  category: "aso"
3904
4541
  },
4542
+ {
4543
+ name: localizeScreenshotsTool.name,
4544
+ description: localizeScreenshotsTool.description,
4545
+ inputSchema: localizeScreenshotsTool.inputSchema,
4546
+ zodSchema: localizeScreenshotsInputSchema,
4547
+ handler: handleLocalizeScreenshots,
4548
+ category: "aso"
4549
+ },
3905
4550
  // Apps Tools
3906
4551
  {
3907
4552
  name: initProjectTool.name,
@@ -3937,6 +4582,7 @@ function getToolDefinitions() {
3937
4582
  initProjectTool,
3938
4583
  createBlogHtmlTool,
3939
4584
  keywordResearchTool,
4585
+ localizeScreenshotsTool,
3940
4586
  searchAppTool,
3941
4587
  validateAsoTool
3942
4588
  ];
@@ -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.2",
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"