This workflow follows the Google Sheets → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "rbB9xa5TQZeGFzXh",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Fill iOS localization gaps from `.strings` \u2192 Google Sheets and PR with placeholders",
"tags": [],
"nodes": [
{
"id": "7e8fd5f1-3a2c-48e7-9e63-48c9864dfa7a",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"position": [
-480,
-180
],
"parameters": {
"path": "new-pathss",
"options": {},
"httpMethod": "POST"
},
"typeVersion": 2
},
{
"id": "ea60b48c-af71-4440-9aaa-2d25dd820f4f",
"name": "Config",
"type": "n8n-nodes-base.set",
"position": [
-240,
-220
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "10855098-beb5-49c8-bb4c-e44640ee33b1",
"name": "GITHUB_OWNER",
"type": "string",
"value": "github_user_name"
},
{
"id": "d0637d54-9b4f-4f83-b09c-9a55f3bc243e",
"name": "GITHUB_REPO",
"type": "string",
"value": "n8n-iOS-Github-repo"
},
{
"id": "df285f8f-dde8-42ed-840b-7eb9219aae16",
"name": "BASE_BRANCH",
"type": "string",
"value": "main"
},
{
"id": "2cedf437-f4a5-4be8-830d-cd4d747dbb91",
"name": "SOURCE_LANG",
"type": "string",
"value": "en"
},
{
"id": "679c6a49-cc7f-45c0-b87f-a38bd36f6c85",
"name": "TARGET_LANG",
"type": "string",
"value": "fr"
},
{
"id": "114d96e4-0ef7-4bb6-b52c-a55575730141",
"name": "PLACEHOLDER_VALUE",
"type": "string",
"value": "__TODO_TRANSLATE__"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "8f5566bc-59de-44fe-b370-a7039de6f926",
"name": "Process File Tree",
"type": "n8n-nodes-base.code",
"position": [
220,
-180
],
"parameters": {
"jsCode": "// Extract .strings files from GitHub tree\nconst tree = $input.first().json.tree;\nconst sourceFiles = [];\nconst targetFiles = [];\nconst targetLang = $('Config').first().json.TARGET_LANG;\n\n// Find Base.lproj and en.lproj files (source)\ntree.forEach(file => {\n if ((file.path.includes('Base.lproj/') || file.path.includes('en.lproj/')) && file.path.endsWith('.strings')) {\n // Extract directory correctly - remove the language folder and filename\n let directory = file.path.replace(/\\/(Base|en)\\.lproj\\/[^\\/]+$/, '');\n if (directory === file.path) {\n // If no replacement happened, it's in root\n directory = '';\n }\n \n sourceFiles.push({\n path: file.path,\n sha: file.sha,\n basename: file.path.split('/').pop(),\n directory: directory\n });\n }\n});\n\n// Find target language files (fr.lproj)\ntree.forEach(file => {\n if (file.path.includes(`${targetLang}.lproj/`) && file.path.endsWith('.strings')) {\n let directory = file.path.replace(new RegExp(`\\\\/${targetLang}\\\\.lproj\\\\/[^\\\\/]+$`), '');\n if (directory === file.path) {\n directory = '';\n }\n \n targetFiles.push({\n path: file.path,\n sha: file.sha,\n basename: file.path.split('/').pop(),\n directory: directory\n });\n }\n});\n\n// Create pairs of source and target files\nconst filePairs = [];\nsourceFiles.forEach(sourceFile => {\n const targetFile = targetFiles.find(tf => \n tf.basename === sourceFile.basename && tf.directory === sourceFile.directory\n );\n \n // Construct correct target path\n let targetPath;\n if (sourceFile.directory === '') {\n targetPath = `${targetLang}.lproj/${sourceFile.basename}`;\n } else {\n targetPath = `${sourceFile.directory}/${targetLang}.lproj/${sourceFile.basename}`;\n }\n \n filePairs.push({\n source: sourceFile,\n target: targetFile || null,\n targetPath: targetPath\n });\n});\n\nconsole.log(`Found ${sourceFiles.length} source files, ${targetFiles.length} target files`);\nconsole.log('File pairs:', filePairs);\n\nreturn filePairs.map(pair => ({ json: pair }));"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "ca005412-11bf-4630-bc19-d29b3916074d",
"name": "Get Source File",
"type": "n8n-nodes-base.httpRequest",
"position": [
480,
-180
],
"parameters": {
"url": "{{\"https://api.github.com/repos/github-user-name/n8n-iOS-Github-repo/contents/\" + $json.source.path + \"?ref=main\"}}",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "githubApi"
},
"credentials": {
"githubApi": {
"name": "<your credential>"
}
},
"typeVersion": 4.2
},
{
"id": "3e658165-e4ec-466d-9639-fabe715db1ff",
"name": "Find Missing Keys",
"type": "n8n-nodes-base.code",
"position": [
1780,
-200
],
"parameters": {
"jsCode": "function parseStringsFile(content) {\n const keys = {};\n if (!content) return keys;\n \n try {\n const decoded = Buffer.from(content, 'base64').toString('utf-8');\n const lines = decoded.split('\\n');\n \n for (const line of lines) {\n const match = line.match(/^\\s*\"([^\"]+)\"\\s*=\\s*\"([^\"]*)\";?/);\n if (match) {\n keys[match[1]] = match[2];\n }\n }\n } catch (error) {\n console.log('Error parsing content:', error);\n }\n \n return keys;\n}\n\nconst results = [];\nconst placeholder = '__TODO_TRANSLATE__';\n\nconsole.log('=== DEBUGGING MERGED DATA ===');\nconsole.log('Total items received:', items.length);\n\n// Log all items to understand the structure\nitems.forEach((item, index) => {\n console.log(`Item ${index}:`);\n console.log(' - name:', item.json.name);\n console.log(' - path:', item.json.path);\n console.log(' - html_url:', item.json.html_url);\n console.log('---');\n});\n\n// Find source file (Base.lproj or en.lproj)\nconst sourceFile = items.find(item => \n item.json.path && (\n item.json.path.includes('Base.lproj/Localizable.strings') ||\n item.json.path.includes('en.lproj/Localizable.strings')\n )\n);\n\n// Find target file (fr.lproj or your target locale)\nconst targetFile = items.find(item => \n item.json.path && item.json.path.includes('fr.lproj/Localizable.strings')\n);\n\nconsole.log('Source file found:', !!sourceFile);\nconsole.log('Target file found:', !!targetFile);\n\nif (sourceFile && targetFile) {\n console.log('Source path:', sourceFile.json.path);\n console.log('Target path:', targetFile.json.path);\n \n const sourceKeys = parseStringsFile(sourceFile.json.content);\n const targetKeys = parseStringsFile(targetFile.json.content);\n \n console.log('Source keys:', Object.keys(sourceKeys).length);\n console.log('Target keys:', Object.keys(targetKeys).length);\n \n // Find missing keys\n Object.keys(sourceKeys).forEach(key => {\n if (!targetKeys.hasOwnProperty(key)) {\n results.push({\n json: {\n File: sourceFile.json.name,\n \"Source Path\": sourceFile.json.path,\n \"Target Path\": targetFile.json.path,\n Key: key,\n \"Source Value\": sourceKeys[key],\n Placeholder: placeholder\n }\n });\n }\n });\n \n console.log('Missing keys found:', results.length);\n} else {\n console.log('ERROR: Could not find both files!');\n}\n\nreturn results.length > 0 ? results : [{\n json: {\n File: \"DEBUG\",\n \"Source Path\": sourceFile ? sourceFile.json.path : \"Not found\",\n \"Target Path\": targetFile ? targetFile.json.path : \"Not found\",\n Key: \"debug\",\n \"Source Value\": `Total items: ${items.length}`,\n Placeholder: placeholder\n }\n}];"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "936bf8f5-17af-4ab6-86bc-7d869008464e",
"name": "Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"position": [
2000,
-200
],
"parameters": {
"columns": {
"value": {
"Key": "={{ $json[\"Key\"] }}",
"File": "={{ $json[\"File\"] }}",
"Placeholder": "={{ $json[\"Placeholder\"] }}",
"Source Path": "={{ $json[\"Source Path\"] }}",
"Target Path": "={{ $json[\"Target Path\"] }}",
"Source Value": "={{ $json[\"Source Value\"] }}"
},
"schema": [
{
"id": "File",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "File",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Source Path",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Source Path",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Target Path",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Target Path",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Key",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Key",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Source Value",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Source Value",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Placeholder",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Placeholder",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "name",
"value": "localize"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "1n_AIqOd10Q0ErQZSO4q4LBMekwgsR4cP7EW2q9nEzdk"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.6
},
{
"id": "545dcc56-793d-42b1-8321-1eabefcfe720",
"name": "Get GitHub Tree",
"type": "n8n-nodes-base.httpRequest",
"position": [
-20,
-180
],
"parameters": {
"url": "={{\"https://api.github.com/repos/\" + $json.GITHUB_OWNER + \"/\" + $json.GITHUB_REPO + \"/git/trees/\" + $json.BASE_BRANCH + \"?recursive=1\"}}",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "githubApi"
},
"credentials": {
"githubApi": {
"name": "<your credential>"
}
},
"typeVersion": 4.2
},
{
"id": "08e3392c-a588-42c2-9c35-6e067d2cf4bb",
"name": "Merge",
"type": "n8n-nodes-base.merge",
"position": [
1540,
-180
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineByPosition"
},
"typeVersion": 3.2,
"alwaysOutputData": false
},
{
"id": "1a0f5915-36a8-40a9-bcda-9c57f4bb1a94",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"position": [
760,
-180
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "10c2ff16-6b8e-4c52-ad0f-0245061bd808",
"name": "en.lproj",
"type": "string",
"value": "https://api.github.com/repos/github-user-name/n8n-iOS-Github-repo/contents/en.lproj/Localizable.strings"
},
{
"id": "8e14c09b-d149-4a4e-88ba-c700ad6f6d52",
"name": "fr.lproj",
"type": "string",
"value": "https://api.github.com/repos/github-user-name/n8n-iOS-Github-repo/contents/fr.lproj/Localizable.strings"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "0a4cb729-fd21-442a-b59d-3c313e0694ef",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"position": [
1300,
-180
],
"parameters": {
"url": "={{ $json[\"url\"] }}",
"options": {}
},
"typeVersion": 4.2
},
{
"id": "e3729e7f-579f-4c4b-9789-f6a00c7565ed",
"name": "Code",
"type": "n8n-nodes-base.code",
"position": [
1080,
-180
],
"parameters": {
"jsCode": "return [\n {\n json: {\n lang: \"en\",\n url: $json[\"en\"][\"lproj\"]\n }\n },\n {\n json: {\n lang: \"fr\",\n url: $json[\"fr\"][\"lproj\"]\n }\n }\n];\n"
},
"typeVersion": 2
}
],
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "b0f3ff39-117a-4ee1-884f-419d55594f24",
"connections": {
"Code": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Find Missing Keys",
"type": "main",
"index": 0
}
]
]
},
"Config": {
"main": [
[
{
"node": "Get GitHub Tree",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
},
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Get GitHub Tree": {
"main": [
[
{
"node": "Process File Tree",
"type": "main",
"index": 0
}
]
]
},
"Get Source File": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Find Missing Keys": {
"main": [
[
{
"node": "Google Sheets",
"type": "main",
"index": 0
}
]
]
},
"Process File Tree": {
"main": [
[
{
"node": "Get Source File",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
githubApigoogleSheetsOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
This workflow streamlines iOS app localisation by identifying missing translation keys in .strings files, exporting them to Google Sheets for easy team collaboration, and generating a pull request with placeholders to fill the gaps efficiently. It suits developers and localisation managers handling multilingual iOS projects who need to automate repetitive tasks without manual file comparisons. The key step involves processing the file tree via code nodes to detect discrepancies, then leveraging Google Sheets for input before creating a GitHub PR through httpRequest integrations.
Use this workflow when scaling localisation for iOS apps with frequent updates, especially if your team prefers spreadsheet-based editing over direct code changes. Avoid it for non-iOS projects or when translations require complex formatting beyond simple placeholders. Common variations include adapting the code nodes to support Android XML files or integrating with other version control systems like Bitbucket.
About this workflow
Fill iOS localization gaps from `.strings` → Google Sheets and PR with placeholders. Uses httpRequest, googleSheets. Webhook trigger; 11 nodes.
Source: https://github.com/papikimono/n8n-Syncing-iOS-localization-gaps-with-Google-Sheets-and-GitHub-PR-placeholders/blob/main/main.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Chatgpt Automatic Code Review In Gitlab Mr. Uses stickyNote, splitOut, lmChatOpenAi, httpRequest. Webhook trigger; 14 nodes.
🔥 n8n Members Sale – n8n Community Members Get ideoGener8r for Just $10! (Reg. $15) Use Coupon Code: (Valid for n8n community members)
Code Github. Uses manualTrigger, stickyNote, n8n, httpRequest. Event-driven trigger; 25 nodes.
Display Project Data On A Smashing Dashboard. Uses httpRequest, github. Scheduled trigger; 24 nodes.
Code Github. Uses manualTrigger, stickyNote, httpRequest, noOp. Event-driven trigger; 24 nodes.