import * as fsPromises from 'node:fs/promises';
import { join } from 'node:path';
import { getIntlayerAPIProxy } from '@intlayer/api';
import {
formatPath,
type ListGitFilesOptions,
listGitFiles,
parallelize,
prepareIntlayer,
writeContentDeclaration,
} from '@intlayer/chokidar';
import {
ANSIColors,
colorize,
colorizeKey,
type GetConfigurationOptions,
getAppLogger,
getConfiguration,
} from '@intlayer/config';
import type { Dictionary } from '@intlayer/types';
import { getUnmergedDictionaries } from '@intlayer/unmerged-dictionaries-entry';
import { PushLogger, type PushStatus } from '../pushLog';
import { checkCMSAuth } from '../utils/checkAccess';
type PushOptions = {
deleteLocaleDictionary?: boolean;
keepLocaleDictionary?: boolean;
dictionaries?: string[];
gitOptions?: ListGitFilesOptions;
configOptions?: GetConfigurationOptions;
build?: boolean;
};
type DictionariesStatus = {
dictionary: Dictionary;
status: 'pending' | 'pushing' | 'modified' | 'pushed' | 'unknown' | 'error';
error?: Error;
errorMessage?: string;
};
// Print per-dictionary summary similar to loadDictionaries
const statusIconsAndColors = {
pushed: { icon: '✔', color: ANSIColors.GREEN },
modified: { icon: '✔', color: ANSIColors.GREEN },
error: { icon: '✖', color: ANSIColors.RED },
default: { icon: '⏲', color: ANSIColors.BLUE },
};
const getIconAndColor = (status: DictionariesStatus['status']) => {
return (
statusIconsAndColors[status as keyof typeof statusIconsAndColors] ??
statusIconsAndColors.default
);
};
/**
* Get all local dictionaries and push them simultaneously.
*/
export const push = async (options?: PushOptions): Promise<void> => {
const config = getConfiguration(options?.configOptions);
const appLogger = getAppLogger(config);
if (options?.build === true) {
await prepareIntlayer(config, { forceRun: true });
} else if (typeof options?.build === 'undefined') {
await prepareIntlayer(config);
}
try {
const hasCMSAuth = await checkCMSAuth(config);
if (!hasCMSAuth) return;
const intlayerAPI = getIntlayerAPIProxy(undefined, config);
const unmergedDictionariesRecord = getUnmergedDictionaries(config);
const allDictionaries = Object.values(unmergedDictionariesRecord).flat();
const customLocations = Array.from(
new Set(
allDictionaries
.map((dictionary) => dictionary.location)
.filter(
(location) =>
location && !['remote', 'local', 'hybrid'].includes(location)
)
)
) as string[];
let selectedCustomLocations: string[] = [];
if (customLocations.length > 0) {
const { multiselect, confirm, isCancel } = await import('@clack/prompts');
if (customLocations.length === 1) {
const shouldPush = await confirm({
message: `Do you want to push dictionaries with custom location ${colorize(customLocations[0], ANSIColors.BLUE, ANSIColors.RESET)}?`,
initialValue: false,
});
if (isCancel(shouldPush)) {
return;
}
if (shouldPush) {
selectedCustomLocations = [customLocations[0]];
}
} else {
const selected = await multiselect({
message: 'Select custom locations to push:',
options: customLocations.map((location) => ({
value: location,
label: location,
})),
required: false,
});
if (isCancel(selected)) {
return;
}
selectedCustomLocations = selected as string[];
}
}
let dictionaries: Dictionary[] = allDictionaries.filter((dictionary) => {
const location =
dictionary.location ?? config.dictionary?.location ?? 'local';
return (
location === 'remote' ||
location === 'hybrid' ||
selectedCustomLocations.includes(location)
);
});
// Check if the dictionaries list is empty after filtering by location
if (dictionaries.length === 0) {
appLogger(
`No dictionaries found to push. Only dictionaries with location ${colorize('remote', ANSIColors.BLUE, ANSIColors.RESET)}, ${colorize('hybrid', ANSIColors.BLUE, ANSIColors.RESET)} or selected custom locations are pushed.`,
{ level: 'warn' }
);
appLogger(
`You can set the location in your dictionary file (e.g. ${colorize("{ key: 'my-key', location: 'hybrid', ... }", ANSIColors.BLUE, ANSIColors.RESET)} or globally in your intlayer.config.ts file (e.g. ${colorize("{ dictionary: { location: 'hybrid' } }", ANSIColors.BLUE, ANSIColors.RESET)}).`,
{ level: 'info' }
);
return;
}
const existingDictionariesKeys: string[] = Object.keys(
unmergedDictionariesRecord
);
if (options?.dictionaries) {
// Check if the provided dictionaries exist
const noneExistingDictionariesOption = options.dictionaries.filter(
(dictionaryId) => !existingDictionariesKeys.includes(dictionaryId)
);
if (noneExistingDictionariesOption.length > 0) {
appLogger(
`The following dictionaries do not exist: ${noneExistingDictionariesOption.join(
', '
)} and have been ignored.`,
{
level: 'error',
}
);
}
// Filter the dictionaries from the provided list of IDs
dictionaries = dictionaries.filter((dictionary) =>
options.dictionaries?.includes(dictionary.key)
);
}
if (options?.gitOptions) {
const gitFiles = await listGitFiles(options.gitOptions);
dictionaries = dictionaries.filter((dictionary) =>
gitFiles.includes(
join(config.content.baseDir, dictionary.filePath ?? '')
)
);
}
// Check if the dictionaries list is empty
if (dictionaries.length === 0) {
appLogger('No local dictionaries found', {
level: 'error',
});
return;
}
appLogger('Pushing dictionaries:');
// Prepare dictionaries statuses
const dictionariesStatuses: DictionariesStatus[] = dictionaries.map(
(dictionary) => ({
dictionary,
status: 'pending',
})
);
// Initialize aggregated logger similar to loadDictionaries
const logger = new PushLogger();
logger.update(
dictionariesStatuses.map<PushStatus>((s) => ({
dictionaryKey: s.dictionary.key,
status: 'pending',
}))
);
const successfullyPushedDictionaries: Dictionary[] = [];
const processDictionary = async (
statusObj: DictionariesStatus
): Promise<void> => {
statusObj.status = 'pushing';
logger.update([
{ dictionaryKey: statusObj.dictionary.key, status: 'pushing' },
]);
try {
const pushResult = await intlayerAPI.dictionary.pushDictionaries([
statusObj.dictionary,
]);
const updatedDictionaries = pushResult.data?.updatedDictionaries ?? [];
const newDictionaries = pushResult.data?.newDictionaries ?? [];
const allDictionaries = [...updatedDictionaries, ...newDictionaries];
for (const remoteDictionaryData of allDictionaries) {
const localDictionary = unmergedDictionariesRecord[
remoteDictionaryData.key
]?.find(
(dictionary) => dictionary.localId === remoteDictionaryData.localId
);
if (!localDictionary) continue;
await writeContentDeclaration(
{ ...localDictionary, id: remoteDictionaryData.id },
config
);
}
if (
updatedDictionaries.some(
(dictionary) => dictionary.key === statusObj.dictionary.key
)
) {
statusObj.status = 'modified';
successfullyPushedDictionaries.push(statusObj.dictionary);
logger.update([
{ dictionaryKey: statusObj.dictionary.key, status: 'modified' },
]);
} else if (
newDictionaries.some(
(dictionary) => dictionary.key === statusObj.dictionary.key
)
) {
statusObj.status = 'pushed';
successfullyPushedDictionaries.push(statusObj.dictionary);
logger.update([
{ dictionaryKey: statusObj.dictionary.key, status: 'pushed' },
]);
} else {
statusObj.status = 'unknown';
}
} catch (error) {
statusObj.status = 'error';
statusObj.error = error as Error;
statusObj.errorMessage = `Error pushing dictionary ${statusObj.dictionary.key}: ${error}`;
logger.update([
{ dictionaryKey: statusObj.dictionary.key, status: 'error' },
]);
}
};
// Process dictionaries in parallel with a concurrency limit (reuse parallelize)
await parallelize(dictionariesStatuses, processDictionary, 5);
// Stop the logger and render final state
logger.finish();
for (const dictionaryStatus of dictionariesStatuses) {
const { icon, color } = getIconAndColor(dictionaryStatus.status);
appLogger(
` - ${colorizeKey(dictionaryStatus.dictionary.key)} ${ANSIColors.GREY}[${color}${icon} ${dictionaryStatus.status}${ANSIColors.GREY}]${ANSIColors.RESET}`
);
}
// Output any error messages
for (const statusObj of dictionariesStatuses) {
if (statusObj.errorMessage) {
appLogger(statusObj.errorMessage, {
level: 'error',
});
}
}
// Handle delete or keep options
const deleteOption = options?.deleteLocaleDictionary;
const keepOption = options?.keepLocaleDictionary;
if (deleteOption && keepOption) {
throw new Error(
'Cannot specify both --deleteLocaleDictionary and --keepLocaleDictionary options.'
);
}
if (deleteOption) {
// Delete only the successfully pushed dictionaries
await deleteLocalDictionaries(successfullyPushedDictionaries, options);
} else if (keepOption) {
// Do nothing, keep the local dictionaries
} else {
// Ask the user
const remoteDictionaries = successfullyPushedDictionaries.filter(
(dictionary) => dictionary.location === 'remote'
);
const remoteDictionariesKeys = remoteDictionaries.map(
(dictionary) => dictionary.key
);
if (remoteDictionaries.length > 0) {
const { confirm, isCancel } = await import('@clack/prompts');
const shouldDelete = await confirm({
message: `Do you want to delete the local dictionaries that were successfully pushed? ${colorize('(Dictionaries:', ANSIColors.GREY, ANSIColors.RESET)} ${colorizeKey(remoteDictionariesKeys)}${colorize(')', ANSIColors.GREY, ANSIColors.RESET)}`,
initialValue: false,
});
if (isCancel(shouldDelete)) {
return;
}
if (shouldDelete) {
await deleteLocalDictionaries(remoteDictionaries, options);
}
}
}
} catch (error) {
appLogger(error, {
level: 'error',
});
}
};
const deleteLocalDictionaries = async (
dictionariesToDelete: Dictionary[],
options?: PushOptions
): Promise<void> => {
const config = getConfiguration(options?.configOptions);
const appLogger = getAppLogger(config);
// Use a Set to collect all unique file paths
const filePathsSet: Set<string> = new Set();
for (const dictionary of dictionariesToDelete) {
const { filePath } = dictionary;
if (!filePath) {
appLogger(
`Dictionary ${colorizeKey(dictionary.key)} does not have a file path`,
{
level: 'error',
}
);
continue;
}
filePathsSet.add(filePath);
}
for (const filePath of filePathsSet) {
try {
const stats = await fsPromises.lstat(filePath);
if (stats.isFile()) {
await fsPromises.unlink(filePath);
appLogger(`Deleted file ${formatPath(filePath)}`, {});
} else if (stats.isDirectory()) {
appLogger(`Path is a directory ${formatPath(filePath)}, skipping.`, {});
} else {
appLogger(
`Unknown file type for ${formatPath(filePath)}, skipping.`,
{}
);
}
} catch (err) {
appLogger(`Error deleting ${formatPath(filePath)}: ${err}`, {
level: 'error',
});
}
}
};