diff --git a/action.yml b/action.yml index 1726b15..52b3d7c 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ -name: 'DeepL Translate Github Action' -author: 'Estee Tey' +name: 'DeepL API Translate Github Action' +author: 'Tyler Haas' description: 'Translate any document using DeepL Translate API' inputs: @@ -15,6 +15,9 @@ inputs: output_file_name_pattern: description: "Pattern of the output file name, including the folder name" required: true + base_ref: + description: "Optional git branch to diff the source file against before translating. Defaults to the pull request base branch when available." + required: false deepl_api_key: description: "API Key for DeepL API" required: true @@ -29,14 +32,24 @@ inputs: description: "End tag to ignore when translating" required: false default: + model_type: + description: "Model type to use for translation. Valid values: quality_optimized, prefer_quality_optimized, latency_optimized" + required: false + timeout: + description: "Connection timeout for each HTTP request retry, in milliseconds. If not provided, uses the default value (5000ms)" + required: false runs: using: composite steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} + fetch-depth: 0 - name: Set up node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 - name: Run translation script shell: bash env: @@ -44,10 +57,13 @@ runs: input_file_path: ${{ inputs.input_file_path }} output_file_name_prefix: ${{ inputs.output_file_name_prefix }} output_file_name_pattern: ${{ inputs.output_file_name_pattern }} + base_ref: ${{ inputs.base_ref }} deepl_api_key: ${{ inputs.deepl_api_key }} ignore_terms: ${{ inputs.ignore_terms }} no_translate_start_tag: ${{ inputs.no_translate_start_tag }} no_translate_end_tag: ${{ inputs.no_translate_end_tag }} + model_type: ${{ inputs.model_type }} + timeout: ${{ inputs.timeout }} run: | cd ${{github.action_path}} && yarn install && yarn start - name: remove unused temp file if it exists diff --git a/package.json b/package.json index fd7d7a2..dd03207 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deepl-translate-github-action", - "version": "2.2.0", + "version": "2.2.1", "author": "Estee ", "license": "MIT", "scripts": { @@ -10,7 +10,7 @@ "coverage": "vitest run --coverage" }, "dependencies": { - "deepl-node": "^1.14.0" + "deepl-node": "^1.22.0" }, "devDependencies": { "@types/node": "^22.7.6", @@ -18,4 +18,4 @@ "typescript": "^5.6.3", "vitest": "^2.1.3" } -} \ No newline at end of file +} diff --git a/playground/local.ts b/playground/local.ts index 9c65fcb..617e2fc 100644 --- a/playground/local.ts +++ b/playground/local.ts @@ -12,7 +12,42 @@ const endTagForNoTranslate = ""; const tempFilePath = path.join(playgroundPath, "to_translate.txt"); const fileExtensionsThatAllowForIgnoringBlocks = [".html", ".xml", ".md", ".txt"]; -const targetLanguages = ["ja"] as deepl.TargetLanguageCode[]; + +// All supported DeepL target languages as of April 2025 +const targetLanguages: deepl.TargetLanguageCode[] = [ + "ar", // Arabic + "bg", // Bulgarian + "cs", // Czech + "da", // Danish + "de", // German + "el", // Greek + "en-GB", // English (British) + "en-US", // English (American) + "es", // Spanish + "et", // Estonian + "fi", // Finnish + "fr", // French + "hu", // Hungarian + "id", // Indonesian + "it", // Italian + "ja", // Japanese + "ko", // Korean + "lt", // Lithuanian + "lv", // Latvian + "nb", // Norwegian Bokmål + "nl", // Dutch + "pl", // Polish + "pt-BR", // Portuguese (Brazilian) + "pt-PT", // Portuguese (all Portuguese variants excluding Brazilian Portuguese) + "ro", // Romanian + "ru", // Russian + "sk", // Slovak + "sl", // Slovenian + "sv", // Swedish + "tr", // Turkish + "uk", // Ukrainian + "zh" // Chinese (unspecified variant for backward compatibility) +]; (async () => { await main({ diff --git a/playground/locales/ja/nested.json b/playground/locales/ja/nested.json deleted file mode 100644 index 435b3c7..0000000 --- a/playground/locales/ja/nested.json +++ /dev/null @@ -1 +0,0 @@ -{"landing":{"welcome":"こんにちは、ようこそ!"},"profile":{"intro":"この後のスナックは?"}} \ No newline at end of file diff --git a/playground/nested.json b/playground/nested.json index a5222ed..c28fdb9 100644 --- a/playground/nested.json +++ b/playground/nested.json @@ -1,8 +1,995 @@ { - "landing": { - "welcome": "Hello, welcome!" - }, - "profile": { - "intro": "What is up for snacks later?" + "admin": { + "actual_cash_requested": "Actual Cash Requested", + "address": "Address: <1>{{address}}", + "adjustDispenserCassettes": "If something is not correct, please adjust the cassette and press \"Refresh\"", + "balance": "Recycler Balance", + "cash_obligation_title": "Cash Obligations", + "cashbox": "Cashbox", + "cashboxStatus": "Cashbox Status: <1>{{cashboxStatus}}", + "cashMgmt": "Cash Management", + "cassettesConfirmNotes": "Total Notes in Cassette {{ position }}", + "cassettesConfirmNotesMsg": "You've entered {{ notes }} note(s) for Cassette {{ position }}.", + "confirmDispenserBalance": "Confirm that each Cassette Position and Status is correct", + "currentIssue": "Current issue:", + "currentIssue_one": "Current issue:", + "currentIssue_many": "Current issue:", + "currentIssue_other": "Current issue:", + "daemonsStatus": "Daemons Status", + "deviceStatus": "Device Status", + "diagnostics": "Diagnostics", + "dispenserBalance": "Dispenser Balance", + "dispenserContent": "Manage the Dispenser balance", + "dispenserDenomination": "Denomination of Cassette {{ position }}", + "dispenserDenominationSubtitle": "Please enter the denomination of Cassette {{ position }}", + "dispenserErrFoundEnd": "Unable to correct cassettes position, Proceed", + "dispenserLoad": "Load Dispenser", + "dispenserLoadInput": "Please enter the total number of notes in Cassette {{ position }}", + "dispenserRemoveCassettes": "Please replace all of the Dispenser cassettes now", + "dispenserTitle": "Dispenser", + "dispenserTotalNotes": "Total Notes in Cassette {{ position }}", + "dispenseTestAmount": "Dispense tests are limited to 4 notes.", + "dispenseTestDisclaimer": "The dispense test is limited to 4 notes, which will be be sent to the reject bin.", + "emptyAll": "Empty All Notes", + "emptyCashbox": "Empty Cashbox", + "emptyCashboxComplete": "Empty Cashbox Complete", + "emptyCashboxCompleteContent": "The cashbox has successfully been emptied.", + "emptyCashboxError": "Empty Cashbox Error", + "emptyCashboxErrorContent": "There may have been an error emptying the cashbox. Please verify.", + "emptyCashboxInstructions": "Please empty/replace the cashbox and press the following button to continue:", + "emptyDispenserComplete": "Empty Dispenser Complete", + "emptying": "Emptying Cash Box", + "emptyRecycler": "Empty Recycler", + "emptyRecyclerComplete": "Empty Recycler Complete", + "emptyRecyclerCompleteContent": "The recycler has successfully been emptied.", + "emptyRecyclerError": "Empty Recycler Error", + "emptyRecyclerErrorContent": "There may have been an error emptying the recycler. Please verify.", + "exit": "Exit", + "featuresIssue": "Administrative features may not function.", + "floatComplete": "Float Complete", + "floatContent": "Transfer Notes from Recycler to Cashbox", + "floatError": "Float Error", + "floatErrorContent": "There may have been an error transferring notes to the cashbox. Please verify.", + "floatNotes": "Float Notes", + "floatRecycler": "Float Recycler", + "hasObligation": "Recycler balance is less than the outstanding sell amount. Would you like to perform a refill now?", + "hasObligationDispenser": "Dispenser balance is less than the outstanding sell amount. Would you like to perform a refill now?", + "inDispenser": "In Dispenser", + "inRecycler": "In Recycler", + "keyboardPlaceholder": "Use keyboard to enter admin access key...", + "loadDispenser": "Load Dispenser", + "loadDispenserComplete": "Load Dispenser Complete", + "loadDispenserCompleteContent": "The dispenser has successfully been loaded.", + "loadDispenserError": "Load Dispenser Error", + "loadDispenserErrorContent": "There may have been an error loading the dispenser. Please verify.", + "locationId": "Location ID: <1>{{locationId}}", + "loginTitle": "Enter Admin Access Key", + "logout": "Logout", + "machineId": "Machine ID: <1>{{machineId}}", + "moveAllNotes": "Move all notes to the cashbox", + "moveCashbox": "Move to Cashbox", + "moveSpecificNostes": "Move specific notes to the cashbox", + "networkStatus": "Network Status", + "notEmptied": "The cashbox was not emptied. Would you like to proceed to the \"Empty Cashbox\" page?", + "notEmptiedConfirm": "Are you sure you want to logout without emptying the cashbox?", + "notesTransferred": "Requested notes have successfully been transfered to the cashbox.", + "obligation": "Obligation", + "position": "Position", + "previousCount": "Previous count: {{previousCount}}", + "problemDetected": "A problem has been detected which will affect machine operation.", + "provisioner": "PROVISIONER", + "provisionerContent": "Go to Provisioner tool", + "qrTest": "QR TEST", + "qrTestContent": "Test the QR Scanner", + "recordValues": "Please record these values", + "refill": "REFILL", + "refill_more_machines": "NOTE: Use the number pad to filter more machines", + "refill_no_more_machines": "Machine not found", + "refill_source_machine": "Select Source From Other Machines", + "refillContent": "Insert New Bills into Recycler", + "refillExternal": "External Source", + "refillingCassettes": "Total Notes in Cassettes Successfully Refilled", + "refillOtherMachines": "Other Machines", + "refillSource": "Specify Source of Funds", + "removingCassettes": "Removing cassettes from dispenser", + "service": "Service", + "setDispenserLoad": "Enter the new count for each cassette", + "setDispenserLoadSubtitle": "The denomination can be changed if needed. If more or less cassettes are needed, use the \"Add\" or \"Remove\" button", + "speedTest": "SPEED TEST", + "speedTestContent": "Run a speed test", + "status": "STATUS", + "statusContent": "View the BTM Status", + "storeName": "Store Name: <1>{{storeName}}", + "successfullyRemoved": "Successfully removed", + "switchEmptyFloat": "Switch to \"{{switch}}\"", + "systemMonitor": "System Monitor", + "testBillAcceptor": "TEST BILL ACCEPTOR", + "testBillAcceptorContent": "Insert notes into the Bill Acceptor", + "testSuite": "Test Suite", + "thisMachine": "This Machine", + "title": "ADMINISTRATION", + "total": "Total", + "totalCashboxEmptied": " The total cash emptied is {{totalCashboxEmptied}}.", + "transaction_id": "Transaction ID", + "transferAllToCashbox": "Transfer All Notes to Cashbox", + "transferring": "Notes are being transferred from Recycler to the Cash Box", + "warningSourceOfFunds": "Are you sure you want to select {{source}} machine as the source of funds?" + }, + "alcoins": { + "popularMessage": "POPULAR" + }, + "altcoin": { + "backToSearch": "back to search", + "buyOtherCryptocurrencies": "Buy Other Cryptocurrencies", + "choose": "Choose: Buy or Sell {{coin}}", + "chooseBuy": "Buy {{coin}}", + "minimum": "Minimum Amount", + "minimumContinue": "Please do not continue unless you plan to purchase {{ minimum }} or more worth of {{ coin }}", + "minimumIs": "Minimum Amount is:", + "minimumPurchase": "Minimum purchase amount depends on the currency selected.", + "otherCryptocurrencies": "Other Cryptocurrencies", + "selectAltcoin": "Select an Altcoin", + "thirdPartyFees": "fees are paid to BTM operator", + "youPay": "You pay" + }, + "altcoins": { + "disabled": "coming soon", + "keyboardPlaceholder": "Use keyboard to filter altcoins..." + }, + "bumpedUpFlowModal": { + "content": "Your current or selected limit requires additional verification.", + "continue": "Continue", + "title": "Limit level upgrade" + }, + "cancelConfirmationModal": { + "modalTitle": "Cancel this pending transaction?" + }, + "capture": { + "aboveMidHoriz": "Above the screen, middle, pointing horizontally", + "belowRightHoriz": "Below the screen, right side, pointing horizontally", + "belowRightSlot": "Below the screen, right side, in sensor slot", + "belowRightVert": "Below the screen, right side, pointing vertically upwards", + "showGuide": "Show Guide", + "showLogs": "Show Logs" + }, + "custom": { + "limitMessage": "A recent change to {{region}} state law limits Bitcoin ATM transactions to {{maxDailyPurchaseLimit}} per day. Use BDCheckout or our Buy Online Service in our app to purchase more bitcoin.", + "scanQRCode": "To learn more, scan this QR Code." + }, + "cashAdded": { + "appInstructionBody": "Open the camera app on your phone.", + "appInstructionBodyTwo": "Point the camera at the QR code on this screen.", + "appInstructionBodyThree": "Tap the notification that appears to open the link. (If you are still having trouble, open a browser on your phone and go to", + "appInstructionBodyFour": "Log into the Bitcoin Depot mobile app using the same phone number you used to log into this kiosk.", + "appInstructionBodyFive": "Complete your transaction by tapping on the button on the home page.", + "appInstructionSubtitle": "Download the Bitcoin Depot Mobile App", + "bodyOne": "You will receive an SMS text confirming your cash deposit.", + "bodyTwo": "Go to our mobile app to finish your transaction.", + "mobileAppDownloadHelp": "I need help downloading the Bitcoin Depot mobile app.", + "title": "Cash Added Successfully!" + }, + "customText": { + "buyAndSellDc": "Buy & Sell Digital Currency", + "priceRateNotice": "retail exchange rate", + "spotPriceDisclaimer": "<0>Note: The above pricing does not include additional fees that will be added to your buy and sell transaction. If you want to learn more about our transaction fees, please visit <3><0>{{website}} or call us at <7><0>{{supportNumber}}" + }, + "deviceAlert": { + "instructions": "Please enter the code to continue" + }, + "duplicateAddressModal": { + "buttonContent": "Continue transaction with a new wallet address that belongs to you", + "content": "This wallet is already associated with another customer account. Please continue with a different wallet address or call or text Customer Support at {{supportNumber}} if you believe this wallet belongs to you.", + "footer": "If you need a wallet, see the instructions below if you'd like to download our {{brandName}} mobile app.", + "title": "Wallet Already in Use" + }, + "end": { + "allSet": "You're all set!", + "buy": "Top up successful", + "buyContent": "You will receive an SMS after we've sent your digital currency. {{digitalCurrency}} will be sent to the following wallet:", + "buyContentAlt": "{{amount}} was received and {{dcAmount}} will be sent to:", + "buyMulti": "{{primaryAmount}} {{primaryCurrency}}, {{secondaryAmount}} {{secondaryCurrency}} received and {{dcAmount}} {{dcCurrency}} will be sent to {{dcAddress}}", + "buySuccessful": "Purchase successful", + "cashInserted": "Cash Inserted:", + "dcReceiving": "{{digitalCurrency}} Receiving:", + "orderReceipt": "A link to the digital receipt has been sent to your mobile number. Up to date transaction information can be found there.", + "patronage": "We appreciate your business and look forward to your next transaction.", + "pending": "Pending", + "refill": "Refill Complete", + "refillContent": "{{amount}} {{currency}} inserted into cash recycler", + "refund": "Refund Complete", + "refundContent": "{{amount}} {{currency}} will be sent to {{dcAddress}}", + "sell": "Sell Complete", + "sellContent": "{{amount}} {{currency}} sold and {{fiatAmount}} {{fiatCurrency}} dispensed.", + "sellDetected": "A transaction to the address has been detected.", + "sellIncomplete": "{{amount}} {{currency}} sold and {{fiatAmount}} {{fiatCurrency}} of {{totalAmount}} {{fiatCurrency}} dispensed.", + "sellPendingInstructions": "You will receive an SMS once the transaction is verified, please return to the machine at that time.", + "stillPending": "The transaction is still pending.", + "thankYou": "Thank you", + "transactionInProgress": "Transaction in progress...", + "txnCancelled": "Transaction Cancelled" + }, + "error": { + "amountOverLimit": "Please enter an amount lower than the displayed limit", + "amountUnderMin": "The minimum is {{dcMin}} {{dcSymbol}} ({{fiatMin}} {{fiatSymbol}})", + "barcodeScanSupport": "Text a photo of your ID", + "billacceptorInfo": "Unable to get billacceptor info", + "camera": "Camera Error", + "cameraUnresponsive": "Sorry, the camera isn't responding. Please try again later.", + "cashboxFull": "Please replace or empty all the cash out of the cashbox.", + "cashboxRemoved": "Please ensure the cashbox is inserted correctly.", + "cpfUnsuccessful": "CPF number validation unsuccessful. Please check the number and try again.", + "deposit_amount": "Please try a larger amount.", + "dispenserBalance": "Unable to retrieve dispenser balance", + "dispenserLoadConfirm": "Please enter all of the denominations and counts for each cassette", + "dispensingError": "Dispensing Error", + "duplicateId": "Duplicate ID", + "duplicateIdReview1": "Your ID may have matched an ID already provided.", + "duplicateIdReview2": "Please wait a moment while we review…", + "duplicateIdReview3": "Just a few more moments, thank you for your patience…", + "duplicateIdSupport1": "Your ID matches another account on file.", + "duplicateIdSupport2": "Please contact support at {{supportNumber}}", + "emptyCassetteNotesMsg": "Please provide the total notes in Cassette {{ position }}.", + "emptyRecycler": "There was a problem emptying the recycler.", + "experiencedIssueVerifying": "We've experienced an issue verifying your {{info}}", + "expiredId": "Expired ID", + "invalidAccessKey": "Invalid Admin Access Key", + "invalidAccessKeyMsg": "Please check your admin access key and try again.", + "invalidCode": "Invalid code", + "invalidCodeNumber": "Invalid number", + "invalidEmail": "Please provide a valid email address", + "invalidNameMax": "The name you provided is too long", + "invalidNameMin": "Please provide both first and last names", + "invalidNumber": "Invalid mobile number", + "invalidPromoCodeMsg": "Please check your promo code and try again.", + "invalidSSN": "The SSN you provided {{ssn}} is invalid", + "levelUpMagstripeBarcodeRead": "Unable to read ID", + "noAmount": "Please enter an amount", + "primaryContact": "This mobile number can no longer be used. Please use your primary contact number.", + "promoCodeNoBuy": "This promo code is not valid for Buy transactions.", + "promoCodeNoSell": "This promo code is not valid for Sell transactions.", + "promoCodeRedeemed": "Already Redeemed", + "promoCodeReedemedMsg": "This promo code has already been redeemed.", + "promoExpired": "Sorry, the promo attributed to that code has expired.", + "refundGeneric": "We are sorry, there was problem encountered while dispensing the cash requested. Instructions to resolve this transaction will be sent to your phone.", + "request": "There was an issue with your request. Please try again later.", + "scan": "Scan Unsuccessful", + "unavailable": "Unavailable", + "unavailableDetails": "Sorry, that option is not available at this time.", + "unavailableMinLimit": "Sorry, your current buy limit is too low for this option.", + "unreachableNumber": "This phone number is a landline or is unreachable", + "unsubscribedRecipientMessage": "This mobile number has previously unsubscribed.<1>Text START to <3><0>{{replyToNumber}} to receive text messages.", + "unsuccessful": "Request Unsuccessful" + }, + "general": { + "action": "Action", + "add": "Add", + "address": "Address", + "administration": "Administration", + "altcoins": "Altcoins", + "asset": "Asset", + "availableAmounts": "Available Amounts", + "back": "Back", + "balance": "Balance", + "banknotesOnly": "banknotes only", + "barcodeScanSupport": "Having trouble scanning your ID? Send a picture of your ID to {{ supportNumber }}", + "brazilianCPF": "CPF", + "btm": "BTM", + "buyAltcoins": "Buy Altcoins", + "buyBitcoins": "Buy Bitcoin", + "buyCurrency": "Buy {{currencySymbol}}", + "buyEthereum": "Buy Ethereum", + "cancel": "Cancel", + "cash": "Cash", + "cashOnly": "cash only", + "cassette": "Cassette", + "changePhoneNumber": "Change Phone Number", + "clear": "Clear", + "close": "Close", + "confirm": "Confirm", + "contactSupport": "Please Contact Support", + "contactSupportImmediateAssistance": "Please contact Support if you need immediate assistance at {{phoneNumber}}.", + "contactSupportOrEmail": "or email {{supportEmail}}", + "continue": "Continue", + "count": "Count", + "daily": "daily", + "date": "Date", + "day": "day", + "denomination": "Denomination", + "dismiss": "Dismiss", + "dispense": "Dispense", + "documents": "Documents", + "done": "Done", + "email": "Email", + "empty": "EMPTY", + "error": "Error", + "exit": "Exit", + "expired": "Expired", + "fail": "Fail", + "firstName": "First Name", + "followInstructionsOnPhone": "Scan QR Code or follow SMS link to start ID verification", + "governmentFirstName": "First Name (as listed on your government ID)", + "governmentLastName": "Last Name (as listed on your government ID)", + "havingIssues": "I need help with scanning.", + "iAmDone": "I'm Done", + "id": "ID", + "idBarcode": "ID Barcode", + "idDocument": "ID Document", + "idDocuments": "ID Documents", + "idVerification": "Identity Verification", + "idVerificationPending": "ID verification pending", + "info": "info", + "italicPlaceholderText": "(as listed on your government ID)", + "lastName": "Last Name", + "lifetime": "lifetime", + "locateCameraTop": "Locate the camera on the top", + "locateScanner": "Locate the scanner below the screen", + "locateSensor": "Locate the sensor slot", + "logout": "Log Out", + "manualReview": "Manual Review", + "minutes_plural": "{{count}} minutes", + "mobile": "Mobile", + "month": "month", + "monthly": "monthly", + "name": "Name", + "no": "No", + "noExit": "No, Exit", + "notBrazilian": "I'm Not Brazilian", + "occupation": "Occupation", + "order": "Order", + "outOfService": "We're very sorry, the machine is temporarily out of service.", + "parsedQr": "Parsed QR", + "pass": "Pass", + "placement": "Placement", + "pleaseWaitProcessing": "Please wait, processing...", + "price": "Price", + "printReceipt": "Print Receipt", + "proceed": "Proceed", + "productId": "Product ID", + "productType": "Product Type", + "promoCode": "Promo Code", + "proofOfAddress": "Proof of Address", + "provisioner": "Provisioner", + "rate": "Rate", + "rawQr": "Raw QR", + "recordedAmount": "Recorded Amount", + "recycler": "RECYCLER", + "ref": "Ref", + "refresh": "Refresh", + "remove": "Remove", + "reset": "Reset", + "resetCamera": "Reset Camera", + "resubscribeError": "Resubscribe Mobile Number", + "review": "Review", + "scanner": "Scanner", + "seconds_plural": "{{count}} seconds", + "secondsLeft": "seconds left", + "selectAltcoin": "Select Altcoin", + "selectLanguage": "Select Language", + "sellBitcoins": "Sell Bitcoin", + "sellCurrency": "Sell {{currencySymbol}}", + "sendCoins": "Send Coins", + "sendLink": "Send Link", + "serviceFee": "Service Fee", + "serviceOperationDetected": "Service Operation Detected", + "ssn": "SSN", + "start": "Start", + "status": "Status", + "stillThere": "Are you still there?", + "submit": "Submit", + "temporarilyDisabled": "TEMPORARILY DISABLED", + "thanks": "Thanks", + "timeoutContinue": "Would you like to continue?", + "total": "Total", + "touchToStart": "Touch to Start", + "transaction": "transaction", + "tryAgain": "Try again", + "tryAgainLater": "Please try again later.", + "tryAgainQuestion": "Try Again?", + "txLimit": "Transaction Limit", + "type": "Type", + "unavailable": "unavailable", + "unknown": "Unknown", + "unreachableError": "Unreachable Phone Number", + "wait": "Please Wait", + "walletInfo": "Wallet Info", + "warning": "Warning", + "webcam": "Webcam", + "website": "Website", + "week": "week", + "weekly": "weekly", + "year": "year", + "yearly": "yearly", + "yes": "Yes", + "yesContinue": "Yes, Continue", + "yesRefill": "Yes, Refill", + "yourDetails": "Your Details", + "verify": "Verify" + }, + "highFeesModal": { + "cancel": "Cancel", + "continue": "I understand. Continue", + "modalText": "Please call us at {{supportCellNumber}} so we can assist you with the current network state. Busy network times may have high gas fees, but we can help you choose the optimal strategy based on your needs.", + "modalText2": "We’re open 24/7 and are happy to help you decide!", + "modalTitle": "Before You Get Started with Altcoins..." + }, + "home": { + "ethereumAndMore": "Ethereum and more available", + "operatedBy": "OPERATED BY", + "otherLanguages": "Other Languages", + "start": "START", + "title": "Bitcoin Teller Machine", + "depositTitle": "Cash Deposit Machine" + }, + "howItWorks": { + "title": "How It Works:", + "bodyOne": "1. Insert cash into the kiosk.", + "bodyTwo": "2. Tap the \"I'm Done\" button.", + "bodyThree": "3. Your funds will be linked to the phone number you entered at this kiosk.", + "bodyFour": "4. Open the Bitcoin Depot mobile app to complete the transaction by converting your cash to Bitcoin." + }, + "id": { + "accepted": "Only accepted forms of ID are listed.", + "back": "ID Back Page Scan", + "backReady": "Have your Driver's License Back Page ready", + "capture": "Capture", + "captureIdBack": "Scan the BACK of your ID", + "captureIdCamera": "Hold your ID to the camera so it's visible on the screen, then tap \"Take Picture\"", + "captureIdFront": "Scan the FRONT of your ID", + "captureIdScanner": "Place your ID on the scanner, then tap \"Capture\"", + "capturePassport": "Scan your Passport", + "capturePassportCamera": "Hold your Passport to the camera so it's visible on the screen, then tap \"Take Picture\"", + "capturePassportScanner": "Place your Passport on the scanner, then tap \"Capture\"", + "captureSelfie": "Please take a Selfie", + "captureSelfieCamera": "Look at the camera, confirm that your face is clearly and fully visible below, then tap \"Take Picture\"", + "cardCamera": "Show your ID card to the camera", + "cardReady": "Have your ID Card ready", + "cardScanner": "Place your ID card over the scanner", + "cardSlot": "Place your ID card inside the slot", + "choiceInstr": "You can submit your identification documents here on the machine, or on the website with your phone or computer.", + "confirmBack": "Confirm ID BACK Scan", + "confirmDocument": "Confirm that your document is clear and in focus", + "confirmFront": "Confirm ID FRONT Scan", + "confirmPassport": "Confirm PASSPORT Scan", + "confirmPhoto": "Confirm Photo", + "confirmVisible": "Please confirm that your face is fully visible in this photo", + "faceScan": "Face Scan", + "front": "ID Front Page Scan", + "frontReady": "Have your Driver's License Front Page ready", + "idCard": "ID Card", + "idOrDl": "ID or Driver's Licence", + "jumio_consent_button_label": "I Accept", + "jumio_consent_text": "I agree to Jumio collecting, processing, and sharing my personal information, which may include biometric data, pursuant to its Privacy Notice.", + "jumio_instructions": "Scan QR to view Jumio Privacy Policy", + "licenseCamera": "Show your License to the camera", + "licenseScanner": "Place your license over the scanner", + "licenseSlot": "Place your license inside the slot", + "maskNotice": "Please remove mask or face covering for the photo", + "passport": "Passport", + "passportCamera": "Show your passport to the camera", + "passportReady": "Have your Passport ready", + "passportScan": "Passport Scan", + "passportScanner": "Place your passport over the scanner", + "passportSlot": "Place your passport inside the slot", + "photoCompare": "This photo will be compared to your ID", + "photoInstructions": "Look at the camera and tap \"Take Picture\"", + "processing": "Verification Processing", + "processWillTakeMessage": "This process usually takes 5 minutes, but may require up to an hour. Please contact our customer service if you haven't received an SMS notification within an hour.", + "reviewingYourDocsMessage": "We are reviewing your documentation, and will send you an SMS after verification is complete.", + "scanAgain": "Scan Again", + "select": "Please select your type of ID", + "takePicture": "Take Picture" + }, + "insertCash": { + "address": "Wallet Address", + "almostThere": "You are almost there!", + "billLimit": "This bill will exceed your transaction limit.", + "billNo": "The bill denomination is not allowed.", + "billOnly": "Only {{allowedDenominations}} bills are accepted.", + "billReject": "Bill Rejected", + "buyRangeReached": "You have reached the top of the {{limitsReason}} buy range you’ve selected. We do different identity verification, based on buy amounts.", + "buyRangeReached2": "Please complete your buy transaction.", + "calculator": "Price Calculator", + "currencyOnly": "{{firstCurrency}}{{secondCurrency}} only", + "done": "Please select I'm Done to complete your transaction.", + "forfeitCancel": "Take me back", + "forfeitClose": "Forfeit transaction and close", + "forfeitConfirm": "Yes, forfeit and exit", + "forfeitWithAltcoin": "Are you sure? Exiting now will forfeit all funds inserted. No {{digitalName}} can be sent nor any refund issued. YOU WILL NOT RECEIVE ANY {{digitalNameUppercase}}.", + "insertSmallerBill": "Insert Smaller Bill", + "minDollarBill": "The first bill you need to insert must be at least a {{minimumBuyAmount}} bill.", + "minimum": "Minimum:", + "minimumPurchaseAmount": "Sorry, but the minimum purchase amount is <1>{{minimumBuyAmount}},<3><4>please insert <1>{{minimumBuyAmount}} or more.", + "more": "Insert more cash", + "note": "Note - if you attempt another transaction today, you'll be prompted for additional verification ", + "noteRejected": "Note Rejected", + "noteRejectedSubtitle": "This banknote exceeds your available limit of {{ buyLimit }} {{ currency }}. Please try a smaller bill.", + "refillProgress": "Refill in Progress", + "sending": "Coins are Being Sent", + "sendingCrypto": "Just a moment as we complete your transaction...", + "title": "Insert Cash", + "tooSmallWithAltcoin": "The {{digitalName}} amount displayed is too small to send. Please insert additional cash to continue with your order.", + "walletAddress": "Your Wallet Address", + "notEnoughAmount": "The amount you've entered is not enough to complete your order.", + "insufficientFunds": "Insufficient Funds", + "minRequired": "Minimum Required Amount:", + "addMoreFunds": "Please add more funds to proceed.", + "cancelOrder": "Cancel my order", + "addFunds": "Add more funds" + }, + "levelUp": { + "additionalInstructions": "You will also be required to provide the following:", + "addressTitle": "Please provide your current address", + "barcodeHoldUpYourID": "Hold the ID barcode steady 5-6 inches away from the scanner with angle until the scanner reads your barcode.", + "bdScanYourIdentity": "Scan your ID Barcode or Dip into the Card Reader", + "bdStayWhileYouVerify": "When you're done, this page will automatically update and then you will be ready to buy crypto!", + "callTextContact": "Call/Text: {{supportNumber}}", + "cancelVerification": "Cancel verification?", + "cardReaderInstructions1": "Only cards with magnetic stripes on the back can be read.", + "cardReaderInstructions2": "Insert your card all the way, then remove quickly.", + "cardReaderInstructions3": "Please also be sure the card is inserted in the proper direction.", + "checkBackHere": "Check back here to finish the verification", + "checkYourPhone": "Check your phone", + "confirmAddress": "Confirm Address", + "confirmCPF": "Confirm CPF", + "confirmEmail": "Confirm Email", + "confirmName": "Confirm Name", + "confirmOccupation": "Confirm Occupation", + "confirmSSN": "Confirm SSN", + "continueOnPhone": "Continue on phone", + "customFlagMessage2": "Or it might be faster if you called them at ", + "dipAndRemoveId": "Dip and remove your ID into the reader.", + "dipAndRemoveId2": "Insert the ID face up into the magnetic strip reader and remove quickly.", + "dipIdReader": "Dip your ID into the Card Reader", + "doneModalTitle": "Great, that's all we need", + "eddInstructions": "A manual review of your account is required to complete the process.", + "eddReviewBodyOne": "Your account will be limited to $1,000 per day until profile review has been completed.", + "eddReviewBodyTwo": "Please contact customer support if you have any questions on the status:", + "email": "Please provide your email address", + "emailContact": "Email: {{supportEmail}}", + "enterEmail": "Enter email address", + "filterAddress": "Use the keyboard to search for your current address, and select it from the list. Unit/apartment numbers not required.", + "filterMoreOccupation": "Use the keyboard to filter occupations", + "followInstructionsBelow": "Follow the instructions below:", + "followInstructionsOnPhone": "ID Verification", + "footnoteContact": "Contact our Private Client Desk for transactions over <1>{{footnoteLimitAmount}}.", + "footnoteContactDetails": "Email: <1><0>{{footnoteEmail}} or call <3><0>{{footnotePhone}}", + "getOneTimeLink": "Get one-time link", + "heresHowToDoIt": "Here’s how to do it:", + "holdUpYourID": "Hold up your ID's barcode to the scanner.", + "holdUpYourID2": "Hold the ID barcode steady 5-6 inches away from the scanner.", + "idInstructions": "To increase your limits, please scan a valid photo ID and take a picture of your face. Our system will process your info and notify you when verification is complete.", + "idInstructionsAlt": "To get started, please scan a valid photo ID and take a picture of your face. Our system will process your info and notify you when verification is complete.", + "incompleteAddress": "Please select your specific street address", + "increaseDailyLimit": "Increase Daily Limit", + "instructions": "Instructions", + "legalName": "What's your legal first and last name", + "LevelUpOverAgeTitle": "Are you {{age}} or older?", + "limitPerTimespan": "Up to <1><0>{{level}} per <3>{{timespan}}", + "mobileIdVerification": "Mobile ID Verification", + "multiInstructions": "To increase your limits, please provide the following:", + "multiInstructionsAlt": "To get started, please provide the following:", + "obtainInformation": "As a money services provider, we are required to obtain this information.", + "occupationTitle": "Please select your occupation", + "occupationTitleAlt": "Please select your previous occupation", + "onThisMachineInstead": "or verify your ID on this machine instead", + "openTheLink": "Open the link and submit your ID documents", + "option": "Option {{num}}", + "orScanQR": "Or scan this QR code to get started", + "poaSmsContinue": "Follow the link on your phone to submit your proof of address.", + "presentNonExpiredId": "Please present a valid, non-expired ID card", + "presentValidID": "Please ensure you are presenting a valid ID card", + "presentValidIDExample": "(e.g. Driver's License)", + "previousOccupation": "Please select your previous occupation prior to retirement", + "processingId": "Just a moment while we process your ID. You are almost there!", + "processingTime": "Takes {{min}}-{{max}} minutes", + "profileAttention": "Your profile requires attention", + "profileBeingReviewed": "Your Profile is Being Reviewed", + "problemVerifyingID": "There was a problem verifying your ID", + "proofOfAddress": "To complete verification you will need to submit your proof of address, which can be uploaded via our website.", + "proofOfAddress2": "Press the button below to be sent a link of the website to your phone.", + "receivedId": "We have received your ID!", + "scanIdBarcode": "Scan your ID Barcode", + "scanToCompleteVerification": "Scan to Complete Verification", + "scanToCompleteVerificationTime": "Takes less than 2 minutes", + "scanYourIdentity": "Scan your ID", + "selectSubtitle": "Each level requires specific documents or information", + "selectTitle": "Please select the level you require", + "sendLinkToPhone": "We’ll send a link to your phone", + "sentLinkTo": "We sent a link to {{mobileNumber}}. It may take a moment to arrive", + "sentSecureLinkTo": "We sent a secure link to {{censoredPhoneNumber}}", + "startCustomerProfile": "Start your customer profile", + "stayWhileCompleting": "Stay here while completing your mobile verification.", + "stayWhileYouVerify": "When you're done, this page will automatically update", + "takePhotoOfYourself": "Take a photo of yourself", + "takesMinutesToArrive": "It may take a moment to arrive", + "tryAnotherVerificationMethod": "Having issues? Try another verification method.", + "tryAnotherVerificationMethod2": "Try another verification method.", + "uploadOrTakePhoto": "Upload or take a photo of your ID", + "verificationRequired": "Verification Required", + "verificationRequiredBodyOne": "Your account will be limited to $1,000 per day until you complete a one-time verification through Plaid.", + "verificationRequiredBodyTwo": "This verification is required by regulators and helps us maintain a secure platform for all customers.", + "verifyIdentity": "Verify identity", + "verifying": "Verifying...", + "verifyOnPhone": "Verify ID on your phone", + "verifyYourIdentity": "Verify your identity", + "visitLinkOnPhone": "Didn't receive a text? Visit this link on your phone ", + "waiting": "Waiting...", + "whatToExpect": "What to Expect", + "havingIssues": "I need help with scanning.", + "youWillBeAskedTo": "For verification you will be asked to:", + "subtitleText": "Scan QR code below or tap the link in the SMS text message we sent to your phone.", + "qrCodeHelp": "Having trouble scanning?", + "qrCodeHelp2": "Go to https://{{idVerification}}", + "IdCsReview": { + "header": "ID Verification", + "instructions": "Please follow the steps below to verify your identification via text message:", + "idFront": "Take a clear picture of the front of your government-issued ID.", + "selfie": "Take a clear, well-lit selfie of yourself", + "send": "Send both images [front of ID and selfie] via text message to {{smsNumber}}", + "approval": "You will receive a confirmation text once approved and automatically be taken to the next page at this kiosk", + "note": "Please ensure all images are clear, well-lit, and show all details legibly. This process helps us verify your identity and comply with regulatory requirements. If you need further assistance, call {{smsNumber}}.", + "waiting": "When you're done, this page will automatically update and then you'll be ready to buy crypto!" } + }, + "levelup": { + "addressInputPlaceholder": "Search address by city or country", + "continueTransBtn": "Please continue with up to a $10k purchase limit", + "customFlagMessage1": "Everything looks good. Our rep just needs to verify your status and will be calling you.", + "customFlagMessage3": "Either way, you can be purchasing Bitcoin in less than a minute!", + "enterSSN": "Please enter your SSN", + "ssnSubTitle1": "Please enter your social security number for verification. This is a legal requirement to transact above certain limits. Extra care is taken for the data security of this information, and it will remain secure.", + "ssnSubTitle2": "Please take care to ensure the address registered to your SSN matches the address you submitted.", + "needHelp": "Need help", + "callUs": "Call Us {{supportNumber}}", + "help": "We are here to help!" + }, + "limits": { + "currentAvailable": "Current Available", + "customerLimit": "You have reached your {{limitsReason}} limit", + "dailyLimit": "Daily Limit", + "dailyLimitReached": "Daily Limit Reached", + "dailyLimitReset": "You have reached the maximum daily purchase amount allowed. Your limit resets in", + "lifetimeAvailable": "Lifetime Available", + "lifetimeLimit": "Lifetime Limit", + "pending": "We're still verifying your identity", + "transactionLimits": "Transaction Limits" + }, + "manualIDVerify": { + "footer": "Typically, IDs are approved in 2 minutes. Blurry photos won't be approved. Retake the photo with your cell phone and text it to {{smsNumber}}", + "subTitle": "You will receive a confirmation text once approved, or wait here until the screen changes.", + "title": "Take a picture of the (1) Front of ID (2) Back of the ID, and a (3) Selfie on a flat surface and text to {{smsNumber}}", + "mainTitle": "If you are experiencing issues with the ID verification process, here is an alternative option:" + }, + "numberKeyPad": { + "provideSSN": "You must provide a SSN to transact above certain limits. Your data is securely transmitted and used only to verify your identity." + }, + "phoneNumberModal": { + "alternativeVoiceCallButton": "Call me with the verfication code instead", + "alternativeVoiceCallButton2": "Call me instead", + "modalDescription": "Please wait up to 2 minutes for your 6-digit verification code to be sent to", + "modalDescription2": "Please check for the 6-digit verification code that should arrive momentarily to the provided phone number {{ censoredNumber }}", + "modalTitle": "Verification code sent!", + "numberConfirmProceedButton": "Yes, this is my number", + "phoneNumberConfirmTitle": "Confirm your phone number", + "proceedButton": "I have received my verification code", + "receiveCall": "Receive a call", + "resendCode": "Resend code", + "unrecievedVerificationMsg": "Haven't received your verification code yet?", + "unrecievedVerificationMsgOptions": "Didn't receive or can't receive SMS text alerts? Please either (1) resend the SMS code, or (2) receive a phone call with the SMS code.", + "useSecretPIN": "Use a Secret PIN instead of SMS text going forward?", + "verifyPhoneDescription": "Please confirm that {{censoredNumber}} is your phone number.", + "voiceCallDescription": "Sending a voice call that contains your 6-digit verification code to", + "voiceCallTitle": "Sending Voice Call" + }, + "pinVerification": { + "backbtn": "Back", + "contactSuport": "Or contact Customer Support at {{supportNumber}}", + "createSubTitle": "Please choose a 4-digit secret PIN to use for accessing your account", + "createTitle": "Create Secret PIN", + "enterSubTitle": "Please enter the 4-digit PIN code you created for your profile", + "enterTitle": "Enter Secret PIN", + "error": "Invalid code", + "forgotPin": "Forgot or don't have your PIN?", + "resetSubTitle": "Please choose a new 4-digit secret PIN to use for accessing your account", + "resetTitle": "Reset Secret PIN", + "textSMSbtn": "Text me an SMS Code instead", + "VerifySubTitle": "Please enter the 4-digit PIN code you created for your profile", + "verifyTitle": "Verify Secret PIN" + }, + "preliminaryAmountRequest": { + "buyAmount": "How much would you like to buy today?", + "hereToPickupCash": "I'm here to pickup cash", + "transactAmount": "How much would you like to transact?" + }, + "priceCalculator": { + "enterAmount": "Enter the Amount", + "priceEstimator": "{{symbol}} Amount Estimator" + }, + "printer": { + "address": "Address: {{address}}", + "addressSent": "{{currencyCode}} address: {{address}}", + "altcoin": "Altcoin: {{currencyCode}}", + "amountSent": "{{currencyCode}} sent: {{amount}}" + }, + "privateWallet": { + "attention": "WARNING:", + "attentionText1": "{{operatorName}} terms of service require all users to use Bitcoin wallets that they own. You selected that you do not own the wallet you are attempting to use.", + "attentionText2": "In order to proceed you will need to create your own Bitcoin wallet and provide the Bitcoin wallet address QR code from that wallet.", + "exit": "Start new transaction with my own Bitcoin wallet", + "optionA": "The wallet is someone else's Bitcoin wallet.", + "optionB": "This is my Bitcoin wallet.", + "selectOption": "Select the button that describes the ownership of the Bitcoin wallet you are using for this transaction:", + "walletAddressEntered": "Wallet Address Entered:" + }, + "promo": { + "accepted": "Your promo code has been accepted", + "codeSuccessful": "Promo Code Applied Successfully", + "discount": "You will receive a {{discount}} discount to the fees on this order" + }, + "promoCode": { + "placeholder": "Use keyboard to enter promo code...", + "title": "Enter Promo Code" + }, + "qrTest": { + "changeAsset": "Change Asset" + }, + "scanQrInstructions": { + "mobileAppDownloadTitle": "Download the Bitcoin Depot Mobile App", + "instructionTitle": "To scan the QR code:", + "instruction1": "1. Open the camera app on your phone.", + "instruction2": "2. Point the camera at the QR code on this screen.", + "instruction3": "3. Tap the notification that appears to open the link.", + "instruction4": "4. If you are still having trouble, open a browser on your phone and go to" + }, + "redeem": { + "cashDispensedShortly": "Your cash will be dispensed shortly...", + "collectCash": "Collect Cash", + "collectLoan": "Collect Loan", + "collectTray": "Please collect your cash from the tray below", + "dispensed": "Dispensed", + "preparingBills": "Preparing bills for dispense...", + "processing": "Please wait. Processing...", + "remaining": "Remaining", + "requested": "Requested" + }, + "scan": { + "bottomCamera": "Use the <1>bottom camera to scan", + "bottomScanner": "Use the <1>bottom scanner to scan", + "greyListed": "This wallet has been graylisted. Please contact Customer Support at {{supportNumber}} if you need assistance.", + "topCamera": "Use the <1>top camera to scan", + "topScanner": "Use the <1>top scanner to scan", + "unsuccessful": "There was an issue with the scan. Please wait a moment and try again." + }, + "scanDigitalWallet": "Scan your digital wallet", + "cameraScanDigitalWallet": "Scan your digital wallet using the camera located at the top of the kiosk", + "sell": { + "amountUnavailable": "The exact amount ({{amount}}) you requested is not currently available.", + "approx": "Approx.", + "bulletOne": "Sell {{coinSymbol}} at {{website}}", + "bulletThree": "Pick up your cash at this BTM", + "bulletTwo": "Get notified when the cash is ready", + "cancel": "Please select YES only if you have NOT sent any coins to the address. Any funds already sent will be considered forfeited. Are you sure you want to cancel this transaction?", + "choiceInstr": "You can start a sell order on the machine, or remotely via the website.", + "choiceInstr2": "Either method will require you to collect the cash on this machine.", + "choiceTitle": "Sell", + "enterAmount": "Enter the Amount in <1>{{currency}}", + "finishedButton": "I have finished sending {{coinName}}", + "includeFees": "(Please include miner fees to this amount)", + "infoBtn": "More Info", + "infoContentOne": "Selling <1>{{coinName}} to a Bitaccess BTM is easy! Just follow the steps online and your <3>{{coinName}} can be turned into cash as soon as it is received. A text message will be sent when you can return to this BTM to retrieve the cash requested within 48 hours.", + "infoContentThree": "Sell <1>{{coinName}} to this BTM anytime by visiting <3><0>{{website}}. This BTM is located at:", + "infoContentTwo": "<0>{{coinName}} transactions aren’t instantaneous, so rather than wait at the BTM or return later the initial transaction can now be started online from any phone or computer. It’s faster and easier than ever before! Start the process any time, any day. Only visit the BTM once the transaction is complete and cash is ready.", + "infoTitle": "Learn More About Remote Sell", + "instructionsOne": "Start a transaction online anytime, from anywhere. Visit only once it's ready.", + "instructionsTwo": "We'll text you a link to the website to get started.", + "modalContinue": "Follow the link on your phone to sell.", + "modalTitle": "Link Sent", + "nearby": "Find amount from BTM's nearby", + "nearbyLocations": "Nearby Locations", + "nearbyNoLocations": "No locations found", + "noNextStep": "No, I've sent my coins. Go to the next step", + "remotely": "Sell Remotely", + "scanQRCode": "Scan QR Code to Send {{coinName}}", + "selectAmount": "Select the amount you would like to withdraw", + "sendLinkError": "We were unable to send the link by SMS. Please visit the following site:", + "smsVerified": "You will receive an SMS when your transaction has been verified.", + "stepOne": "Open your wallet app, select \"{{coinName}}\", tap \"Send\" or similar, and tap \"Scan\" or similar.", + "stepThree": "Send exactly", + "stepTwo": "Scan the QR code with your wallet scanner.", + "title": "Sell {{coinName}} from Anywhere", + "troubleScanning": "Trouble Scanning?", + "tryAnotherFormat": "Try another format", + "waitMessage": "Waiting on your {{coinName}}... (takes {{min}}-{{max}} minutes)", + "yesCancel": "Yes, cancel it" + }, + "speedTest": { + "client": "CLIENT", + "country": "Country", + "download": "Download", + "host": "Host", + "location": "Location", + "ping": "Ping", + "sponsor": "Sponsor", + "testServer": "TEST SERVER", + "title": "Speed Test", + "upload": "Upload" + }, + "terms": { + "accept": "I accept these terms and conditions.", + "title": "Disclaimer" + }, + "testSuite": { + "billAcceptorTest": "Bill Acceptor Test", + "dispenseTest": "Dispense Test", + "dispenseTestAssessment": "Dispense Test Assessment", + "emptyCashboxTest": "Empty Cashbox Test", + "emptyCashboxTestAssessment": "Empty Cashbox Test Assessment", + "emptyRecyclerTest": "Empty Recycler Test", + "emptyRecyclerTestAssessment": "Empty Recycler Test Assessment", + "faceCaptureTest": "Face Capture Test", + "idCaptureTest": "ID Capture Test", + "loadDispenserTest": "Load Dispenser Test", + "loadDispenserTestAssessment": "Load Dispenser Test Assessment", + "printerTest": "Printer Test", + "printerTestAssessment": "Printer Test Assessment", + "qrCaptureTest": "QR Capture Test" + }, + "tradeType": { + "adminAccess": "ADMINISTRATOR ACCESS", + "altcoinsScamContent": "If you were instructed to buy Altcoins, do not proceed. You may be the target of a scam.", + "bdDoneModalDescription": "We are processing your information!", + "bdTxLimit": "Current Maximum Purchase Amount", + "bdTxLimitInstructions": "Press the \"{{buttonName}}\" button below", + "changeNumber": "This phone number is not recognized as a valid mobile number. Please change your number to your personal mobile number. Press the button below to receive a link by SMS.", + "changeNumberContinue": "Follow the link on your phone to change your number.", + "customerWalletBanner": "Need a wallet?", + "customerWalletBannerButton": "Text me the download link", + "customerWalletBannerInstructions": "You'll need a mobile wallet to buy or sell digital currency. Tap to download the {{brandName}} Wallet app", + "customerWalletBannerQR": "Download our {{brandName}} wallet by scanning the QR code", + "doneModalDescription": "Please wait. We are processing your information.", + "firstTime": "Please register to buy or sell digital currency.", + "increaseLimit": "Increase Limit", + "increasePurchaseLimit": "Increase Purchase Limit", + "instructions": "Please select one of the following options", + "LevelUpOverAgeDescription-verify": "You must be at least {{age}} or older to use this BTM", + "openOrder": "You currently have an open order and cannot start another transaction until this order is completed.", + "openOrderBD": "You currently have an open order and cannot start another until it is completed. If you require assistance, please reach out to Customer Service at {{supportNumber}} and they can help you out.", + "recentTitle": "Your Recent Transactions", + "recentType": "Transaction Type", + "recentValue": "Transaction Value", + "redeem": "Redeem", + "register": "Register", + "scamContent": "If you were instructed to buy Bitcoin, do not proceed. You may be the target of a scam.", + "scamContinue": "Nobody sent me here; Continue", + "scamTitle": "Caution: Personal Use Only", + "txLimit": "Current Transaction Limit", + "viewAltcoins": "View Altcoins", + "welcome": "Welcome", + "welcomeTo": "Welcome to {{operatorName}}" + }, + "transactionEndModal": { + "endModalPartOne": "We've confirmed that you've sent us your {{coinName}}. Your transaction is being processed on the blockchain.", + "endModalPartThree": "We've sent you a text with your digital receipt.", + "endModalPartTwo": "This can take between {{min}}-{{max}} minutes.", + "endModalTitle": "Processing your transaction. We'll text you when your cash is ready for pickup.", + "pendingModalMessage": "Your cash will be available in a moment.", + "pendingModalTitle": "Coins received, thank you." + }, + "stateDisclosures": { + "disclaimerTitle": "{{state}} Disclaimer", + "Minnesota": { + "disclaimer1": "Virtual currency is not legal tender, backed or insured by the government, and accounts and value balances are not subject to Federal Deposit Insurance Corporation, National Credit Union Administration, or Securities Investor Protection Corporation protections;", + "disclaimer2": "Some virtual currency transactions are deemed to be made when recorded on a public ledger, which may not be the date or time when the person initiates the transaction;", + "disclaimer3": "Virtual currency’s value may be derived from market participants’ continued willingness to exchange fiat currency for virtual currency, which may result in the permanent and total loss of a particular virtual currency’s value if the market for virtual currency disappears;", + "disclaimer4": "A person who accepts a virtual currency as payment today is not required to accept and might not accept virtual currency in the future;", + "disclaimer5": "The volatility and unpredictability of the price of virtual currency relative to fiat currency may result in a significant loss over a short period;", + "disclaimer6": "The nature of virtual currency means that any technological difficulties experienced by virtual currency kiosk operators may prevent access to or use of a person’s virtual currency; and", + "disclaimer7": "Any bond maintained by the virtual currency kiosk operator for the benefit of a person may not cover all losses a person incurs.", + "disclaimer8": "The person’s liability for unauthorized virtual currency transactions;", + "disclaimer9": "The person’s right to stop payment of a virtual currency transfer and the procedure to stop payment;", + "disclaimer9-1": "Stop payment of a virtual currency transfer and the procedure to stop payment;", + "disclaimer9-2": "Receive a receipt, trade ticket, or other evidence of a transaction at the time of the transaction;", + "disclaimer9-3": "Prior notice of a change in the virtual currency kiosk operator’s rules or policies;", + "disclaimer10": "Under what circumstances the virtual currency kiosk operator, without a court or government order, discloses a person’s account information to third parties;", + "disclaimer11": "Other disclosures that are customarily provided in connection with opening a person’s account.", + "warning": "WARNING: LOSSES DUE TO FRAUDULENT OR ACCIDENTAL TRANSACTIONS ARE NOT RECOVERABLE AND TRANSACTIONS IN VIRTUAL CURRENCY ARE IRREVERSIBLE. VIRTUAL CURRENCY TRANSACTIONS MAY BE USED BY SCAMMERS IMPERSONATING LOVED ONES, THREATENING JAIL TIME, AND INSISTING YOU WITHDRAW MONEY FROM YOUR BANK ACCOUNT TO PURCHASE VIRTUAL CURRENCY." + }, + "acknowledge": "I understand " + }, + "USDWallet": { + "disclaimer": "Disclaimer", + "newTransaction": "Start new transaction with a different US-based wallet", + "no": "No", + "TitlePage": "Is this Address associated with a virtual currency wallet or exchange located within the United States?", + "walletID": "Wallet ID Entered:", + "yes": "Yes" + }, + "verify": { + "advancedCodeCPFInstructions": "Please enter your Brazilian CPF Number", + "advancedCodeInstructions": "Please enter your Verification Number", + "callMewithcode": "Call me with code", + "codeInstructions": "Please enter the 6-Digit verification code sent to your mobile", + "enterAdvancedCode": "Enter your Number", + "enterAdvancedCodeCPF": "Enter your CPF Number", + "enterCode": "Enter Verification Code", + "enterNumber": "Enter your Mobile Number", + "enterSms": "Enter SMS Code", + "haveANewNumber": "Have a new number?", + "haveANewNumber2": "Scan the QR code to change your number", + "iReceivedIt": "I received it", + "keyboardPlaceholder": "Use keyboard to enter code...", + "numberInstructions": "Please enter your mobile number to verify your identity.", + "numberInternational": "Enter your mobile phone number to log in or sign up.", + "scamWarning": "If someone else sent you to this machine and provided you with a QR Code or wallet ID to send funds to, it is most likely a scam.", + "smsRePromptSubTitle": "Some carriers might be blocking text messages.", + "smsRePromptTitle": "Didn't Receive an SMS?", + "verifyAgeAlertContent": "You must be at least {{minimumAge}} years old to continue.", + "verifyAgeAlertTitle": "Are you {{minimumAge}} or over?" + }, + "wallet": { + "accessWallet": "How do I access my digital wallet?", + "accessWalletTitle": "To access your digital wallet:", + "accessWalletBodyOne": "1. Open the app on your phone that contains your wallet.", + "accessWalletBodyTwo": "2. Tap on the receive button.", + "accessWalletBodyThree": "3. A QR code should appear that you can use to scan with the camera at the top of the kiosk.", + "accessWalletBodyFour": "4. You should also have the option to view the wallet ID if you choose to enter it manually.", + "closeAccessWallet": "Close", + "bdDescription": "In your Bitcoin Depot wallet, QR code can be found by clicking the \"Receive\" button", + "bdMobileScanner": "Place your mobile phone 5-6 inches away from the scanner with angle until the scanner reads your barcode", + "bdReady": "Have your {{ currency }} wallet ready. In your Bitcoin Depot wallet, QR code can be found by clicking the \"Receive\" button", + "checkQr": "Check for QR Codes", + "collectTray": "Please collect your paper wallet from the tray below", + "locatePublicKey": "Locate the Public Key in your paper wallet", + "manualWallet": "I want to manually enter my wallet ID.", + "mobileCamera": "Show your QR Code to the camera", + "mobileScanner": "Place your mobile over the scanner", + "mobileSlot": "Place your mobile inside the slot", + "needWallet": "I need a wallet.", + "needWalletBody": "Download the Bitcoin Depot wallet by scanning the QR code below with your phone's camera.", + "needWalletTitle": "Need a wallet?", + "nextInstructions": "Once you have a wallet set up, go back to scan or manually enter in your wallet ID.", + "noWallet": "No wallet? Text me a download link", + "paperConfirm": "Yes, Proceed", + "printAgain": "No, Print again", + "printing": "Your Wallet is Printing", + "publicKeyCamera": "Show your Public Key QR Code to the camera", + "publicKeyScanner": "Show your Public Key QR Code to the scanner", + "publicKeySlot": "Place your Public Key inside the slot", + "qrReady": "Please have the QR Code of your Bitcoin Wallet ready", + "qrVisible": "Make sure that your entire QR Code is visible on the screen below", + "question": "Do you have a Bitcoin Wallet?", + "ready": "Have your {{ currency }} wallet ready", + "scanDigitalWallet": "Scan your digital wallet", + "scanHelp": "I need help with scanning.", + "scanningHelpBodyOne": "1. Make sure your Bitcoin wallet's QR code is displayed on your phone.", + "scanningHelpBodyTwo": "2. Locate the camera at the top of this kiosk.", + "scanningHelpBodyThree": "3. Position your phone so the entire QR code is visible within the camera's view on the kiosk screen.", + "scanningHelpBodyFour": "4. The screen will update once your QR code has been successfully scanned.", + "scanningHelpTitle": "Scan your digital wallet:", + "scannerNoVideoSubtitle": "Make sure that your entire QR Code is visible on the scanner below", + "screeningFailure1": "This wallet has been flagged for review. Please contact Customer Support at {{supportNumber}} if you need assistance.", + "screeningFailure2": "This wallet address cannot be used. Please provide a different wallet address.", + "twoCodes": "Please make sure there are TWO QR Codes printed on your paper wallet.", + "valAddress": "The address does not appear to be a valid {{coin}} address. Please check the address and try again.", + "valDuplicate": "Sorry, that {{coin}} address is already in use. Please scan a new address from your personal wallet.", + "valFailed": "Sorry, that QR code can't be scanned. Please present a receiving address for your personal {{coin}} wallet.", + "valMismatch": "Please scan the address printed on the paper wallet.", + "walletAccessButton": "How do I access my digital wallet?", + "validAddressTitle": "Address Valid", + "invalidAddressTitle": "Invalid Address", + "addresstatusContentLine1": "Oops, that doesn't look right.", + "addresstatusContentLine2": "Please check your Bitcoin wallet address and try again.", + "manualWalletScanTitleValid": "Your wallet ID:", + "warning": "Warning: This transaction is irreversible. You are solely responsible for ensuring the accuracy of your wallet ID.", + "manualWalletScanTitle": "Scan QR Code above to paste Wallet ID from your phone or type in your wallet ID for verification:", + "important": "Important:", + "manualAddressVerifyDisclaimerLine1": "Double-check your wallet ID - it must match exactly, including letter case.", + "manualAddressVerifyDisclaimerLine2": "Ensure the ID is a valid Bitcoin wallet ID, and not for other cryptocurrencies like Etherium.", + "manualAddressVerifyDisclaimerLine3": "Verify this is your personal wallet ID." + }, + "walletLink": { + "modalButton": "I have created my wallet", + "modalMessage1": "Wallet download instructions sent!", + "modalMessage2": "Please follow the link we just texted you. You will learn how to create and use your digital wallet." + } } diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..8f415cd --- /dev/null +++ b/src/git.ts @@ -0,0 +1,108 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) + +interface BaseFileContentParams { + workspacePath: string + inputFileRelativePath: string + baseRef?: string +} + +function isMissingPathError(stderr: string) { + return ( + stderr.includes('exists on disk, but not in') || + stderr.includes('pathspec') || + stderr.includes('fatal: path') || + stderr.includes('does not exist') + ) +} + +function getWorkflowRepositoryUrl(): string | null { + const serverUrl = process.env.GITHUB_SERVER_URL?.replace(/\/$/, '') + const repository = process.env.GITHUB_REPOSITORY + + if (!serverUrl || !repository) { + return null + } + + return `${serverUrl}/${repository}.git` +} + +export async function getBaseFileContent({ + workspacePath, + inputFileRelativePath, + baseRef, +}: BaseFileContentParams): Promise { + if (!baseRef) { + return null + } + + const baseRepoUrl = getWorkflowRepositoryUrl() + const baseBranchRef = baseRepoUrl + ? `refs/remotes/base/${baseRef}` + : `refs/remotes/origin/${baseRef}` + const fetchSource = baseRepoUrl ?? 'origin' + + try { + await execFileAsync( + 'git', + [ + 'fetch', + '--no-tags', + fetchSource, + `+refs/heads/${baseRef}:${baseBranchRef}`, + ], + { cwd: workspacePath, maxBuffer: 10 * 1024 * 1024 }, + ) + } catch (error) { + console.warn(`Failed to fetch base branch ${baseBranchRef}, falling back to local refs if available.`, error) + } + + let baseCommitRef: string | null = null + + try { + const { stdout } = await execFileAsync( + 'git', + ['merge-base', 'HEAD', baseBranchRef], + { cwd: workspacePath, maxBuffer: 10 * 1024 * 1024 }, + ) + + const mergeBaseSha = stdout.trim() + if (mergeBaseSha) { + baseCommitRef = mergeBaseSha + } + } catch (error) { + console.warn( + `Failed to determine merge-base with ${baseBranchRef}, falling back to full translation.`, + error, + ) + } + + if (!baseCommitRef) { + return null + } + + try { + const normalizedInputFileRelativePath = inputFileRelativePath.replace(/\\/g, '/') + const { stdout } = await execFileAsync( + 'git', + ['show', `${baseCommitRef}:${normalizedInputFileRelativePath}`], + { cwd: workspacePath, maxBuffer: 10 * 1024 * 1024 }, + ) + + return stdout + } catch (error) { + const stderr = error instanceof Error && 'stderr' in error ? String(error.stderr) : '' + + if (isMissingPathError(stderr)) { + return null + } + + console.warn( + `Failed to read ${inputFileRelativePath} from ${baseCommitRef}, falling back to full translation.`, + error, + ) + return null + } +} diff --git a/src/index.ts b/src/index.ts index 5b68429..50f829e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,30 @@ import type { TargetLanguageCode } from "deepl-node"; import { Translator } from 'deepl-node'; import path from "path"; -import { main } from "./main"; +import { getBaseFileContent } from "./git"; +import { main, type ModelType } from "./main"; +import { createTranslatorOptions } from "./utils"; const authKey = process.env.deepl_api_key as string; -const translator = new Translator(authKey); +const translatorOptions = createTranslatorOptions(process.env.timeout); + +const translator = new Translator(authKey, translatorOptions); +const workspacePath = process.env.GITHUB_WORKSPACE as string; +const inputFileRelativePath = process.env.input_file_path as string; +const baseRef = process.env.base_ref || process.env.GITHUB_BASE_REF; const inputFilePath = path.join( - process.env.GITHUB_WORKSPACE as string, - process.env.input_file_path as string, + workspacePath, + inputFileRelativePath, ); const outputFileNamePattern = path.join( - process.env.GITHUB_WORKSPACE as string, + workspacePath, process.env.output_file_name_pattern as string, ) const startTagForNoTranslate = process.env.no_translate_start_tag as string; const endTagForNoTranslate = process.env.no_translate_end_tag as string; const tempFilePath = path.join( - process.env.GITHUB_WORKSPACE as string, + workspacePath, "to_translate.txt", ); const fileExtensionsThatAllowForIgnoringBlocks = [".html", ".xml", ".md", ".txt"]; @@ -34,8 +41,27 @@ const fileExtensionsThatAllowForIgnoringBlocks = [".html", ".xml", ".md", ".txt" targetLanguages = targetLanguages.filter(lang => !excludedLanguages.includes(lang)); + const modelTypeEnv = process.env.model_type; + let modelType: ModelType | undefined; + if (modelTypeEnv) { + const validModelTypes: ModelType[] = ['quality_optimized', 'prefer_quality_optimized', 'latency_optimized']; + if (validModelTypes.includes(modelTypeEnv as ModelType)) { + modelType = modelTypeEnv as ModelType; + } else { + console.warn(`Invalid model_type value: ${modelTypeEnv}. Valid values are: ${validModelTypes.join(', ')}. Ignoring model_type parameter.`); + } + } + + const baseFileContent = await getBaseFileContent({ + workspacePath, + inputFileRelativePath, + baseRef, + }); + await main({ translator, + workspacePath, + inputFileRelativePath, inputFilePath, outputFileNamePattern, startTagForNoTranslate, @@ -43,5 +69,7 @@ const fileExtensionsThatAllowForIgnoringBlocks = [".html", ".xml", ".md", ".txt" tempFilePath, fileExtensionsThatAllowForIgnoringBlocks, targetLanguages, + modelType, + baseFileContent, }); })(); diff --git a/src/main.ts b/src/main.ts index 92e84fa..135a8fb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,134 +1,338 @@ -import type { TargetLanguageCode, Translator } from "deepl-node"; -import fs from "fs"; -import path from "path"; -import { TranslatedJSONResults, buildOutputFileName, removeKeepTagsFromString, replaceAll, translateRecursive } from "./utils"; +import type { TargetLanguageCode, Translator } from 'deepl-node' +import fs from 'fs' +import path from 'path' +import { + buildOutputFileName, + buildOutputJson, + collectAllStringsFromJson, + collectStringMapFromJson, + deleteNestedValue, + diffLines, + getTextLineMetadata, + parseJsonString, + parseTextDocument, + removeKeepTagsFromString, + replaceParameterStringsInJSONValueWithKeepTags, + replaceAll, + setNestedValue, + stringifyTextDocument, + translateTexts, +} from './utils' interface HTMLlikeParams { - startTagForNoTranslate?: string; - endTagForNoTranslate?: string; + startTagForNoTranslate?: string + endTagForNoTranslate?: string } +export type ModelType = 'quality_optimized' | 'prefer_quality_optimized' | 'latency_optimized' + export interface MainFunctionParams extends HTMLlikeParams { - translator: Translator; - inputFilePath: string; - outputFileNamePattern: string; - tempFilePath: string; - fileExtensionsThatAllowForIgnoringBlocks: string[]; - targetLanguages: TargetLanguageCode[]; + translator: Translator + workspacePath?: string + inputFileRelativePath?: string + inputFilePath: string + outputFileNamePattern: string + tempFilePath: string + fileExtensionsThatAllowForIgnoringBlocks: string[] + targetLanguages: TargetLanguageCode[] + modelType?: ModelType + baseFileContent?: string | null +} + +async function ensureOutputDirectoryExists(outputFileName: string) { + const outputFolderPath = path.dirname(outputFileName) + + if (!fs.existsSync(outputFolderPath)) { + await fs.promises.mkdir(outputFolderPath, { recursive: true }) + } +} + +function prepareFullTextForTranslation( + inputText: string, + startTagForNoTranslate?: string, + endTagForNoTranslate?: string, +) { + if (!startTagForNoTranslate || !endTagForNoTranslate) { + return inputText + } + + const textWithNoTranslateStartTagReplaced = replaceAll(inputText, startTagForNoTranslate, '') + return replaceAll(textWithNoTranslateStartTagReplaced, endTagForNoTranslate, '') +} + +async function translateWholeTextFile( + inputText: string, + targetLang: TargetLanguageCode, + translator: Translator, + modelType: ModelType | undefined, + startTagForNoTranslate?: string, + endTagForNoTranslate?: string, +) { + const preparedText = prepareFullTextForTranslation( + inputText, + startTagForNoTranslate, + endTagForNoTranslate, + ) + const [translatedText = ''] = await translateTexts([preparedText], targetLang, translator, { + modelType, + postprocess: removeKeepTagsFromString, + }) + + return translatedText +} + +async function buildIncrementalTextOutput( + currentText: string, + baseFileContent: string, + targetFileContent: string, + targetLang: TargetLanguageCode, + translator: Translator, + modelType: ModelType | undefined, + startTagForNoTranslate?: string, + endTagForNoTranslate?: string, +) { + const currentDocument = parseTextDocument(currentText) + const baseDocument = parseTextDocument(baseFileContent) + const targetDocument = parseTextDocument(targetFileContent) + + if (targetDocument.lines.length !== baseDocument.lines.length) { + return translateWholeTextFile( + currentText, + targetLang, + translator, + modelType, + startTagForNoTranslate, + endTagForNoTranslate, + ) + } + + const operations = diffLines(baseDocument.lines, currentDocument.lines) + const lineMetadata = getTextLineMetadata( + currentDocument.lines, + startTagForNoTranslate, + endTagForNoTranslate, + ) + const linesToTranslate: string[] = [] + let currentLineIndex = 0 + + for (const operation of operations) { + if (operation.type === 'equal') { + currentLineIndex += operation.lines.length + continue + } + + if (operation.type === 'insert') { + for (let lineOffset = 0; lineOffset < operation.lines.length; lineOffset++) { + const metadata = lineMetadata[currentLineIndex + lineOffset] + if (metadata.shouldTranslate) { + linesToTranslate.push(metadata.preparedLine) + } + } + + currentLineIndex += operation.lines.length + } + } + + const translatedLines = await translateTexts(linesToTranslate, targetLang, translator, { + modelType, + postprocess: removeKeepTagsFromString, + }) + + const resultLines: string[] = [] + let translatedLineIndex = 0 + let targetLineIndex = 0 + currentLineIndex = 0 + + for (const operation of operations) { + if (operation.type === 'equal') { + resultLines.push(...targetDocument.lines.slice(targetLineIndex, targetLineIndex + operation.lines.length)) + targetLineIndex += operation.lines.length + currentLineIndex += operation.lines.length + continue + } + + if (operation.type === 'delete') { + targetLineIndex += operation.lines.length + continue + } + + for (let lineOffset = 0; lineOffset < operation.lines.length; lineOffset++) { + const metadata = lineMetadata[currentLineIndex + lineOffset] + if (metadata.shouldTranslate) { + resultLines.push(translatedLines[translatedLineIndex] ?? metadata.outputLine) + translatedLineIndex++ + } else { + resultLines.push(metadata.outputLine) + } + } + + currentLineIndex += operation.lines.length + } + + return stringifyTextDocument({ + lines: resultLines, + eol: currentDocument.eol, + endsWithNewline: currentDocument.endsWithNewline, + }) +} + +async function buildJsonOutput( + inputJson: Record, + baseJson: Record | null, + outputFileName: string, + targetLang: TargetLanguageCode, + translator: Translator, + modelType: ModelType | undefined, +) { + const { keys: currentKeys, values: currentValues } = collectAllStringsFromJson(inputJson) + const currentValueMap = collectStringMapFromJson(inputJson) + const targetFileExists = fs.existsSync(outputFileName) + + const requiresFullTranslation = !baseJson || !targetFileExists + if (requiresFullTranslation) { + const translatedValues = await translateTexts(currentValues, targetLang, translator, { + modelType, + preprocess: (value) => replaceParameterStringsInJSONValueWithKeepTags(value), + postprocess: removeKeepTagsFromString, + }) + + return JSON.stringify(buildOutputJson(translatedValues, currentKeys), null, 2) + } + + const targetJsonString = await fs.promises.readFile(outputFileName, 'utf8').catch(() => '') + const targetJson = parseJsonString>(targetJsonString) + + if (targetJson === null) { + const translatedValues = await translateTexts(currentValues, targetLang, translator, { + modelType, + preprocess: (value) => replaceParameterStringsInJSONValueWithKeepTags(value), + postprocess: removeKeepTagsFromString, + }) + + return JSON.stringify(buildOutputJson(translatedValues, currentKeys), null, 2) + } + + const baseValueMap = collectStringMapFromJson(baseJson) + const keysToTranslate = currentKeys.filter((key) => currentValueMap.get(key) !== baseValueMap.get(key)) + const keysToDelete = [...baseValueMap.keys()].filter((key) => !currentValueMap.has(key)) + + if (keysToTranslate.length === 0 && keysToDelete.length === 0) { + return targetJsonString + } + + for (const key of keysToDelete) { + deleteNestedValue(targetJson, key) + } + + const translatedValues = await translateTexts( + keysToTranslate.map((key) => currentValueMap.get(key) ?? ''), + targetLang, + translator, + { + modelType, + preprocess: (value) => replaceParameterStringsInJSONValueWithKeepTags(value), + postprocess: removeKeepTagsFromString, + }, + ) + + keysToTranslate.forEach((key, index) => { + setNestedValue(targetJson, key, translatedValues[index] ?? currentValueMap.get(key) ?? '') + }) + + return JSON.stringify(targetJson, null, 2) } export async function main(params: MainFunctionParams) { - const { - translator, - inputFilePath, - outputFileNamePattern, - startTagForNoTranslate, - endTagForNoTranslate, - tempFilePath, - fileExtensionsThatAllowForIgnoringBlocks, - targetLanguages, - } = params; - const fileExtension = path.extname(inputFilePath); - const isFileHtmlLike = - fileExtensionsThatAllowForIgnoringBlocks.includes(fileExtension); - - if (isFileHtmlLike) { - const inputText = fs.readFileSync(inputFilePath, "utf8"); - let textWithNoTranslateTagsReplaced = inputText; - if (startTagForNoTranslate && endTagForNoTranslate) { - const textWithNoTranslateStartTagReplaced = replaceAll( - inputText, - startTagForNoTranslate, - "", - ); - const textWithNoTranslateEndTagReplaced = replaceAll( - textWithNoTranslateStartTagReplaced, - endTagForNoTranslate, - "", - ); - - textWithNoTranslateTagsReplaced = textWithNoTranslateEndTagReplaced; - } - - let textToBeWrittenToTempFile = textWithNoTranslateTagsReplaced; - - console.debug("textToBeWrittenToTempFile: ", textToBeWrittenToTempFile); - - fs.writeFileSync(tempFilePath, textToBeWrittenToTempFile); - - const tempFileExists = fs.existsSync(tempFilePath); - console.debug("tempFileExists: ", tempFileExists); - const translateFilePath = tempFileExists ? tempFilePath : inputFilePath; - - fs.readFile(translateFilePath, "utf8", async function (err, text) { - if (err) { - return console.info(err); - } - - console.info( - `Translating the input file into ${targetLanguages.length} languages...`, - ); - - for (const targetLanguage of targetLanguages) { - const targetLang = targetLanguage as TargetLanguageCode; - const textResult = await translator.translateText( - text, - null, - targetLang, - { - preserveFormatting: true, - tagHandling: "xml", - ignoreTags: ["keep"], - }, - ); - - const translatedText = textResult.text; - - if (translatedText === undefined) { - console.error(`got undefined translatedText, skipping for ${targetLang}`) - return - } - const resultText = removeKeepTagsFromString(translatedText); - - const outputFileName = buildOutputFileName(targetLang, outputFileNamePattern); - const outputFolderPath = path.dirname(outputFileName); - if (!fs.existsSync(outputFolderPath)) { - fs.mkdirSync(outputFolderPath, { recursive: true }); - } - fs.writeFile(outputFileName, resultText, function (err) { - if (err) return console.info(err); - console.info(`Translated ${targetLang}`); - }); - } - }); - } else if (fileExtension === ".json") { - fs.readFile(inputFilePath, "utf8", async (err, jsonString) => { - if (err) { - console.info("Error reading file", err); - return; - } - - try { - const inputJson = JSON.parse(jsonString); - const translatedRecords = {} as TranslatedJSONResults; - const translatedResults = await translateRecursive(inputJson, targetLanguages, translator, translatedRecords); - - for (const targetLanguage of targetLanguages) { - const targetLang = targetLanguage as TargetLanguageCode; - const outputFileName = buildOutputFileName(targetLang, outputFileNamePattern); - const resultJson = JSON.stringify(translatedResults[targetLang]); - const outputFolderPath = path.dirname(outputFileName); - if (!fs.existsSync(outputFolderPath)) { - fs.mkdirSync(outputFolderPath, { recursive: true }); - } - fs.writeFile(outputFileName, resultJson, function (err) { - if (err) return console.info(err); - console.info(`Translated ${targetLang}`); - }); - } - } catch (err) { - console.info("Error parsing JSON string", err); - } - }); - } + const { + translator, + workspacePath: _workspacePath, + inputFileRelativePath: _inputFileRelativePath, + inputFilePath, + outputFileNamePattern, + startTagForNoTranslate, + endTagForNoTranslate, + tempFilePath, + fileExtensionsThatAllowForIgnoringBlocks, + targetLanguages, + modelType, + baseFileContent, + } = params + const fileExtension = path.extname(inputFilePath) + const isFileHtmlLike = fileExtensionsThatAllowForIgnoringBlocks.includes(fileExtension) + + if (isFileHtmlLike) { + const inputText = await fs.promises.readFile(inputFilePath, 'utf8').catch((err) => { + console.info('Error reading file', err) + return '' + }) + + const preparedText = prepareFullTextForTranslation( + inputText, + startTagForNoTranslate, + endTagForNoTranslate, + ) + fs.writeFileSync(tempFilePath, preparedText) + + const writePromises = targetLanguages.map(async (targetLang: TargetLanguageCode) => { + const outputFileName = buildOutputFileName(targetLang, outputFileNamePattern) + await ensureOutputDirectoryExists(outputFileName) + + const outputText = + baseFileContent && fs.existsSync(outputFileName) + ? await buildIncrementalTextOutput( + inputText, + baseFileContent, + await fs.promises.readFile(outputFileName, 'utf8'), + targetLang, + translator, + modelType, + startTagForNoTranslate, + endTagForNoTranslate, + ) + : await translateWholeTextFile( + inputText, + targetLang, + translator, + modelType, + startTagForNoTranslate, + endTagForNoTranslate, + ) + + await fs.promises.writeFile(outputFileName, outputText) + console.info(`Translated ${targetLang}`) + }) + + await Promise.all(writePromises) + } else if (fileExtension === '.json') { + const jsonString = await fs.promises.readFile(inputFilePath, 'utf8').catch((err) => { + console.info('Error reading file', err) + return '' + }) + + const inputJson = parseJsonString>(jsonString) + const baseJson = baseFileContent ? parseJsonString>(baseFileContent) : null + + if (inputJson === null) { + return + } + + const writePromises = targetLanguages.map(async (targetLang: TargetLanguageCode) => { + const outputFileName = buildOutputFileName(targetLang, outputFileNamePattern) + await ensureOutputDirectoryExists(outputFileName) + + const resultJsonString = await buildJsonOutput( + inputJson, + baseJson, + outputFileName, + targetLang, + translator, + modelType, + ) + + await fs.promises.writeFile(outputFileName, resultJsonString) + console.info(`Translated ${targetLang}`) + }) + + await Promise.all(writePromises) + } } diff --git a/src/utils.ts b/src/utils.ts index d9f9f6e..055f2ee 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,130 +1,556 @@ -import type { TargetLanguageCode, Translator, TextResult } from "deepl-node"; - -function replaceAll(str: string, search: string, replacement: string): string { - let index = str.indexOf(search); - while (index != -1) { - str = str.replace(search, replacement); - index = str.indexOf(search); - } - return str; -} - -function replaceParameterStringsInJSONValueWithKeepTags(value: string): string { - const termRegex = /({{.*?}}|{.*?})/g; - return value.replace(termRegex, (match) => `${match}`); -} - -function removeKeepTagsFromString(str: string): string { - if (!str.includes("")) return str; - - const textWithNoTranslateStartTagReplaced = replaceAll(str, "", ""); - const textWithNoTranslateEndTagReplaced = replaceAll( - textWithNoTranslateStartTagReplaced, - "", - "", - ); - return textWithNoTranslateEndTagReplaced; -} - -type PossibleRecursive = { - [K in keyof T]: T[K] extends object ? PossibleRecursive : T[K]; -}; - -type TranslatedJSONResults = Record< - TargetLanguageCode, - PossibleRecursive> ->; - -export const applyRecursive = async ( - inputJson: Record, - path: string[] = [], - operation: Function, - operationArgs: any[], -) => { - const keys = Object.keys(inputJson); - - for (const key of keys) { - const newPath = [...path, key]; - - if (typeof inputJson[key] === "object") { - await applyRecursive(inputJson[key], newPath, operation, operationArgs); - } else { - await operation(inputJson[key], newPath, ...operationArgs); - } - } -}; - -const translateRecursive = async ( - inputJson: Record, - targetLanguages: TargetLanguageCode[], - translator: Translator, - translatedResults: TranslatedJSONResults, -) => { - const translate = async ( - value: string, - path: string[], - targetLanguages: TargetLanguageCode[], - translator: Translator, - translatedResults: TranslatedJSONResults, - ) => { - const textToBeTranslated = - replaceParameterStringsInJSONValueWithKeepTags(value); - - for (const targetLanguage of targetLanguages) { - const textResult = (await translator.translateText( - textToBeTranslated, - null, - targetLanguage, - { - preserveFormatting: true, - tagHandling: "xml", - ignoreTags: ["keep"], - }, - )) as TextResult; - - if (!translatedResults[targetLanguage]) { - translatedResults[targetLanguage] = {}; - } - - const translatedText = textResult.text; - const resultText = removeKeepTagsFromString(translatedText); - - // Assign the translated text to its original position in object - let currentKey: Record = translatedResults[targetLanguage]; - for (let i = 0; i < path.length; i++) { - if (i === path.length - 1) { - currentKey[path[i]] = resultText; - } else { - if (!currentKey[path[i]]) { - currentKey[path[i]] = {}; - } - currentKey = currentKey[path[i]]; - } - } - } - }; - - await applyRecursive(inputJson, [], translate, [ - targetLanguages, - translator, - translatedResults, - ]); - - return translatedResults; -}; - -function buildOutputFileName( - targetLang: string, - outputFileNamePattern: string, -) { - return `${outputFileNamePattern.replace("{language}", targetLang)}` -} - -export { - replaceAll, - removeKeepTagsFromString, - replaceParameterStringsInJSONValueWithKeepTags, - translateRecursive, - buildOutputFileName, - TranslatedJSONResults, -}; +import type { TargetLanguageCode, Translator, TextResult } from "deepl-node"; +import type { ModelType } from "./main"; + +const maxTextsPerBatch = 50 + +export interface TranslatedTextResult { + lang: TargetLanguageCode + text: string[] +} + +interface CollectedStrings { + keys: string[] + values: string[] +} + +interface TranslateTextsOptions { + modelType?: ModelType + preprocess?: (value: string) => string + postprocess?: (value: string) => string +} + +export interface TextDocument { + lines: string[] + eol: '\n' | '\r\n' + endsWithNewline: boolean +} + +export interface TextLineMetadata { + preparedLine: string + shouldTranslate: boolean + outputLine: string +} + +export interface LineDiffOperation { + type: 'equal' | 'insert' | 'delete' + lines: string[] +} + +type PossibleRecursive = { + [K in keyof T]: T[K] extends object ? PossibleRecursive : T[K] +} + +type TranslatedJSONResults = Record>> + +function replaceAll(str: string, search: string, replacement: string): string { + let index = str.indexOf(search) + while (index !== -1) { + str = str.replace(search, replacement) + index = str.indexOf(search) + } + return str +} + +function replaceParameterStringsInJSONValueWithKeepTags(value: string): string { + const termRegex = /({{.*?}}|{.*?})/g + return value.replace(termRegex, (match) => `${match}`) +} + +function removeKeepTagsFromString(str: string): string { + if (!str.includes('')) return str + + const textWithNoTranslateStartTagReplaced = replaceAll(str, '', '') + const textWithNoTranslateEndTagReplaced = replaceAll(textWithNoTranslateStartTagReplaced, '', '') + return textWithNoTranslateEndTagReplaced +} + +/** + * Collects all string values from a JSON object along with their dot-notation keys. + */ +function collectAllStringsFromJson(json: Record, prefix: string = ''): CollectedStrings { + const keys: string[] = [] + const values: string[] = [] + + interface StackItem { + obj: Record + currentPrefix: string + keysToProcess?: string[] + currentKeyIndex?: number + } + + const stack: StackItem[] = [ + { + obj: json, + currentPrefix: prefix, + keysToProcess: Object.keys(json), + currentKeyIndex: 0, + }, + ] + + while (stack.length > 0) { + const current = stack[stack.length - 1] + const { obj, currentPrefix, keysToProcess = [], currentKeyIndex = 0 } = current + + if (currentKeyIndex >= keysToProcess.length) { + stack.pop() + continue + } + + const key = keysToProcess[currentKeyIndex] + current.currentKeyIndex = currentKeyIndex + 1 + + if (!Object.prototype.hasOwnProperty.call(obj, key)) continue + + const value = obj[key] + const escapedKey = key.replace(/\./g, '\\.') + const newKey = currentPrefix ? `${currentPrefix}.${escapedKey}` : escapedKey + + if (typeof value === 'string') { + keys.push(newKey) + values.push(value) + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + stack.push({ + obj: value, + currentPrefix: newKey, + keysToProcess: Object.keys(value), + currentKeyIndex: 0, + }) + } + } + + return { keys, values } +} + +function collectStringMapFromJson(json: Record) { + const { keys, values } = collectAllStringsFromJson(json) + return new Map(keys.map((key, index) => [key, values[index]])) +} + +async function translateWithExponentialBackoffRetry( + batch: string[], + targetLanguage: TargetLanguageCode, + translator: Translator, + modelType?: ModelType, + maxRetries: number = 5, + baseDelay: number = 1000, +): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const translateOptions: { + preserveFormatting: boolean + tagHandling: 'xml' + ignoreTags: string[] + modelType?: ModelType + } = { + preserveFormatting: true, + tagHandling: 'xml', + ignoreTags: ['keep'], + } + + if (modelType) { + translateOptions.modelType = modelType + } + + const result = await translator.translateText(batch, null, targetLanguage, translateOptions) + return Array.isArray(result) ? result : [result] + } catch (error: any) { + if (error.message?.includes('Too many requests') || error.status === 429) { + if (attempt === maxRetries) { + throw error + } + + const delay = baseDelay * Math.pow(2, attempt) + console.log(`Rate limited (429) on attempt ${attempt + 1}, retrying in ${delay}ms...`) + + await new Promise((resolve) => setTimeout(resolve, delay)) + continue + } + + throw error + } + } + + throw new Error('Unexpected error in translateWithRetry') +} + +async function translateTexts( + sourceStrings: string[], + targetLanguage: TargetLanguageCode, + translator: Translator, + options: TranslateTextsOptions = {}, +): Promise { + if (sourceStrings.length === 0) { + return [] + } + + const preprocess = options.preprocess ?? replaceParameterStringsInJSONValueWithKeepTags + const postprocess = options.postprocess ?? ((value: string) => value) + const textsToBeTranslated = sourceStrings.map(preprocess) + const maxRequestSizeBytes = 128 * 1024 + const estimatedOverheadBytes = 2048 + const maxTextSizeBytes = maxRequestSizeBytes - estimatedOverheadBytes + const translatedTexts: string[] = [] + + let currentBatch: string[] = [] + let currentBatchSize = 0 + + const flushBatch = async () => { + if (currentBatch.length === 0) { + return + } + + const result = await translateWithExponentialBackoffRetry( + currentBatch, + targetLanguage, + translator, + options.modelType, + ) + + translatedTexts.push(...result.map((item) => postprocess(item.text))) + currentBatch = [] + currentBatchSize = 0 + } + + for (const text of textsToBeTranslated) { + const textSizeBytes = new TextEncoder().encode(text).length + + if (textSizeBytes > maxTextSizeBytes) { + throw new Error( + `Text length ${text.length} exceeds maxTextSizeBytes ${maxTextSizeBytes} (encoded size: ${textSizeBytes} bytes)`, + ) + } + + if ( + currentBatch.length > 0 + && (currentBatchSize + textSizeBytes > maxTextSizeBytes || currentBatch.length >= maxTextsPerBatch) + ) { + await flushBatch() + } + + currentBatch.push(text) + currentBatchSize += textSizeBytes + } + + await flushBatch() + return translatedTexts +} + +function groupItemsByLang(arr: TranslatedTextResult[]): Record { + return arr.reduce((acc: Record, currentItem: TranslatedTextResult) => { + if (acc[currentItem.lang]) { + acc[currentItem.lang] = acc[currentItem.lang].concat(currentItem.text) + } else { + acc[currentItem.lang] = currentItem.text + } + return acc + }, {} as Record) +} + +function translateStrings( + sourceStrings: string[], + targetLanguage: TargetLanguageCode, + translator: Translator, + modelType?: ModelType, +): Promise[] { + const textsToBeTranslated = sourceStrings.map(replaceParameterStringsInJSONValueWithKeepTags) + const maxRequestSizeBytes = 128 * 1024 + const estimatedOverheadBytes = 2048 + const maxTextSizeBytes = maxRequestSizeBytes - estimatedOverheadBytes + const promises: Promise[] = [] + + let currentBatch: string[] = [] + let currentBatchSize = 0 + + const createBatchPromise = (batch: string[]) => + translateWithExponentialBackoffRetry(batch, targetLanguage, translator, modelType).then((result) => ({ + lang: targetLanguage, + text: result.map((item) => item.text), + })) + + const flushBatch = () => { + if (currentBatch.length === 0) { + return + } + + promises.push(createBatchPromise(currentBatch)) + currentBatch = [] + currentBatchSize = 0 + } + + for (const text of textsToBeTranslated) { + const textSizeBytes = new TextEncoder().encode(text).length + + if (textSizeBytes > maxTextSizeBytes) { + throw new Error( + `Text length ${text.length} exceeds maxTextSizeBytes ${maxTextSizeBytes} (encoded size: ${textSizeBytes} bytes)`, + ) + } + + if ( + currentBatch.length > 0 + && (currentBatchSize + textSizeBytes > maxTextSizeBytes || currentBatch.length >= maxTextsPerBatch) + ) { + flushBatch() + } + + currentBatch.push(text) + currentBatchSize += textSizeBytes + } + + flushBatch() + + return promises +} + +function buildOutputFileName(targetLang: string, outputFileNamePattern: string) { + return outputFileNamePattern.replace(/\{language\}/g, targetLang) +} + +function splitJsonKeyPath(key: string) { + return key.split(/(? part.replace(/\\\./g, '.')) +} + +function setNestedValue(target: Record, jsonKey: string, value: string) { + const keyParts = splitJsonKeyPath(jsonKey) + let currentLevel = target + + for (let index = 0; index < keyParts.length; index++) { + const part = keyParts[index] + const isLastPart = index === keyParts.length - 1 + + if (isLastPart) { + currentLevel[part] = removeKeepTagsFromString(value) + return + } + + if (!currentLevel[part] || typeof currentLevel[part] !== 'object' || Array.isArray(currentLevel[part])) { + currentLevel[part] = {} + } + + currentLevel = currentLevel[part] + } +} + +function deleteNestedValue(target: Record, jsonKey: string) { + const keyParts = splitJsonKeyPath(jsonKey) + + const remove = (currentLevel: Record, depth: number): boolean => { + const part = keyParts[depth] + + if (!Object.prototype.hasOwnProperty.call(currentLevel, part)) { + return false + } + + if (depth === keyParts.length - 1) { + delete currentLevel[part] + return Object.keys(currentLevel).length === 0 + } + + const nextLevel = currentLevel[part] + if (!nextLevel || typeof nextLevel !== 'object' || Array.isArray(nextLevel)) { + return false + } + + const shouldDeleteCurrent = remove(nextLevel, depth + 1) + if (shouldDeleteCurrent) { + delete currentLevel[part] + } + + return Object.keys(currentLevel).length === 0 + } + + remove(target, 0) +} + +function buildOutputJson(translatedTexts: string[], jsonKeys: string[]): Record { + const result: Record = {} + + for (let i = 0; i < jsonKeys.length; i++) { + setNestedValue(result, jsonKeys[i], translatedTexts[i]) + } + + return result +} + +function parseJsonString(jsonString: string): T | null { + try { + return JSON.parse(jsonString) as T + } catch { + return null + } +} + +function pushLineDiffOperation(operations: LineDiffOperation[], type: LineDiffOperation['type'], line: string) { + const previousOperation = operations[operations.length - 1] + if (previousOperation && previousOperation.type === type) { + previousOperation.lines.push(line) + return + } + + operations.push({ type, lines: [line] }) +} + +function diffLines(baseLines: string[], currentLines: string[]): LineDiffOperation[] { + const rows = baseLines.length + 1 + const cols = currentLines.length + 1 + const lcs: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)) + + for (let baseIndex = baseLines.length - 1; baseIndex >= 0; baseIndex--) { + for (let currentIndex = currentLines.length - 1; currentIndex >= 0; currentIndex--) { + if (baseLines[baseIndex] === currentLines[currentIndex]) { + lcs[baseIndex][currentIndex] = lcs[baseIndex + 1][currentIndex + 1] + 1 + } else { + lcs[baseIndex][currentIndex] = Math.max(lcs[baseIndex + 1][currentIndex], lcs[baseIndex][currentIndex + 1]) + } + } + } + + const operations: LineDiffOperation[] = [] + let baseIndex = 0 + let currentIndex = 0 + + while (baseIndex < baseLines.length && currentIndex < currentLines.length) { + if (baseLines[baseIndex] === currentLines[currentIndex]) { + pushLineDiffOperation(operations, 'equal', currentLines[currentIndex]) + baseIndex++ + currentIndex++ + } else if (lcs[baseIndex + 1][currentIndex] >= lcs[baseIndex][currentIndex + 1]) { + pushLineDiffOperation(operations, 'delete', baseLines[baseIndex]) + baseIndex++ + } else { + pushLineDiffOperation(operations, 'insert', currentLines[currentIndex]) + currentIndex++ + } + } + + while (baseIndex < baseLines.length) { + pushLineDiffOperation(operations, 'delete', baseLines[baseIndex]) + baseIndex++ + } + + while (currentIndex < currentLines.length) { + pushLineDiffOperation(operations, 'insert', currentLines[currentIndex]) + currentIndex++ + } + + return operations +} + +function parseTextDocument(text: string): TextDocument { + const eol: '\n' | '\r\n' = text.includes('\r\n') ? '\r\n' : '\n' + const normalizedText = text.replace(/\r\n/g, '\n') + const endsWithNewline = normalizedText.endsWith('\n') + const lines = normalizedText.split('\n') + + if (endsWithNewline) { + lines.pop() + } + + return { lines, eol, endsWithNewline } +} + +function stringifyTextDocument(document: TextDocument) { + const body = document.lines.join(document.eol) + return document.endsWithNewline ? `${body}${document.eol}` : body +} + +function getTextLineMetadata( + lines: string[], + startTag?: string, + endTag?: string, +): TextLineMetadata[] { + let insideNoTranslateBlock = false + const hasNoTranslateTags = Boolean(startTag && endTag) + + return lines.map((line) => { + const hasStartTag = Boolean(hasNoTranslateTags && startTag && line.includes(startTag)) + const hasEndTag = Boolean(hasNoTranslateTags && endTag && line.includes(endTag)) + const opensMultiLineBlock = hasStartTag && !hasEndTag + const closesMultiLineBlock = hasEndTag && insideNoTranslateBlock + + if (insideNoTranslateBlock && !hasEndTag) { + return { + preparedLine: line, + shouldTranslate: false, + outputLine: line, + } + } + + if (opensMultiLineBlock) { + insideNoTranslateBlock = true + return { + preparedLine: line, + shouldTranslate: false, + outputLine: line, + } + } + + if (closesMultiLineBlock) { + insideNoTranslateBlock = false + return { + preparedLine: line, + shouldTranslate: false, + outputLine: line, + } + } + + let preparedLine = line + if (startTag && hasStartTag) { + preparedLine = replaceAll(preparedLine, startTag, '') + } + if (endTag && hasEndTag) { + preparedLine = replaceAll(preparedLine, endTag, '') + } + + if (hasEndTag) { + insideNoTranslateBlock = false + } + + return { + preparedLine, + shouldTranslate: true, + outputLine: line, + } + }) +} + +function createTranslatorOptions(timeoutValue: string | undefined): { minTimeout?: number } | undefined { + if (!timeoutValue) { + return undefined + } + + const parsedTimeoutValue = parseInt(timeoutValue, 10) + const isValidTimeout = !isNaN(parsedTimeoutValue) && parsedTimeoutValue > 0 + + if (isValidTimeout) { + return { minTimeout: parsedTimeoutValue } + } + + console.warn( + `Invalid timeout value: ${timeoutValue}. Expected a positive number in milliseconds. Ignoring timeout parameter.` + ) + return undefined +} + +export { + replaceAll, + removeKeepTagsFromString, + replaceParameterStringsInJSONValueWithKeepTags, + groupItemsByLang, + translateTexts, + translateStrings, + buildOutputFileName, + buildOutputJson, + TranslatedJSONResults, + collectAllStringsFromJson, + collectStringMapFromJson, + deleteNestedValue, + parseJsonString, + setNestedValue, + CollectedStrings, + createTranslatorOptions, + diffLines, + getTextLineMetadata, + parseTextDocument, + stringifyTextDocument, +} diff --git a/tests/git.test.ts b/tests/git.test.ts new file mode 100644 index 0000000..9dfa576 --- /dev/null +++ b/tests/git.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +const execFileAsyncMock = vi.fn() + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})) + +vi.mock('util', () => ({ + promisify: vi.fn(() => execFileAsyncMock), +})) + +describe('getBaseFileContent', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + process.env.GITHUB_SERVER_URL = 'https://github.com' + process.env.GITHUB_REPOSITORY = 'base/repo' + }) + + test('uses the merge-base commit when diffing against a configured branch', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) // fetch + .mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // merge-base + .mockResolvedValueOnce({ stdout: '{"warning":"Warning"}', stderr: '' }) // show + + const { getBaseFileContent } = await import('../src/git') + + const result = await getBaseFileContent({ + workspacePath: '/repo', + inputFileRelativePath: 'public/locales/translation.en.json', + baseRef: 'release/leapfrog', + }) + + expect(result).toBe('{"warning":"Warning"}') + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 1, + 'git', + [ + 'fetch', + '--no-tags', + 'https://github.com/base/repo.git', + '+refs/heads/release/leapfrog:refs/remotes/base/release/leapfrog', + ], + { cwd: '/repo', maxBuffer: 10 * 1024 * 1024 }, + ) + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 2, + 'git', + ['merge-base', 'HEAD', 'refs/remotes/base/release/leapfrog'], + { cwd: '/repo', maxBuffer: 10 * 1024 * 1024 }, + ) + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 3, + 'git', + ['show', 'abc123:public/locales/translation.en.json'], + { cwd: '/repo', maxBuffer: 10 * 1024 * 1024 }, + ) + }) + + test('falls back to full translation when merge-base lookup fails', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) // fetch + .mockRejectedValueOnce(new Error('merge-base failed')) + + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { getBaseFileContent } = await import('../src/git') + + const result = await getBaseFileContent({ + workspacePath: '/repo', + inputFileRelativePath: 'public/locales/translation.en.json', + baseRef: 'release/leapfrog', + }) + + expect(result).toBeNull() + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + expect(consoleWarnSpy).toHaveBeenCalled() + consoleWarnSpy.mockRestore() + }) + + test('falls back to origin when workflow repository metadata is unavailable', async () => { + delete process.env.GITHUB_SERVER_URL + delete process.env.GITHUB_REPOSITORY + + execFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) // fetch + .mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // merge-base + .mockResolvedValueOnce({ stdout: '{"warning":"Warning"}', stderr: '' }) // show + + const { getBaseFileContent } = await import('../src/git') + + await getBaseFileContent({ + workspacePath: '/repo', + inputFileRelativePath: 'public/locales/translation.en.json', + baseRef: 'release/leapfrog', + }) + + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 1, + 'git', + [ + 'fetch', + '--no-tags', + 'origin', + '+refs/heads/release/leapfrog:refs/remotes/origin/release/leapfrog', + ], + { cwd: '/repo', maxBuffer: 10 * 1024 * 1024 }, + ) + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 2, + 'git', + ['merge-base', 'HEAD', 'refs/remotes/origin/release/leapfrog'], + { cwd: '/repo', maxBuffer: 10 * 1024 * 1024 }, + ) + }) + + test('normalizes Windows path separators before reading the blob', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) // fetch + .mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // merge-base + .mockResolvedValueOnce({ stdout: '{"warning":"Warning"}', stderr: '' }) // show + + const { getBaseFileContent } = await import('../src/git') + + await getBaseFileContent({ + workspacePath: '/repo', + inputFileRelativePath: 'public\\locales\\translation.en.json', + baseRef: 'release/leapfrog', + }) + + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 3, + 'git', + ['show', 'abc123:public/locales/translation.en.json'], + { cwd: '/repo', maxBuffer: 10 * 1024 * 1024 }, + ) + }) +}) diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..edbd491 --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' +import { Translator } from 'deepl-node' + +// Mock deepl-node before any imports +vi.mock('deepl-node', () => ({ + Translator: vi.fn(), +})) + +// Mock main to prevent it from running +vi.mock('../src/main', () => ({ + main: vi.fn(), +})) + +// Mock utils to spy on createTranslatorOptions +vi.mock('../src/utils', async () => { + const actual = await vi.importActual('../src/utils') + return { + ...actual, + createTranslatorOptions: vi.fn(), + } +}) + +describe('index - timeout parameter initialization', () => { + const originalEnv = process.env + let mockTranslatorConstructor: ReturnType + let mockCreateTranslatorOptions: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + process.env = { ...originalEnv } + + // Get fresh mocks + const { Translator } = await import('deepl-node') + mockTranslatorConstructor = vi.mocked(Translator) + mockTranslatorConstructor.mockClear() + + const utils = await import('../src/utils') + mockCreateTranslatorOptions = vi.mocked(utils.createTranslatorOptions) + mockCreateTranslatorOptions.mockClear() + }) + + afterEach(() => { + process.env = originalEnv + vi.clearAllMocks() + }) + + test('should call createTranslatorOptions with timeout from environment and pass result to Translator', async () => { + process.env.deepl_api_key = 'test-api-key' + process.env.timeout = '10000' + process.env.GITHUB_WORKSPACE = '/workspace' + process.env.input_file_path = 'test.md' + process.env.output_file_name_pattern = 'output.md' + + // Mock the return value + mockCreateTranslatorOptions.mockReturnValue({ minTimeout: 10000 }) + + // Mock Translator constructor + mockTranslatorConstructor.mockImplementation(() => ({ + getTargetLanguages: vi.fn().mockResolvedValue([]), + })) + + // Reset modules after setting env vars to ensure fresh import + vi.resetModules() + + // Re-import mocks after reset + const { Translator } = await import('deepl-node') + mockTranslatorConstructor = vi.mocked(Translator) + const utils = await import('../src/utils') + mockCreateTranslatorOptions = vi.mocked(utils.createTranslatorOptions) + mockCreateTranslatorOptions.mockReturnValue({ minTimeout: 10000 }) + mockTranslatorConstructor.mockImplementation(() => ({ + getTargetLanguages: vi.fn().mockResolvedValue([]), + })) + + // Import index to trigger initialization + await import('../src/index') + + // Wait a bit for the module to initialize + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockCreateTranslatorOptions).toHaveBeenCalledWith('10000') + expect(mockTranslatorConstructor).toHaveBeenCalledWith('test-api-key', { minTimeout: 10000 }) + }) + + test('should call createTranslatorOptions with undefined when timeout is not set', async () => { + process.env.deepl_api_key = 'test-api-key' + delete process.env.timeout + process.env.GITHUB_WORKSPACE = '/workspace' + process.env.input_file_path = 'test.md' + process.env.output_file_name_pattern = 'output.md' + + // Reset modules after setting env vars to ensure fresh import + vi.resetModules() + + // Re-import mocks after reset + const { Translator } = await import('deepl-node') + mockTranslatorConstructor = vi.mocked(Translator) + const utils = await import('../src/utils') + mockCreateTranslatorOptions = vi.mocked(utils.createTranslatorOptions) + mockCreateTranslatorOptions.mockReturnValue(undefined) + mockTranslatorConstructor.mockImplementation(() => ({ + getTargetLanguages: vi.fn().mockResolvedValue([]), + })) + + // Import index to trigger initialization + await import('../src/index') + + // Wait a bit for the module to initialize + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockCreateTranslatorOptions).toHaveBeenCalledWith(undefined) + expect(mockTranslatorConstructor).toHaveBeenCalledWith('test-api-key', undefined) + }) +}) diff --git a/tests/main.test.ts b/tests/main.test.ts index 2ef076b..d9a5024 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,138 +1,353 @@ -import type { SpyInstance } from "vitest"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type { MainFunctionParams } from "../src/main"; -import { main } from "../src/main"; -import fs from "fs"; - -vi.mock("deepl-node", () => ({ - TargetLanguageCode: "", -})); - -describe("main - HTMLlike files", () => { - const mockTranslator = { - translateText: vi.fn().mockResolvedValue(() => - Promise.resolve({ - text: "translated text", - }), - ), - } as any; - - vi.mock("deepl-node"); - vi.mock("fs"); - - let mockTranslatorSpy: SpyInstance; - let existsSpy: SpyInstance; - let readFileSyncSpy: SpyInstance; - let writeFileSyncSpy: SpyInstance; - let writeFileSpy: SpyInstance; - let readFileSpy: SpyInstance; - - const fakeInputFileFolderPath = "test"; - const fakeInputFilename = "inputFilePath.md"; - const fakeOutputFileNamePattern = `${fakeInputFileFolderPath}/`; - const fakeTempFilePath = "to_translate.txt"; - const fakeReadFileResult = Buffer.from("Your mocked data here"); - - beforeEach(() => { - mockTranslatorSpy = vi.spyOn(mockTranslator, "translateText"); - existsSpy = vi.mocked(fs.existsSync).mockReturnValue(true); - readFileSyncSpy = vi - .mocked(fs.readFileSync) - .mockReturnValue("readFile sync result"); - writeFileSyncSpy = vi.mocked(fs.writeFileSync).mockReturnValue(); - writeFileSpy = vi - .mocked(fs.writeFile) - .mockImplementation((path, data, callback) => { - callback(null); - }); - readFileSpy = vi.mocked(fs.readFile).mockImplementation((( - _path: any, - _encoding, - callback: (err: any, data: Buffer) => void, - ) => { - callback(null, fakeReadFileResult); // Pass the mocked data - }) as any); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - test("should run without errors", async () => { - const testParams: MainFunctionParams = { - translator: mockTranslator, - inputFilePath: `${fakeInputFileFolderPath}/${fakeInputFilename}`, - outputFileNamePattern: fakeOutputFileNamePattern, - tempFilePath: fakeTempFilePath, - fileExtensionsThatAllowForIgnoringBlocks: [".html", ".xml", ".md"], - targetLanguages: ["de"], - }; - await expect(main(testParams)).resolves.not.toThrow(); - expect(mockTranslatorSpy).toHaveBeenCalled(); - }); -}); - -describe("main - JSON files", () => { - const mockTranslator = { - translateText: vi.fn().mockResolvedValue(() => - Promise.resolve({ - text: "{'someKey': 'translated text'}", - }), - ), - } as any; - - vi.mock("deepl-node"); - vi.mock("fs"); - - let mockTranslatorSpy: SpyInstance; - let existsSpy: SpyInstance; - let readFileSyncSpy: SpyInstance; - let writeFileSyncSpy: SpyInstance; - let writeFileSpy: SpyInstance; - let readFileSpy: SpyInstance; - - const fakeInputFileFolderPath = "test"; - const fakeInputFilename = "inputFilePath.json"; - const fakeOutputFileNamePattern = `${fakeInputFileFolderPath}/{language}.json`; - const fakeTempFilePath = "to_translate.txt"; - const testJSON = { - welcome: "Welcome, {name}!", - language: "Language", - description: "This is a wonderful world isn't it?", - }; - const testJSONstring = JSON.stringify(testJSON); - - const fakeReadFileResult = Buffer.from(testJSONstring); - - beforeEach(() => { - mockTranslatorSpy = vi.spyOn(mockTranslator, "translateText"); - existsSpy = vi.mocked(fs.existsSync).mockReturnValue(true); - readFileSyncSpy = vi.mocked(fs.readFileSync).mockReturnValue(testJSONstring); - writeFileSyncSpy = vi.mocked(fs.writeFileSync).mockReturnValue(); - writeFileSpy = vi - .mocked(fs.writeFile) - .mockImplementation((path, data, callback) => { - callback(null); - }); - readFileSpy = vi.mocked(fs.readFile).mockImplementation((( - _path: any, - _encoding, - callback: (err: any, data: Buffer) => void, - ) => { - callback(null, fakeReadFileResult); // Pass the mocked data - }) as any); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - test("should run without errors", async () => { - const testParams: MainFunctionParams = { - translator: mockTranslator, - inputFilePath: `${fakeInputFileFolderPath}/${fakeInputFilename}`, - outputFileNamePattern: fakeOutputFileNamePattern, - tempFilePath: fakeTempFilePath, - fileExtensionsThatAllowForIgnoringBlocks: [".html", ".xml", ".md"], - targetLanguages: ["de"], - }; - await expect(main(testParams)).resolves.not.toThrow(); - expect(mockTranslatorSpy).toHaveBeenCalled(); - }); -}); +import { afterEach, beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest' +import type { MainFunctionParams } from '../src/main' +import { main } from '../src/main' +import fs from 'fs' + +vi.mock('deepl-node', () => ({ + TargetLanguageCode: '', +})) + +// Mock fs module at the top level +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, + }, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})) + +describe('main - HTMLlike files', () => { + const mockTranslator = { + translateText: vi.fn().mockResolvedValue({ + text: 'translated text', + }), + } as any + + let mockTranslatorSpy: MockInstance + + const fakeInputFileFolderPath = 'test' + const fakeInputFilename = 'inputFilePath.md' + const fakeOutputFileNamePattern = `${fakeInputFileFolderPath}/{language}.md` + const fakeTempFilePath = 'to_translate.txt' + const fakeReadFileResult = 'Your mocked data here' + + beforeEach(() => { + mockTranslatorSpy = vi.spyOn(mockTranslator, 'translateText') + + // Mock fs methods + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('readFile sync result') + vi.mocked(fs.writeFileSync).mockReturnValue() + + // Mock fs.promises methods + vi.mocked(fs.promises.readFile).mockResolvedValue(fakeReadFileResult) + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined as any) + }) + afterEach(() => { + vi.clearAllMocks() + }) + test('should run without errors', async () => { + const testParams: MainFunctionParams = { + translator: mockTranslator, + inputFilePath: `${fakeInputFileFolderPath}/${fakeInputFilename}`, + outputFileNamePattern: fakeOutputFileNamePattern, + tempFilePath: fakeTempFilePath, + fileExtensionsThatAllowForIgnoringBlocks: ['.html', '.xml', '.md'], + targetLanguages: ['de'], + } + await expect(main(testParams)).resolves.not.toThrow() + expect(mockTranslatorSpy).toHaveBeenCalled() + }) +}) + +describe('main - JSON files', () => { + const mockTranslator = { + translateText: vi + .fn() + .mockResolvedValue([ + { text: 'translated text' }, + { text: 'another translated text' }, + { text: 'third translated text' }, + ]), + } as any + + let mockTranslatorSpy: MockInstance + + const fakeInputFileFolderPath = 'test' + const fakeInputFilename = 'inputFilePath.json' + const fakeOutputFileNamePattern = `${fakeInputFileFolderPath}/{language}.json` + const fakeTempFilePath = 'to_translate.txt' + const testJSON = { + welcome: 'Welcome, {{name}}!', + language: 'Language', + description: "This is a wonderful world isn't it?", + } + const testJSONstring = JSON.stringify(testJSON) + + beforeEach(() => { + mockTranslatorSpy = vi.spyOn(mockTranslator, 'translateText') + + // Mock fs methods + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(testJSONstring) + vi.mocked(fs.writeFileSync).mockReturnValue() + + // Mock fs.promises methods with valid JSON + vi.mocked(fs.promises.readFile).mockResolvedValue(testJSONstring) + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined as any) + }) + afterEach(() => { + vi.clearAllMocks() + }) + test('should run without errors', async () => { + const testParams: MainFunctionParams = { + translator: mockTranslator, + inputFilePath: `${fakeInputFileFolderPath}/${fakeInputFilename}`, + outputFileNamePattern: fakeOutputFileNamePattern, + tempFilePath: fakeTempFilePath, + fileExtensionsThatAllowForIgnoringBlocks: ['.html', '.xml', '.md'], + targetLanguages: ['de'], + } + await expect(main(testParams)).resolves.not.toThrow() + expect(mockTranslatorSpy).toHaveBeenCalled() + }) +}) + +describe('main - ES-419 translation from English', () => { + const mockTranslator = { + translateText: vi.fn().mockResolvedValue({ + text: 'texto traducido', + }), + } as any + + let mockTranslatorSpy: MockInstance + + const fakeInputFileFolderPath = 'test' + const fakeInputFilename = 'inputFilePath.md' + const fakeOutputFileNamePattern = `${fakeInputFileFolderPath}/{language}.md` + const fakeTempFilePath = 'to_translate.txt' + const fakeReadFileResult = 'Hello, world!' + + beforeEach(() => { + mockTranslatorSpy = vi.spyOn(mockTranslator, 'translateText') + + // Mock fs methods + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('readFile sync result') + vi.mocked(fs.writeFileSync).mockReturnValue() + + // Mock fs.promises methods + vi.mocked(fs.promises.readFile).mockResolvedValue(fakeReadFileResult) + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined as any) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test('should successfully translate ES-419 from English with default modelType', async () => { + const testParams: MainFunctionParams = { + translator: mockTranslator, + inputFilePath: `${fakeInputFileFolderPath}/${fakeInputFilename}`, + outputFileNamePattern: fakeOutputFileNamePattern, + tempFilePath: fakeTempFilePath, + fileExtensionsThatAllowForIgnoringBlocks: ['.html', '.xml', '.md', '.txt'], + targetLanguages: ['es-419'], + } + + await expect(main(testParams)).resolves.not.toThrow() + expect(mockTranslatorSpy).toHaveBeenCalled() + + // Verify translateText was called with correct parameters + const [textToTranslate, sourceLanguage, targetLanguage, options] = mockTranslatorSpy.mock.calls[0] + expect(textToTranslate).toEqual([fakeReadFileResult]) + expect(sourceLanguage).toBe(null) + expect(targetLanguage).toBe('es-419') + expect(options).toEqual({ + preserveFormatting: true, + tagHandling: 'xml', + ignoreTags: ['keep'], + }) + // Should not include modelType when not provided + expect(options).not.toHaveProperty('modelType') + }) + + test('should successfully translate ES-419 from English with prefer_quality_optimized modelType', async () => { + const testParams: MainFunctionParams = { + translator: mockTranslator, + inputFilePath: `${fakeInputFileFolderPath}/${fakeInputFilename}`, + outputFileNamePattern: fakeOutputFileNamePattern, + tempFilePath: fakeTempFilePath, + fileExtensionsThatAllowForIgnoringBlocks: ['.html', '.xml', '.md', '.txt'], + targetLanguages: ['es-419'], + modelType: 'prefer_quality_optimized', + } + + await expect(main(testParams)).resolves.not.toThrow() + expect(mockTranslatorSpy).toHaveBeenCalled() + + // Verify translateText was called with correct parameters including modelType + const [textToTranslate, sourceLanguage, targetLanguage, options] = mockTranslatorSpy.mock.calls[0] + expect(textToTranslate).toEqual([fakeReadFileResult]) + expect(sourceLanguage).toBe(null) + expect(targetLanguage).toBe('es-419') + expect(options).toEqual({ + preserveFormatting: true, + tagHandling: 'xml', + ignoreTags: ['keep'], + modelType: 'prefer_quality_optimized', + }) + }) +}) + +describe('main - incremental JSON translation', () => { + const mockTranslator = { + translateText: vi.fn().mockImplementation(async (texts: string[]) => + texts.map((text) => ({ text: `translated:${text}` })), + ), + } as any + + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.writeFileSync).mockReturnValue() + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined as any) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test('should only translate changed JSON keys and preserve unchanged target values', async () => { + const currentJson = { + title: 'Updated title', + description: 'Same description', + nested: { + added: 'Brand new', + }, + } + const baseJson = { + title: 'Original title', + description: 'Same description', + removed: 'Delete me', + } + const targetJson = { + title: 'Titulo original', + description: 'Descripcion igual', + removed: 'Borrame', + } + + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath: any) => { + if (filePath === 'test/input.json') return JSON.stringify(currentJson) + if (filePath === 'test/es.json') return JSON.stringify(targetJson) + return '' + }) + + await main({ + translator: mockTranslator, + inputFilePath: 'test/input.json', + outputFileNamePattern: 'test/{language}.json', + tempFilePath: 'to_translate.txt', + fileExtensionsThatAllowForIgnoringBlocks: ['.html', '.xml', '.md', '.txt'], + targetLanguages: ['es'], + baseFileContent: JSON.stringify(baseJson), + }) + + expect(mockTranslator.translateText).toHaveBeenCalledTimes(1) + expect(mockTranslator.translateText).toHaveBeenCalledWith( + ['Updated title', 'Brand new'], + null, + 'es', + { + preserveFormatting: true, + tagHandling: 'xml', + ignoreTags: ['keep'], + }, + ) + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith( + 'test/es.json', + JSON.stringify( + { + title: 'translated:Updated title', + description: 'Descripcion igual', + nested: { + added: 'translated:Brand new', + }, + }, + null, + 2, + ), + ) + }) +}) + +describe('main - incremental text translation', () => { + const mockTranslator = { + translateText: vi.fn().mockImplementation(async (texts: string[]) => + texts.map((text) => ({ text: `translated:${text}` })), + ), + } as any + + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.writeFileSync).mockReturnValue() + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined as any) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test('should patch only changed text lines into the existing translated file', async () => { + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath: any) => { + if (filePath === 'test/input.md') return 'Hello there\nSame line\n' + if (filePath === 'test/es.md') return 'Hola\nMisma linea\nRemove me\n' + return '' + }) + + await main({ + translator: mockTranslator, + inputFilePath: 'test/input.md', + outputFileNamePattern: 'test/{language}.md', + tempFilePath: 'to_translate.txt', + fileExtensionsThatAllowForIgnoringBlocks: ['.html', '.xml', '.md', '.txt'], + targetLanguages: ['es'], + baseFileContent: 'Hello\nSame line\nRemove me\n', + }) + + expect(mockTranslator.translateText).toHaveBeenCalledTimes(1) + expect(mockTranslator.translateText).toHaveBeenCalledWith( + ['Hello there'], + null, + 'es', + { + preserveFormatting: true, + tagHandling: 'xml', + ignoreTags: ['keep'], + }, + ) + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith( + 'test/es.md', + 'translated:Hello there\nMisma linea\n', + ) + }) +}) diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 7f46418..2cc6223 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,111 +1,1069 @@ import { describe, expect, test, vi } from "vitest"; -import { applyRecursive, removeKeepTagsFromString, replaceAll, replaceParameterStringsInJSONValueWithKeepTags } from '../src/utils' +import { + removeKeepTagsFromString, + replaceAll, + replaceParameterStringsInJSONValueWithKeepTags, + translateTexts, + collectAllStringsFromJson, + buildOutputFileName, + buildOutputJson, + translateStrings, + groupItemsByLang, + createTranslatorOptions, + getTextLineMetadata, + type TranslatedTextResult, +} from '../src/utils' +import * as fs from 'fs' +import * as path from 'path' describe('replaceAll', () => { test('should replace all occurrences of a substring in a string', () => { - const str = 'hello world'; - const search = 'l'; - const replacement = 'x'; - const expected = 'hexxo worxd'; - const result = replaceAll(str, search, replacement); - expect(result).toEqual(expected); - }); + const str = 'hello world' + const search = 'l' + const replacement = 'x' + const expected = 'hexxo worxd' + const result = replaceAll(str, search, replacement) + expect(result).toEqual(expected) + }) test('should return the original string if the search string is not found', () => { - const str = 'hello world'; - const search = 'z'; - const replacement = 'x'; - const expected = 'hello world'; - const result = replaceAll(str, search, replacement); - expect(result).toEqual(expected); - }); -}); + const str = 'hello world' + const search = 'z' + const replacement = 'x' + const expected = 'hello world' + const result = replaceAll(str, search, replacement) + expect(result).toEqual(expected) + }) +}) describe('removeKeepTagsFromString', () => { test('should remove all and tags from a string', () => { - const str = 'hello world'; - const expected = 'hello world'; - const result = removeKeepTagsFromString(str); - expect(result).toEqual(expected); - }); + const str = 'hello world' + const expected = 'hello world' + const result = removeKeepTagsFromString(str) + expect(result).toEqual(expected) + }) test('should return the original string if it does not contain tags', () => { - const str = 'hello world'; - const expected = 'hello world'; - const result = removeKeepTagsFromString(str); - expect(result).toEqual(expected); - }); -}); + const str = 'hello world' + const expected = 'hello world' + const result = removeKeepTagsFromString(str) + expect(result).toEqual(expected) + }) +}) + +describe('getTextLineMetadata', () => { + test('should handle empty lines array', () => { + const result = getTextLineMetadata([], '', '') + + expect(result).toEqual([]) + }) + + test('should handle missing start/end tags without throwing', () => { + expect(getTextLineMetadata(['Hello {{name}}'], undefined, undefined)).toEqual([ + { + preparedLine: 'Hello {{name}}', + shouldTranslate: true, + outputLine: 'Hello {{name}}', + }, + ]) + + expect(getTextLineMetadata(['Hello {{name}}'], undefined, '}}')).toEqual([ + { + preparedLine: 'Hello {{name}}', + shouldTranslate: true, + outputLine: 'Hello {{name}}', + }, + ]) + }) + + test('should not translate multi-line keep block boundary lines', () => { + const result = getTextLineMetadata( + [ + 'Translate me', + '', + 'Protected text', + '', + 'Translate me too', + ], + '', + '', + ) + + expect(result).toEqual([ + { + preparedLine: 'Translate me', + shouldTranslate: true, + outputLine: 'Translate me', + }, + { + preparedLine: '', + shouldTranslate: false, + outputLine: '', + }, + { + preparedLine: 'Protected text', + shouldTranslate: false, + outputLine: 'Protected text', + }, + { + preparedLine: '', + shouldTranslate: false, + outputLine: '', + }, + { + preparedLine: 'Translate me too', + shouldTranslate: true, + outputLine: 'Translate me too', + }, + ]) + }) + + test('should still translate lines with inline keep tags', () => { + const result = getTextLineMetadata(['Hello {{name}}'], '{{', '}}') + + expect(result).toEqual([ + { + preparedLine: 'Hello name', + shouldTranslate: true, + outputLine: 'Hello {{name}}', + }, + ]) + }) + + test('should preserve inline no-translate blocks while translating surrounding text', () => { + const result = getTextLineMetadata( + ['prefix protected suffix'], + '', + '', + ) + + expect(result).toEqual([ + { + preparedLine: 'prefix protected suffix', + shouldTranslate: true, + outputLine: 'prefix protected suffix', + }, + ]) + }) +}) + +describe('buildOutputFileName', () => { + test('should replace {language} with the target language', () => { + const outputFileNamePattern = 'output_{language}.json' + const targetLang = 'es' + const expected = 'output_es.json' + const result = buildOutputFileName(targetLang, outputFileNamePattern) + + expect(result).toEqual(expected) + }) + + test('should replace {language} with target language when multiple occurrences are present', () => { + const outputFileNamePattern = 'output_{language}_{language}.json' + const targetLang = 'es' + const expected = 'output_es_es.json' + const result = buildOutputFileName(targetLang, outputFileNamePattern) + + expect(result).toEqual(expected) + }) +}) describe('replaceParameterStringsInJSONValueWithKeepTags', () => { test('Should properly wrap {} and {{}} strings with keep tags', () => { - const input = 'Hello {World} and {{Universe}}'; - const expectedOutput = 'Hello {World} and {{Universe}}'; + const input = 'Hello {World} and {{Universe}}' + const expectedOutput = 'Hello {World} and {{Universe}}' - expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(expectedOutput); - }); + expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(expectedOutput) + }) test('Should handle empty {} and {{}}', () => { - const input = '{} and {{}}'; - const expectedOutput = '{} and {{}}'; + const input = '{} and {{}}' + const expectedOutput = '{} and {{}}' - expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(expectedOutput); - }); + expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(expectedOutput) + }) test('Should not modify strings without {} or {{}}', () => { - const input = 'Hello World!'; - expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(input); - }); + const input = 'Hello World!' + expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(input) + }) test('Should handle strings with multiple {} and {{}}', () => { - const input = '{Hello} {World} and {{Universe}}'; - const expectedOutput = '{Hello} {World} and {{Universe}}'; + const input = '{Hello} {World} and {{Universe}}' + const expectedOutput = '{Hello} {World} and {{Universe}}' + + expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(expectedOutput) + }) +}) + +describe('translateTexts', () => { + test('should protect placeholders by default and allow keep-tag postprocessing', async () => { + const mockTranslator = { + translateText: vi.fn().mockResolvedValue([{ text: 'Hola {{name}}' }]), + } as any + + const result = await translateTexts(['Hello {{name}}'], 'es', mockTranslator, { + postprocess: removeKeepTagsFromString, + }) + + expect(mockTranslator.translateText).toHaveBeenCalledWith( + ['Hello {{name}}'], + null, + 'es', + expect.objectContaining({ + tagHandling: 'xml', + ignoreTags: ['keep'], + preserveFormatting: true, + }), + ) + expect(result).toEqual(['Hola {{name}}']) + }) + + test('should allow callers to override the default preprocess', async () => { + const mockTranslator = { + translateText: vi.fn().mockResolvedValue([{ text: 'custom value' }]), + } as any + + await translateTexts(['Hello {{name}}'], 'es', mockTranslator, { + preprocess: (value) => `custom:${value}`, + }) + + expect(mockTranslator.translateText).toHaveBeenCalledWith( + ['custom:Hello {{name}}'], + null, + 'es', + expect.objectContaining({ + tagHandling: 'xml', + ignoreTags: ['keep'], + preserveFormatting: true, + }), + ) + }) +}) + +describe('collectAllStringsFromJson', () => { + test('should handle simple flat object', () => { + const input = { + name: 'John', + greeting: 'Hello', + age: 30, // Should be ignored + active: true, // Should be ignored + } + + const result = collectAllStringsFromJson(input) - expect(replaceParameterStringsInJSONValueWithKeepTags(input)).toEqual(expectedOutput); - }); -}); + expect(result.keys).toEqual(['name', 'greeting']) + expect(result.values).toEqual(['John', 'Hello']) + expect(result.keys.length).toBe(result.values.length) + }) -describe('applyRecursive', () => { - test('should append "translated" to all string values', async () => { + test('should handle the user example correctly', () => { const input = { - a: { - b: "test", - c: "test2" + foo: 'some text', + bar: { baz: 'some other text' }, + } + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toEqual(['foo', 'bar.baz']) + expect(result.values).toEqual(['some text', 'some other text']) + }) + + test('should handle deeply nested objects', () => { + const input = { + level1: { + level2: { + level3: { + level4: { + message: 'deep message', + }, + another: 'another message', + }, + surface: 'surface message', }, - d: "test3" - }; - const expected = { - a: { - b: "testtranslated", - c: "test2translated" + }, + root: 'root message', + } + + const result = collectAllStringsFromJson(input) + + // The iterative approach may produce keys in a different order than the recursive approach + // So we check that all expected keys and values are present, regardless of order + expect(result.keys).toHaveLength(4) + expect(result.keys).toContain('level1.level2.level3.level4.message') + expect(result.keys).toContain('level1.level2.level3.another') + expect(result.keys).toContain('level1.level2.surface') + expect(result.keys).toContain('root') + + expect(result.values).toHaveLength(4) + expect(result.values).toContain('deep message') + expect(result.values).toContain('another message') + expect(result.values).toContain('surface message') + expect(result.values).toContain('root message') + }) + + test('should ignore non-string values', () => { + const input = { + stringValue: 'keep this', + numberValue: 42, + booleanValue: true, + nullValue: null, + undefinedValue: undefined, + arrayValue: ['ignore', 'this', 'array'], + dateValue: new Date(), + nested: { + anotherString: 'keep this too', + anotherNumber: 3.14, + anotherArray: [1, 2, 3], + }, + } + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toEqual(['stringValue', 'nested.anotherString']) + expect(result.values).toEqual(['keep this', 'keep this too']) + }) + + test('should handle empty objects', () => { + const input = {} + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toEqual([]) + expect(result.values).toEqual([]) + }) + + test('should handle objects with only nested empty objects', () => { + const input = { + level1: { + level2: { + level3: {}, }, - d: "test3translated" - }; - const operation = async (value: string, path: string[]) => { - let obj = input; - for (let key of path) { - if(typeof obj[key] === "string") { - obj[key] += "translated"; - } - obj = obj[key]; + }, + } + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toEqual([]) + expect(result.values).toEqual([]) + }) + + test('should handle objects with mixed empty and non-empty nested objects', () => { + const input = { + empty: {}, + notEmpty: { + message: 'found it', + }, + anotherEmpty: { + nested: {}, + }, + } + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toEqual(['notEmpty.message']) + expect(result.values).toEqual(['found it']) + }) + + test('should handle strings with special characters and whitespace', () => { + const input = { + special: 'Special chars: @#$%^&*()', + whitespace: ' spaces around ', + multiline: 'line1\nline2\nline3', + unicode: 'Unicode: 你好 🌍', + empty: '', + nested: { + quotes: 'She said "Hello"', + singleQuotes: "It's working", + }, + } + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toEqual([ + 'special', + 'whitespace', + 'multiline', + 'unicode', + 'empty', + 'nested.quotes', + 'nested.singleQuotes', + ]) + expect(result.values).toEqual([ + 'Special chars: @#$%^&*()', + ' spaces around ', + 'line1\nline2\nline3', + 'Unicode: 你好 🌍', + '', + 'She said "Hello"', + "It's working", + ]) + }) + + test('should handle arrays correctly (ignore them)', () => { + const input = { + stringValue: 'keep this', + arrayOfStrings: ['ignore', 'all', 'of', 'these'], + nested: { + anotherString: 'keep this too', + arrayOfObjects: [{ ignore: 'this object' }, { also: 'ignore this' }], + mixedArray: [1, 'ignore', true, { nested: 'ignore' }], + }, + } + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toEqual(['stringValue', 'nested.anotherString']) + expect(result.values).toEqual(['keep this', 'keep this too']) + }) + + test('should maintain correct index correspondence between keys and values', () => { + const input = { + first: 'value1', + second: { + nested: 'value2', + deeper: { + deep: 'value3', + }, + }, + third: 'value4', + } + + const result = collectAllStringsFromJson(input) + + // Verify each key corresponds to its value at the same index + for (let i = 0; i < result.keys.length; i++) { + const keyPath = result.keys[i] + const expectedValue = result.values[i] + + // Navigate to the value using the key path + let actualValue = input + const pathParts = keyPath.split('.') + for (const part of pathParts) { + actualValue = actualValue[part] + } + + expect(actualValue).toBe(expectedValue) + } + }) + + test('should handle complex real-world JSON structure', () => { + // Load the fixture file + const fixturePath = path.join(__dirname, 'fixtures', 'nestedJSON1.json') + const fixtureContent = fs.readFileSync(fixturePath, 'utf8') + const input = JSON.parse(fixtureContent) + + const result = collectAllStringsFromJson(input) + + // Should find many strings (exact count may vary with fixture changes) + expect(result.keys.length).toBeGreaterThan(50) + expect(result.values.length).toBe(result.keys.length) + + // Verify some expected keys exist + expect(result.keys).toContain('pages.login.title') + expect(result.keys).toContain('pages.login.fields.email') + expect(result.keys).toContain('buttons.create') + expect(result.keys).toContain('notifications.error') + + // Verify some expected values exist + expect(result.values).toContain('Sign in to your account') + expect(result.values).toContain('Email') + expect(result.values).toContain('Create') + expect(result.values).toContain('Error (status code: {{statusCode}})') + + // Verify all collected strings are actually strings + result.values.forEach((value) => { + expect(typeof value).toBe('string') + }) + + // Verify all keys follow dot notation pattern (including escaped dots) + result.keys.forEach((key) => { + expect(key).toMatch(/^[a-zA-Z_][a-zA-Z0-9_\\.-]*(\.[a-zA-Z_][a-zA-Z0-9_\\.-]*)*$/) + }) + }) + + test('should handle objects with numeric and special character keys', () => { + const input = { + 'normal-key': 'normal value', + '123': 'numeric key', + 'key with spaces': 'spaced key', + nested: { + '456': 'nested numeric', + 'special@key': 'special char key', + }, + } + + const result = collectAllStringsFromJson(input) + + expect(result.keys).toContain('normal-key') + expect(result.keys).toContain('123') + expect(result.keys).toContain('key with spaces') + expect(result.keys).toContain('nested.456') + expect(result.keys).toContain('nested.special@key') + + expect(result.values).toContain('normal value') + expect(result.values).toContain('numeric key') + expect(result.values).toContain('spaced key') + expect(result.values).toContain('nested numeric') + expect(result.values).toContain('special char key') + }) + + test('should handle very deeply nested structure (stress test)', () => { + // Create a deeply nested structure programmatically + let deeplyNested: Record = {} + let current: Record = deeplyNested + const depth = 20 + + for (let i = 0; i < depth; i++) { + current[`level${i}`] = {} + current = current[`level${i}`] + } + current.finalMessage = 'found at the end' + + const result = collectAllStringsFromJson(deeplyNested) + + const expectedKey = Array.from({ length: depth }, (_, i) => `level${i}`).join('.') + '.finalMessage' + expect(result.keys).toContain(expectedKey) + expect(result.values).toContain('found at the end') + }) + + test('should handle extremely deep nesting without stack overflow (iterative approach)', () => { + // Create an extremely deeply nested structure to test iterative approach + let extremelyDeep: Record = {} + let current: Record = extremelyDeep + const depth = 1000 // Much deeper than would be safe with recursion + + for (let i = 0; i < depth; i++) { + current[`deep${i}`] = {} + current = current[`deep${i}`] + } + current.treasureAtTheEnd = 'success!' + current.anotherString = 'also found' + + const result = collectAllStringsFromJson(extremelyDeep) + + const expectedKey1 = Array.from({ length: depth }, (_, i) => `deep${i}`).join('.') + '.treasureAtTheEnd' + const expectedKey2 = Array.from({ length: depth }, (_, i) => `deep${i}`).join('.') + '.anotherString' + + expect(result.keys).toContain(expectedKey1) + expect(result.keys).toContain(expectedKey2) + expect(result.values).toContain('success!') + expect(result.values).toContain('also found') + + // Verify we found exactly 2 strings + expect(result.keys.length).toBe(2) + expect(result.values.length).toBe(2) + }) + + test('should handle keys that contain dots', () => { + const input = { + 'user.name': 'John Doe', + 'api.key': 'secret123', + 'config.env.prod': 'production value', + user: { + age: '30', + 'profile.picture': 'avatar.jpg', + }, + nested: { + 'dot.key': 'nested dot value', + regular: 'regular nested value', + }, + } + + const result = collectAllStringsFromJson(input) + + // Keys with literal dots should be escaped + expect(result.keys).toContain('user\\.name') + expect(result.keys).toContain('api\\.key') + expect(result.keys).toContain('config\\.env\\.prod') + expect(result.keys).toContain('user.profile\\.picture') + expect(result.keys).toContain('nested.dot\\.key') + + // Regular nested keys should use normal dot notation + expect(result.keys).toContain('user.age') + expect(result.keys).toContain('nested.regular') + + // Verify corresponding values + expect(result.values).toContain('John Doe') + expect(result.values).toContain('secret123') + expect(result.values).toContain('production value') + expect(result.values).toContain('avatar.jpg') + expect(result.values).toContain('nested dot value') + expect(result.values).toContain('30') + expect(result.values).toContain('regular nested value') + + // Verify index correspondence for keys with escaped dots + const userNameIndex = result.keys.indexOf('user\\.name') + expect(result.values[userNameIndex]).toBe('John Doe') + + const apiKeyIndex = result.keys.indexOf('api\\.key') + expect(result.values[apiKeyIndex]).toBe('secret123') + + const nestedDotKeyIndex = result.keys.indexOf('nested.dot\\.key') + expect(result.values[nestedDotKeyIndex]).toBe('nested dot value') + }) + + test('should handle size-based batching for translateStrings', () => { + // Mock translator for testing + const mockTranslator = { + translateText: vi.fn().mockResolvedValue([{ text: 'translated' }]), + } as any + + // Create strings of varying sizes to test batching + const shortStrings = Array(10).fill('short') + const mediumStrings = Array(5).fill('medium length string') + const longString = 'x'.repeat(100000) // ~100 KiB string + const veryLongString = 'x'.repeat(120000) // ~120 KiB string (just under the limit) + + const allStrings = [...shortStrings, ...mediumStrings, longString, veryLongString] + + const result = translateStrings(allStrings, 'es', mockTranslator) + + // Should create multiple batches due to size constraints + expect(result.length).toBeGreaterThan(1) + + // Verify that the very long string gets its own batch + expect(mockTranslator.translateText).toHaveBeenCalled() + + // Check that no single batch exceeds the text size limit (128 KiB - overhead) + const maxTextSizeBytes = 128 * 1024 - 2048 // 128 KiB - 2KB overhead + const calls = mockTranslator.translateText.mock.calls + calls.forEach((call: any) => { + const batch = call[0] // First argument is the batch array + const batchSizeBytes = new TextEncoder().encode(batch.join('')).length + expect(batchSizeBytes).toBeLessThanOrEqual(maxTextSizeBytes) + }) + + // Verify that the very long string is in its own batch + const veryLongStringBatch = calls.find((call: any) => call[0].some((str: string) => str.length === 120000)) + expect(veryLongStringBatch).toBeDefined() + expect(veryLongStringBatch[0]).toHaveLength(1) // Should be alone in its batch + }) + + test('should handle rate limiting with exponential backoff', async () => { + let callCount = 0 + const mockTranslator = { + translateText: vi.fn().mockImplementation(async () => { + callCount++ + if (callCount === 1) { + const error = new Error('Too many requests, DeepL servers are currently experiencing high load') as any + error.status = 429 + throw error + } + return [{ text: 'translated' }] + }), + } as any + + const result = translateStrings(['test string'], 'es', mockTranslator) + + expect(result).toHaveLength(1) + expect(Array.isArray(result)).toBe(true) + + // Track timing to verify exponential backoff + const startTime = Date.now() + const translationResult = await result[0] + const totalTime = Date.now() - startTime + + expect(translationResult).toEqual({ lang: 'es', text: ['translated'] }) + expect(mockTranslator.translateText).toHaveBeenCalledTimes(2) + + // Verify that the delay was approximately 1000ms (base delay) + // Allow some tolerance for test execution overhead + expect(totalTime).toBeGreaterThanOrEqual(900) // At least 900ms + expect(totalTime).toBeLessThanOrEqual(1500) // No more than 1500ms + }, 10000) // Increase timeout to 10 seconds + + test('should handle non-rate-limit errors immediately', async () => { + const mockTranslator = { + translateText: vi.fn().mockRejectedValue(new Error('Authentication failed')), + } as any + + const result = translateStrings(['test string'], 'es', mockTranslator) + + expect(result).toHaveLength(1) + + await expect(result[0]).rejects.toThrow('Authentication failed') + + expect(mockTranslator.translateText).toHaveBeenCalledTimes(1) + }) + + test('should handle multiple rate limit retries with increasing delays', async () => { + let callCount = 0 + const mockTranslator = { + translateText: vi.fn().mockImplementation(async () => { + callCount++ + if (callCount <= 3) { + // First 3 calls fail with rate limit error + const error = new Error('Too many requests, DeepL servers are currently experiencing high load') as any + error.status = 429 + throw error } - }; + // 4th call succeeds + return [{ text: 'translated' }] + }), + } as any + + const result = translateStrings(['test string'], 'es', mockTranslator) + + expect(result).toHaveLength(1) + + // Track timing to verify exponential backoff progression + const startTime = Date.now() + const translationResult = await result[0] + const totalTime = Date.now() - startTime + + expect(translationResult).toEqual({ lang: 'es', text: ['translated'] }) + expect(mockTranslator.translateText).toHaveBeenCalledTimes(4) + + // Verify that the total delay was approximately: + // 1st retry: 1000ms (base delay) + // 2nd retry: 2000ms (base * 2^1) + // 3rd retry: 4000ms (base * 2^2) + // Total: ~7000ms minimum + expect(totalTime).toBeGreaterThanOrEqual(6500) // At least 6.5 seconds + expect(totalTime).toBeLessThanOrEqual(9000) // No more than 9 seconds + }, 10000) // Increase timeout to 10 seconds +}) + +describe('createTranslatorOptions', () => { + test('should return undefined when timeout is not provided', () => { + const result = createTranslatorOptions(undefined) + + expect(result).toBeUndefined() + }) + + test('should return minTimeout when valid timeout is provided', () => { + const result = createTranslatorOptions('10000') + + expect(result).toEqual({ minTimeout: 10000 }) + }) + + test('should return undefined when timeout is empty string', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = createTranslatorOptions('') + + expect(result).toBeUndefined() + expect(consoleWarnSpy).not.toHaveBeenCalled() + + consoleWarnSpy.mockRestore() + }) + + test('should return undefined and warn when timeout is invalid (non-numeric)', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = createTranslatorOptions('invalid') + + expect(result).toBeUndefined() + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Invalid timeout value: invalid. Expected a positive number in milliseconds. Ignoring timeout parameter.' + ) + + consoleWarnSpy.mockRestore() + }) + + test('should return undefined and warn when timeout is zero', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = createTranslatorOptions('0') + + expect(result).toBeUndefined() + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Invalid timeout value: 0. Expected a positive number in milliseconds. Ignoring timeout parameter.' + ) + + consoleWarnSpy.mockRestore() + }) + + test('should return undefined and warn when timeout is negative', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = createTranslatorOptions('-5000') + + expect(result).toBeUndefined() + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Invalid timeout value: -5000. Expected a positive number in milliseconds. Ignoring timeout parameter.' + ) + + consoleWarnSpy.mockRestore() + }) + + test('should return minTimeout when timeout is a valid positive number', () => { + const result = createTranslatorOptions('5000') + + expect(result).toEqual({ minTimeout: 5000 }) + }) + + test('should return minTimeout when timeout is a large valid number', () => { + const result = createTranslatorOptions('30000') + + expect(result).toEqual({ minTimeout: 30000 }) + }) + + test('should handle timeout with decimal values by parsing as integer', () => { + const result = createTranslatorOptions('5000.99') + + expect(result).toEqual({ minTimeout: 5000 }) + }) +}) + +describe('buildOutputJson', () => { + test('should handle string with tags', () => { + const translatedTexts = ['Hello World'] + const jsonKeys = ['greeting'] + + const result = buildOutputJson(translatedTexts, jsonKeys) + expect(result).toEqual({ + greeting: 'Hello World', + }) + }) + + test('should handle simple flat object reconstruction', () => { + const translatedTexts = ['Hello', 'World'] + const jsonKeys = ['greeting', 'message'] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({ + greeting: 'Hello', + message: 'World', + }) + }) + + test('should handle nested object reconstruction', () => { + const translatedTexts = ['Hello', 'World'] + const jsonKeys = ['greeting', 'user.name'] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({ + greeting: 'Hello', + user: { + name: 'World', + }, + }) + }) + + test('should handle deeply nested object reconstruction', () => { + const translatedTexts = ['deep message', 'another message', 'surface message', 'root message'] + const jsonKeys = [ + 'level1.level2.level3.level4.message', + 'level1.level2.level3.another', + 'level1.level2.surface', + 'root', + ] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({ + level1: { + level2: { + level3: { + level4: { + message: 'deep message', + }, + another: 'another message', + }, + surface: 'surface message', + }, + }, + root: 'root message', + }) + }) + + test('should handle literal dots in keys (escaped dots)', () => { + const translatedTexts = [ + 'John Doe', + 'secret123', + 'production value', + '30', + 'avatar.jpg', + 'nested dot value', + 'regular nested value', + ] + const jsonKeys = [ + 'user\\.name', + 'api\\.key', + 'config\\.env\\.prod', + 'user.age', + 'user.profile\\.picture', + 'nested.dot\\.key', + 'nested.regular', + ] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({ + 'user.name': 'John Doe', + 'api.key': 'secret123', + 'config.env.prod': 'production value', + user: { + age: '30', + 'profile.picture': 'avatar.jpg', + }, + nested: { + 'dot.key': 'nested dot value', + regular: 'regular nested value', + }, + }) + }) + + test('should handle mixed literal dots and nested objects', () => { + const translatedTexts = ['Hello', 'World', 'Universe'] + const jsonKeys = ['greeting', 'user\\.profile.name', 'user\\.profile.description'] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({ + greeting: 'Hello', + 'user.profile': { + name: 'World', + description: 'Universe', + }, + }) + }) + + test('should handle empty arrays', () => { + const translatedTexts: string[] = [] + const jsonKeys: string[] = [] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({}) + }) + + test('should handle single key-value pair', () => { + const translatedTexts = ['Single value'] + const jsonKeys = ['single'] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({ + single: 'Single value', + }) + }) + + test('should handle complex real-world example', () => { + const translatedTexts = ['Sign in to your account', 'Email', 'Create', 'Error (status code: {{statusCode}})'] + const jsonKeys = ['pages.login.title', 'pages.login.fields.email', 'buttons.create', 'notifications.error'] + + const result = buildOutputJson(translatedTexts, jsonKeys) + + expect(result).toEqual({ + pages: { + login: { + title: 'Sign in to your account', + fields: { + email: 'Email', + }, + }, + }, + buttons: { + create: 'Create', + }, + notifications: { + error: 'Error (status code: {{statusCode}})', + }, + }) + }) +}) + +describe('groupItemsByLang', () => { + test('should group items by language code', () => { + const input: TranslatedTextResult[] = [ + { lang: 'es', text: ['hola', 'mundo'] }, + { lang: 'fr', text: ['bonjour'] }, + { lang: 'es', text: ['adios'] }, + { lang: 'de', text: ['hallo'] }, + { lang: 'fr', text: ['au revoir'] }, + ] + + const result = groupItemsByLang(input) + + expect(result).toEqual({ + es: ['hola', 'mundo', 'adios'], + fr: ['bonjour', 'au revoir'], + de: ['hallo'], + }) + }) + + test('should handle single language with multiple items', () => { + const input: TranslatedTextResult[] = [ + { lang: 'es', text: ['hola'] }, + { lang: 'es', text: ['mundo'] }, + { lang: 'es', text: ['adios'] }, + ] + + const result = groupItemsByLang(input) + + expect(result).toEqual({ + es: ['hola', 'mundo', 'adios'], + }) + }) + + test('should handle single item per language', () => { + const input: TranslatedTextResult[] = [ + { lang: 'es', text: ['hola'] }, + { lang: 'fr', text: ['bonjour'] }, + { lang: 'de', text: ['hallo'] }, + ] + + const result = groupItemsByLang(input) + + expect(result).toEqual({ + es: ['hola'], + fr: ['bonjour'], + de: ['hallo'], + }) + }) + + test('should handle empty array', () => { + const input: TranslatedTextResult[] = [] + + const result = groupItemsByLang(input) + + expect(result).toEqual({}) + }) + + test('should handle single item', () => { + const input: TranslatedTextResult[] = [{ lang: 'es', text: ['hola'] }] + + const result = groupItemsByLang(input) - await applyRecursive(input, [], operation, []); + expect(result).toEqual({ + es: ['hola'], + }) + }) - expect(input).toEqual(expected); -}); + test('should preserve order of text arrays within each language', () => { + const input: TranslatedTextResult[] = [ + { lang: 'es', text: ['first', 'second'] }, + { lang: 'es', text: ['third'] }, + { lang: 'es', text: ['fourth', 'fifth'] }, + ] + const result = groupItemsByLang(input) - test('should pass correct arguments to operation', async () => { - const input = { a: "test" }; - const operationArgs = [1, 2, 3]; - const operation = vi.fn(); + expect(result.es).toEqual(['first', 'second', 'third', 'fourth', 'fifth']) + }) - await applyRecursive(input, [], operation, operationArgs); + test('should handle mixed language codes including variants', () => { + const input: TranslatedTextResult[] = [ + { lang: 'en-US', text: ['hello'] }, + { lang: 'en-GB', text: ['hello'] }, + { lang: 'pt-BR', text: ['olá'] }, + { lang: 'pt-PT', text: ['olá'] }, + { lang: 'zh', text: ['你好'] }, + ] - expect(operation).toHaveBeenCalledWith("test", ["a"], ...operationArgs); - }); + const result = groupItemsByLang(input) - // Here you can add more test cases according to your requirements -}); + expect(result).toEqual({ + 'en-US': ['hello'], + 'en-GB': ['hello'], + 'pt-BR': ['olá'], + 'pt-PT': ['olá'], + zh: ['你好'], + }) + }) +})