UNPKG

strapi-to-lokalise-plugin

Version:

Preview and sync Lokalise translations from Strapi admin

873 lines (782 loc) 35.4 kB
'use strict'; /** * STRAPI + LOKALISE PLUGIN SETTINGS GUIDELINES * -------------------------------------------- * * GOAL: * ----- * Provide a secure, persistent way for any Strapi admin to configure * Lokalise integration (API token, project ID, optional base URL) * without hardcoding sensitive values in code or environment variables. * * REQUIREMENTS: * ------------- * 1. Plugin settings (Lokalise API token & project ID) must be stored * server-side using Strapi's core_store (`type: 'plugin', name: 'lokalise-sync'`). * * 2. Tokens must never be exposed to frontend: * - Only send masked token or a boolean `tokenConfigured: true/false`. * - Do NOT return raw tokens via GET. * * 3. Validation rules: * - Token must start with 'pk_' or 'tok_' * - Token must have no spaces, min length ~20 * - Project ID should accept numeric IDs or UUID-like strings * - Base URL can be optional; whitelist Lokalise API URLs * * 4. Optional Encryption (recommended for production): * - AES-256-GCM using an env variable secret (LOKALISE_SYNC_SECRET_KEY) * - Encrypt before saving; decrypt transparently when using * - If no secret provided, fallback to plain text for usability * * 5. Admin API endpoints (server-only): * - GET /lokalise-sync/settings * - Returns masked token and project ID * - Does NOT return raw API token * - POST /lokalise-sync/settings * - Validates token and project ID * - Optionally encrypts token before saving * - Stores settings in Strapi core_store * - POST /lokalise-sync/settings/test * - Tests token/project connectivity with Lokalise * - Returns success or descriptive error * * 6. Frontend (Admin Panel): * - Settings form with fields: * - Project ID (visible) * - API token (masked) * - Base URL (optional) * - Save button writes to secured backend route * - Test connection button verifies credentials * * 7. Runtime safety: * - All server services (sync, preview) fetch token from settings * - If token is missing, throw: "Configure Lokalise in plugin settings" * - Retry/backoff wrapper for Lokalise API calls (exponential + jitter) * - Respect 429 Retry-After headers * - Preview remains fast even with thousands of keys * * 8. Token rotation & revocation: * - Admin can replace token anytime via settings UI * - Old token must be replaced before syncing * - Show clear messages for 401/403 errors from Lokalise * * 9. Audit & logging: * - Log changes to plugin settings (who changed, when) * - Never log the raw token * - Settings endpoints protected by 'admin::isAuthenticatedAdmin' * * 10. Migration / backward compatibility: * - Remove any hardcoded tokens from code * - Provide a migration step to store old tokens safely in plugin settings * - Use `config_v1` or versioned plugin config for future upgrades * * 11. Preview & Sync hash handling: * - Store calculated hash and tag snapshot at sync time * - Use same tag snapshot during preview for hash comparison * - Avoid including `updatedAt` in hashes to prevent false re-synces * * SUMMARY: * -------- * - No hardcoded tokens ✔ * - Safe for multiple users ✔ * - Compliant with Strapi security guidelines ✔ * - Compliant with Lokalise token security guidelines ✔ * - Works out-of-the-box and supports optional encryption ✔ * - Fully auditable and extendable ✔ * * Reference: * ---------- * Strapi: https://docs.strapi.io/developer-docs/latest/development/backend-customization/plugin-dev.html * Lokalise API: https://developers.lokalise.com/docs * * This comment serves as the canonical guideline for plugin developers. * Always follow these steps when touching settings, token storage, or API integrations. */ module.exports = ({ strapi }) => { let runner; let slugMap = null; const formatLogArgs = (...args) => args .flat() .map((arg) => { if (arg instanceof Error) { return arg.stack || arg.message; } if (typeof arg === 'object') { try { return JSON.stringify(arg); } catch (err) { return String(arg); } } return String(arg); }) .join(' '); const loggerAdapter = { log: (...args) => strapi.log.info(formatLogArgs(...args)), error: (...args) => strapi.log.error(formatLogArgs(...args)), }; const normalize = (value) => { if (!value || (typeof value !== 'string' && typeof value !== 'number')) { return ''; } return value .toString() .trim() .replace(/\s+/g, '-') .replace(/[^a-zA-Z0-9-]/g, '') .toLowerCase(); }; const discoverStrapiTypes = () => { const types = new Set(); Object.values(strapi.contentTypes || {}).forEach((schema) => { if (!schema?.uid?.startsWith('api::')) { return; } const { kind = 'collectionType' } = schema || {}; const uidPart = schema?.uid?.split('.')[1] || ''; const collectionName = schema?.collectionName || ''; const plural = schema?.info?.pluralName || ''; const singular = schema?.info?.singularName || ''; const apiPath = kind === 'singleType' ? normalize(singular || uidPart || '') : normalize(collectionName || plural || uidPart || ''); if (apiPath && apiPath.length > 0) { types.add(apiPath); } }); return Array.from(types); }; const buildContentTypeMap = () => { const map = {}; Object.values(strapi.contentTypes || {}).forEach((schema) => { if (!schema?.uid?.startsWith('api::')) { return; } const { kind = 'collectionType' } = schema || {}; const uidPart = schema?.uid?.split('.')[1] || ''; const collectionName = schema?.collectionName || ''; const plural = schema?.info?.pluralName || ''; const singular = schema?.info?.singularName || ''; const apiPath = kind === 'singleType' ? normalize(singular || uidPart || '') : normalize(collectionName || plural || uidPart || ''); if (apiPath && apiPath.length > 0) { map[apiPath] = { uid: schema.uid, kind }; } }); return map; }; const buildContentTypeDescriptors = () => { const descriptors = new Map(); Object.values(strapi.contentTypes || {}).forEach((schema) => { if (!schema?.uid?.startsWith('api::')) { return; } const { kind = 'collectionType' } = schema || {}; const uidPart = schema?.uid?.split('.')[1] || ''; const collectionName = schema?.collectionName || ''; const plural = schema?.info?.pluralName || ''; const singular = schema?.info?.singularName || ''; const apiPath = kind === 'singleType' ? normalize(singular || uidPart || '') : normalize(collectionName || plural || uidPart || ''); if (!apiPath || apiPath.length === 0 || descriptors.has(apiPath)) { return; } descriptors.set(apiPath, { type: apiPath, uid: schema.uid, kind, displayName: schema?.info?.displayName || schema?.info?.name || schema?.info?.pluralName || schema?.info?.singularName || apiPath, }); }); return Array.from(descriptors.values()).sort((a, b) => a.displayName.localeCompare(b.displayName) ); }; const getRunner = async () => { if (!runner) { // Lazy load lokaliseSync to avoid parse-time evaluation const { createSyncRunner } = require('../lib/lokaliseSync'); const inferredTypes = discoverStrapiTypes(); const settingsService = strapi.plugin('lokalise-sync').service('settings'); const lokaliseCredentials = await settingsService.getRuntimeConfig(); const slugService = strapi.plugin('lokalise-sync').service('slugs'); slugMap = await slugService.getAll(); const collectionTypes = Object.values(strapi.contentTypes || {}) .filter((schema) => schema.kind !== 'singleType' && schema?.uid?.startsWith('api::')) .map((schema) => { const uidPart = schema?.uid?.split('.')[1] || ''; const collectionName = schema?.collectionName || ''; const plural = schema?.info?.pluralName || ''; return normalize(collectionName || plural || uidPart || ''); }) .filter(Boolean); const allTypes = Array.from(new Set([...inferredTypes, ...collectionTypes])); runner = createSyncRunner({ logger: loggerAdapter, strapiTypes: allTypes.length > 0 ? allTypes.join(',') : undefined, collectionTypes, entityService: strapi.entityService, metadataService: strapi.plugin('lokalise-sync').service('metadata'), contentTypeMap: buildContentTypeMap(), skipNestedRelations: [ 'authors.articles', 'categories.articles', ], slugMap, lokaliseApiToken: lokaliseCredentials?.lokaliseApiToken, lokaliseProjectId: lokaliseCredentials?.lokaliseProjectId, lokaliseBaseUrl: lokaliseCredentials?.lokaliseBaseUrl, onSlugMapChange: async (nextMap) => { slugMap = nextMap; await slugService.setAll(nextMap); }, }); } return runner; }; const mapToResponse = (keysMap) => { const payload = []; for (const [type, value] of keysMap.entries()) { if (Array.isArray(value)) { payload.push({ type, keys: value }); } else { const message = value?.error || 'Unable to fetch keys'; payload.push({ type, error: message }); } } return payload; }; const applyRenamesToKeys = (type, keys, renamesMap = {}) => { if (!Array.isArray(keys) || keys.length === 0) { return keys; } const typeRenames = renamesMap[type] || {}; return keys.map((key) => { const original = key.original_key_name || key.key_name; const renamed = typeRenames[original]; if (renamed && renamed !== original) { return { ...key, original_key_name: original, key_name: renamed, }; } if (key.original_key_name) { return { ...key, key_name: original, }; } return { ...key, original_key_name: original, key_name: original, }; }); }; return { async preview({ types, debugMode = false, includeMetadata = false, // CRITICAL: Never fetch key_ids during preview - use lokalise_key_id from Strapi instead previewLimit = null, slugFilters = [], keyNameFilters = [], keyIdFilters = [], keyValueFilters = [], } = {}) { // BEST PRACTICE: No hard limits - processes entries incrementally with memory-efficient storage // previewLimit: Ignored - system processes all entries incrementally using Map-based deduplication // The system processes in batches of 1000 entries, storing keys in a Map (keyed by key_name) // This automatically deduplicates and prevents memory overflow even with millions of entries // Memory usage is bounded by unique keys, not total entries const renamesService = strapi.plugin('lokalise-sync').service('renames'); const renamesMap = await renamesService.getAll(); const runnerInstance = await getRunner(); // TEMP: Reset hash debug counter for fresh logs if (typeof global !== 'undefined') { global.hashDebugCount = {}; global.hashExtractionDebugCount = 0; global.hashExtractionStartTime = Date.now(); } const keysMap = await runnerInstance.collectKeysByType({ types, debugMode, previewLimit, slugFilters, keyNameFilters, keyIdFilters, keyValueFilters }); // Handle empty content types gracefully if (!keysMap || keysMap.size === 0) { return { types: [], results: {}, message: 'No content types found. Please create content types in Strapi and add some entries to preview.', isEmpty: true, appliedFilters: { types: Array.isArray(types) && types.length > 0 ? types.map((item) => item.trim()).filter(Boolean) : [], slugs: [], keyNames: [], keyIds: [], keyValues: [], }, }; } let existingMetaMap = new Map(); // CRITICAL: NEVER fetch from Lokalise during preview - use lokalise_key_id from Strapi instead // This makes preview FAST - no API calls needed! // Preview should ONLY read lokalise_key_id from Strapi entries (already loaded, instant) // If keys don't have lokalise_key_id in Strapi, sync will search Lokalise when needed // Setting includeMetadata=true would make preview SLOW (API calls for every key) if (includeMetadata) { // Only fetch Lokalise metadata if explicitly requested (slows down preview significantly) // NOTE: This should rarely/never be used - preview should be fast! const lookupNames = new Set(); keysMap.forEach((value) => { if (Array.isArray(value)) { value.forEach((key) => { if (key && typeof key.key_name === 'string') { lookupNames.add(key.key_name); } if (key && typeof key.original_key_name === 'string') { lookupNames.add(key.original_key_name); } }); } }); if (lookupNames.size > 0) { try { existingMetaMap = await runnerInstance.getExistingTagsForKeys(Array.from(lookupNames)); } catch (err) { strapi.log.warn('Failed to load Lokalise metadata during preview', err); } } } const normalizedSlugFilters = Array.isArray(slugFilters) ? slugFilters .flatMap((item) => { if (Array.isArray(item)) { return item; } if (typeof item === 'string') { return item.split(',').map((part) => part.trim()); } return []; }) .map((item) => item.trim().toLowerCase()) .filter(Boolean) : []; const slugFilterActive = normalizedSlugFilters.length > 0; const slugMatchesFilters = (candidate) => { if (!slugFilterActive) { return true; } if (!candidate || typeof candidate !== 'string') { return false; } const normalized = candidate.toLowerCase(); return normalizedSlugFilters.some((filter) => { if (!filter) { return false; } if (filter === '*') { return true; } if (filter.startsWith('*') && filter.endsWith('*') && filter.length > 2) { const inner = filter.slice(1, -1); return normalized.includes(inner); } if (filter.startsWith('*')) { return normalized.endsWith(filter.slice(1)); } if (filter.endsWith('*')) { return normalized.startsWith(filter.slice(0, -1)); } return normalized.includes(filter); }); }; // Normalize key filters FIRST (before they're used in filtering logic) const normalizedKeyNameFilters = Array.isArray(keyNameFilters) ? keyNameFilters .flatMap((item) => { if (Array.isArray(item)) { return item; } if (typeof item === 'string') { return item.split(',').map((part) => part.trim()); } return []; }) .map((item) => item.trim()) .filter(Boolean) : []; const normalizedKeyIdFilters = Array.isArray(keyIdFilters) ? keyIdFilters .flatMap((item) => { if (Array.isArray(item)) { return item; } if (typeof item === 'string') { return item.split(',').map((part) => part.trim()); } return []; }) .map((item) => item.trim()) .filter(Boolean) : []; const normalizedKeyValueFilters = Array.isArray(keyValueFilters) ? keyValueFilters .flatMap((item) => { if (Array.isArray(item)) { return item; } if (typeof item === 'string') { return item.split(',').map((part) => part.trim()); } return []; }) .map((item) => item.trim()) .filter(Boolean) : []; // Check if any filters are active const keyNameFilterActive = normalizedKeyNameFilters.length > 0; const keyIdFilterActive = normalizedKeyIdFilters.length > 0; const keyValueFilterActive = normalizedKeyValueFilters.length > 0; const anyFiltersActive = slugFilterActive || keyNameFilterActive || keyIdFilterActive || keyValueFilterActive; const filteredMap = new Map(); keysMap.forEach((value, type) => { if (Array.isArray(value)) { const renamed = applyRenamesToKeys(type, value, renamesMap).map((key) => { const meta = existingMetaMap.get(key.key_name) || (key.original_key_name ? existingMetaMap.get(key.original_key_name) : undefined); // CRITICAL: Use lokalise_key_id from Strapi FIRST (fast, already in key object) // Only use meta.key_id as fallback (from Lokalise fetch, slow) // This makes preview fast - no Lokalise API calls needed! const lokaliseKeyId = key.lokalise_key_id ?? meta?.key_id ?? undefined; // Extract stored hash from Strapi metadata (lokalise.keys[keyName].meta.lokalise_sync_hash) // The key object should have lokalise_meta or meta from the entry's lokalise.keys structure // Also check if the key has a lokalise object with keys[keyName].meta structure let storedHash = null; let hashSource = null; // TEMP: Track where hash was found // TEMP: Debug hash extraction for first 5 keys (reset counter per preview) // Reset counter at start of preview const previewStartTime = global.hashExtractionStartTime || Date.now(); if (!global.hashExtractionStartTime || (Date.now() - previewStartTime) > 60000) { // Reset if more than 1 minute has passed (new preview run) global.hashExtractionStartTime = Date.now(); global.hashExtractionDebugCount = 0; } const hashDebugCount = (global.hashExtractionDebugCount || 0); const shouldDebugHash = hashDebugCount < 5; if (key.lokalise_meta?.lokalise_sync_hash) { storedHash = key.lokalise_meta.lokalise_sync_hash; hashSource = 'lokalise_meta'; } else if (key.meta?.lokalise_sync_hash) { storedHash = key.meta.lokalise_sync_hash; hashSource = 'meta'; } else if (key.lokalise?.keys) { // Check all keys in lokalise.keys for matching key_name const keyName = key.key_name || key.original_key_name; if (keyName && key.lokalise.keys[keyName]?.meta?.lokalise_sync_hash) { storedHash = key.lokalise.keys[keyName].meta.lokalise_sync_hash; hashSource = `lokalise.keys[${keyName}].meta`; } else { // Try to find by iterating (in case key_name format differs) for (const [storedKeyName, keyData] of Object.entries(key.lokalise.keys || {})) { if (keyData?.meta?.lokalise_sync_hash) { // Check if this key matches (by comparing normalized key names) const normalizedStored = storedKeyName.replace(/\[(\d+)\]/g, '.$1'); const normalizedCurrent = keyName?.replace(/\[(\d+)\]/g, '.$1'); if (normalizedStored === normalizedCurrent || storedKeyName === keyName) { storedHash = keyData.meta.lokalise_sync_hash; hashSource = `lokalise.keys[${storedKeyName}].meta (matched)`; break; } } } } } // TEMP: Debug hash extraction if (shouldDebugHash && lokaliseKeyId) { strapi.log.info(`[HASH EXTRACTION DEBUG] Key "${key.key_name || key.original_key_name}":`); strapi.log.info(` - lokalise_key_id: ${lokaliseKeyId}`); strapi.log.info(` - key.lokalise_meta exists: ${!!key.lokalise_meta}`); strapi.log.info(` - key.meta exists: ${!!key.meta}`); strapi.log.info(` - key.lokalise.keys exists: ${!!key.lokalise?.keys}`); if (key.lokalise?.keys) { const keyName = key.key_name || key.original_key_name; strapi.log.info(` - key.lokalise.keys[${keyName}] exists: ${!!key.lokalise.keys[keyName]}`); if (key.lokalise.keys[keyName]) { strapi.log.info(` - key.lokalise.keys[${keyName}].meta exists: ${!!key.lokalise.keys[keyName].meta}`); if (key.lokalise.keys[keyName].meta) { strapi.log.info(` - key.lokalise.keys[${keyName}].meta.lokalise_sync_hash: ${key.lokalise.keys[keyName].meta.lokalise_sync_hash ? key.lokalise.keys[keyName].meta.lokalise_sync_hash.substring(0, 8) + '...' : 'null'}`); } } } strapi.log.info(` - storedHash found: ${!!storedHash} (source: ${hashSource || 'none'})`); global.hashExtractionDebugCount = hashDebugCount + 1; } // Determine best available tags for hash calculation const snapshotTags = Array.isArray(key.lokalise_meta?.lokalise_tags_snapshot) && key.lokalise_meta.lokalise_tags_snapshot.length > 0 ? key.lokalise_meta.lokalise_tags_snapshot : Array.isArray(meta?.lokalise_tags_snapshot) && meta.lokalise_tags_snapshot.length > 0 ? meta.lokalise_tags_snapshot : []; const tagCandidates = [ ...(Array.isArray(key.tags) ? key.tags : []), ...(Array.isArray(key.existing_tags) ? key.existing_tags : []), ...(Array.isArray(meta?.tags) ? meta.tags : []), ...snapshotTags, ] .map((tag) => (tag === null || tag === undefined ? '' : String(tag).trim())) .filter(Boolean); const mergedTags = Array.from(new Set(tagCandidates)); // Calculate current hash for this key let currentHash = null; try { if (runnerInstance.calculateKeyHash) { currentHash = runnerInstance.calculateKeyHash({ key_name: key.key_name, tags: mergedTags, translations: Array.isArray(key.translations) ? key.translations : [], }); } } catch (hashErr) { // If hash calculation fails, continue without it (will be treated as "needs sync") strapi.log.warn(`Failed to calculate hash for key "${key.key_name}": ${hashErr.message}`); } // TEMP: Debug logging for hash extraction (sample first 5 keys per type) if (typeof hashDebugCount === 'undefined') { global.hashDebugCount = {}; } const typeName = type || 'unknown'; if (!global.hashDebugCount[typeName]) { global.hashDebugCount[typeName] = 0; } if (global.hashDebugCount[typeName] < 5) { const lokaliseKeyId = key.lokalise_key_id ?? meta?.key_id ?? undefined; strapi.log.info(`[HASH DEBUG] Key: ${key.key_name || key.original_key_name || 'unknown'}`); strapi.log.info(` - key_id: ${lokaliseKeyId || 'none'}`); strapi.log.info(` - storedHash: ${storedHash ? `${storedHash.substring(0, 8)}... (from ${hashSource})` : 'none'}`); strapi.log.info(` - currentHash: ${currentHash ? `${currentHash.substring(0, 8)}...` : 'none'}`); strapi.log.info(` - hashesMatch: ${storedHash && currentHash ? storedHash === currentHash : 'N/A (no hash data)'}`); global.hashDebugCount[typeName]++; } return { ...key, existing_tags: Array.isArray(meta?.tags) ? meta.tags : [], lokalise_key_id: typeof lokaliseKeyId === 'number' ? lokaliseKeyId : undefined, // Add hash information for sync status detection lokalise_sync_hash: storedHash, current_sync_hash: currentHash, }; }); let filteredKeys = renamed; // Apply filters in the correct order: // 1. Slug filters (only if no key filters - key filters take priority) // 2. Key name/value filters are already applied in the backend during collection // But we need to apply them here too in case they weren't applied in backend const hasKeyFilters = keyNameFilterActive || keyIdFilterActive || keyValueFilterActive; if (slugFilterActive && !hasKeyFilters) { filteredKeys = filteredKeys.filter((key) => { const slugCandidate = (typeof key.entry_slug === 'string' && key.entry_slug) || (typeof key.entrySlug === 'string' && key.entrySlug) || (typeof key.slug === 'string' && key.slug) || (typeof key.key_name === 'string' ? key.key_name.split('.').slice(1, 2).join('.') : ''); return slugMatchesFilters(slugCandidate); }); } // Apply key name/value filters in service layer (in case they weren't applied in backend) // This is a safety net - the backend should have already filtered, but we double-check here if (hasKeyFilters) { filteredKeys = filteredKeys.filter((key) => { let matches = false; // Check key name filters if (keyNameFilterActive && normalizedKeyNameFilters.length > 0) { const keyName = String(key.key_name || '').toLowerCase(); const keyNameMatch = normalizedKeyNameFilters.some((filter) => { const filterLower = String(filter).toLowerCase(); return keyName === filterLower || keyName.includes(filterLower); }); if (keyNameMatch) matches = true; } // Check key value filters if (keyValueFilterActive && normalizedKeyValueFilters.length > 0) { const keyValueMatch = normalizedKeyValueFilters.some((filter) => { if (key.translations && Array.isArray(key.translations)) { return key.translations.some((translation) => { const translationText = String(translation.translation || translation.value || '').toLowerCase(); const filterLower = String(filter).toLowerCase(); return translationText.includes(filterLower); }); } return false; }); if (keyValueMatch) matches = true; } // Check key ID filters if (keyIdFilterActive && normalizedKeyIdFilters.length > 0) { const keyIdMatch = normalizedKeyIdFilters.some((filter) => { if (key.lokalise_key_id) { const idStr = String(key.lokalise_key_id).toLowerCase(); if (idStr.includes(filter.toLowerCase())) return true; } if (key.entry_id) { const idStr = String(key.entry_id).toLowerCase(); if (idStr.includes(filter.toLowerCase())) return true; } return false; }); if (keyIdMatch) matches = true; } return matches; }); } // Only include this content type if it has matching keys, or if no filters are active if (anyFiltersActive) { // When filters are active, only include content types with at least one matching key if (filteredKeys.length > 0) { filteredMap.set(type, filteredKeys); } // Skip content types with no matching keys } else { // No filters active - include all content types (even if empty) filteredMap.set(type, filteredKeys); } } else { // Error case - only include if no filters are active if (!anyFiltersActive) { filteredMap.set(type, value); } // Skip error entries when filters are active } }); const appliedTypeFilters = Array.isArray(types) && types.length > 0 ? types.map((item) => item.trim()).filter(Boolean) : []; // TEMP: Summary log for hash distribution let totalKeys = 0; let keysWithHash = 0; let legacyKeys = 0; let unsyncedKeys = 0; filteredMap.forEach((keys) => { if (Array.isArray(keys)) { keys.forEach((key) => { totalKeys++; const hasKeyId = key.lokalise_key_id != null; const hasStoredHash = key.lokalise_sync_hash && key.lokalise_sync_hash !== null && key.lokalise_sync_hash !== ''; if (!hasKeyId) { unsyncedKeys++; } else if (hasStoredHash) { keysWithHash++; } else { legacyKeys++; } }); } }); strapi.log.info(`[HASH SUMMARY] Total keys: ${totalKeys}, With hash: ${keysWithHash}, Legacy (key_id but no hash): ${legacyKeys}, Unsynced: ${unsyncedKeys}`); return { types: Array.from(filteredMap.keys()), results: mapToResponse(filteredMap), appliedFilters: { types: appliedTypeFilters, slugs: slugFilterActive ? normalizedSlugFilters : [], keyNames: keyNameFilterActive ? normalizedKeyNameFilters : [], keyIds: keyIdFilterActive ? normalizedKeyIdFilters : [], keyValues: keyValueFilterActive ? normalizedKeyValueFilters : [], }, }; }, async syncSelection(selection = [], incomingRenames = {}, options = {}) { if (!Array.isArray(selection) || selection.length === 0) { throw new Error('Selection array is required'); } const runnerInstance = await getRunner(); const renamesService = strapi.plugin('lokalise-sync').service('renames'); let renamesMap = await renamesService.getAll(); if (incomingRenames && typeof incomingRenames === 'object' && !Array.isArray(incomingRenames)) { renamesMap = await renamesService.update(incomingRenames); } const prepared = []; for (const item of selection) { const { type, keyNames, keys: providedKeys } = item || {}; if (!type) continue; const keysMap = await runnerInstance.collectKeysByType({ types: [type] }); const allKeys = keysMap.get(type) || []; const renamedKeys = applyRenamesToKeys(type, allKeys, renamesMap); // Determine which keys to sync let keysToSync = []; if (Array.isArray(providedKeys) && providedKeys.length > 0) { // Use provided keys (from preview) - they have lokalise_key_id // But we need to merge with full key data from Strapi (translations, etc.) const providedKeyNames = new Set(providedKeys.map(k => k.key_name || k.keyName).filter(Boolean)); const fullKeys = renamedKeys.filter((key) => providedKeyNames.has(key.key_name)); // Merge lokalise_key_id and existing_tags from provided keys into full keys const providedKeysMap = new Map(); providedKeys.forEach((pk) => { const keyName = pk.key_name || pk.keyName; if (keyName) { providedKeysMap.set(keyName, { lokalise_key_id: pk.lokalise_key_id || null, existing_tags: Array.isArray(pk.existing_tags) ? pk.existing_tags : [], }); } }); keysToSync = fullKeys.map((key) => { const providedData = providedKeysMap.get(key.key_name); if (providedData) { // Merge lokalise_key_id and existing_tags from preview into full key data const mergedKey = { ...key, lokalise_key_id: providedData.lokalise_key_id || key.lokalise_key_id || undefined, existing_tags: providedData.existing_tags.length > 0 ? providedData.existing_tags : (key.existing_tags || []), }; // LOG: Track if we have lokalise_key_id if (mergedKey.lokalise_key_id) { strapi.log.info(`✅ Key "${key.key_name}" has lokalise_key_id=${mergedKey.lokalise_key_id} from preview - will UPDATE in Lokalise`); } else { strapi.log.info(`⚠️ Key "${key.key_name}" has NO lokalise_key_id - will CREATE as new in Lokalise`); } return mergedKey; } return key; }); const keysWithId = keysToSync.filter(k => k.lokalise_key_id).length; strapi.log.info(`📊 Type '${type}': ${keysWithId}/${keysToSync.length} keys have lokalise_key_id from preview (will UPDATE), ${keysToSync.length - keysWithId} will CREATE`); } else if (Array.isArray(keyNames) && keyNames.length > 0) { // Fallback: if only keyNames provided, filter by names keysToSync = renamedKeys.filter((key) => keyNames.includes(key.key_name)); } if (keysToSync.length > 0) { prepared.push({ type, keys: keysToSync }); } } if (prepared.length === 0) { return { result: [], message: 'No matching keys found for selection.' }; } const result = await runnerInstance.syncSelectedKeys(prepared, options); return { result }; }, async syncAll() { const runnerInstance = await getRunner(); const { successCount, errorCount, totalKeys } = await runnerInstance.run({ preview: false }); return { successCount, errorCount, totalKeys }; }, async listContentTypes() { return buildContentTypeDescriptors(); }, }; };