strapi-to-lokalise-plugin
Version:
Preview and sync Lokalise translations from Strapi admin
873 lines (782 loc) • 35.4 kB
JavaScript
;
/**
* 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();
},
};
};