import { basename, relative } from 'node:path';
import type { AIOptions } from '@intlayer/api';
import {
formatPath,
getGlobalLimiter,
getTaskLimiter,
type ListGitFilesOptions,
prepareIntlayer,
writeContentDeclaration,
} from '@intlayer/chokidar';
import {
ANSIColors,
colorize,
colorizeKey,
colorizePath,
getAppLogger,
getConfiguration,
} from '@intlayer/config';
import type { Locale } from '@intlayer/types';
import {
ensureArray,
type GetTargetDictionaryOptions,
getTargetUnmergedDictionaries,
} from '../getTargetDictionary';
import { setupAI } from '../utils/setupAI';
import {
listTranslationsTasks,
type TranslationTask,
} from './listTranslationsTasks';
import { translateDictionary } from './translateDictionary';
import { writeFill } from './writeFill';
const NB_CONCURRENT_TRANSLATIONS = 7;
// Arguments for the fill function
export type FillOptions = {
sourceLocale?: Locale;
outputLocales?: Locale | Locale[];
mode?: 'complete' | 'review';
gitOptions?: ListGitFilesOptions;
aiOptions?: AIOptions; // Added aiOptions to be passed to translateJSON
verbose?: boolean;
nbConcurrentTranslations?: number;
nbConcurrentTasks?: number; // NEW: number of tasks that may run at once
build?: boolean;
skipMetadata?: boolean;
} & GetTargetDictionaryOptions;
/**
* Fill translations based on the provided options.
*/
export const fill = async (options?: FillOptions): Promise<void> => {
const configuration = getConfiguration(options?.configOptions);
const appLogger = getAppLogger(configuration);
if (options?.build === true) {
await prepareIntlayer(configuration, { forceRun: true });
} else if (typeof options?.build === 'undefined') {
await prepareIntlayer(configuration);
}
const { defaultLocale, locales } = configuration.internationalization;
const mode = options?.mode ?? 'complete';
const baseLocale = options?.sourceLocale ?? defaultLocale;
const outputLocales = options?.outputLocales
? ensureArray(options.outputLocales)
: locales;
const aiResult = await setupAI(configuration, options?.aiOptions);
if (!aiResult?.hasAIAccess) return;
const { aiClient, aiConfig } = aiResult;
const targetUnmergedDictionaries =
await getTargetUnmergedDictionaries(options);
const affectedDictionaryKeys = new Set<string>();
targetUnmergedDictionaries.forEach((dict) => {
affectedDictionaryKeys.add(dict.key);
});
const keysToProcess = Array.from(affectedDictionaryKeys);
appLogger([
'Affected dictionary keys for processing:',
keysToProcess.length > 0
? keysToProcess.map((key) => colorizeKey(key)).join(', ')
: colorize('No keys found', ANSIColors.YELLOW),
]);
if (keysToProcess.length === 0) return;
/**
* List the translations tasks
*
* Create a list of per-locale dictionaries to translate
*
* In 'complete' mode, filter only the missing locales to translate
*/
const translationTasks: TranslationTask[] = listTranslationsTasks(
targetUnmergedDictionaries.map((dictionary) => dictionary.localId!),
outputLocales,
mode,
baseLocale,
configuration
);
// AI calls in flight at once (translateJSON + metadata audit)
const nbConcurrentTranslations =
options?.nbConcurrentTranslations ?? NB_CONCURRENT_TRANSLATIONS;
const globalLimiter = getGlobalLimiter(nbConcurrentTranslations);
// NEW: number of *tasks* that may run at once (start/prepare/log/write)
const nbConcurrentTasks = Math.max(
1,
Math.min(
options?.nbConcurrentTasks ?? nbConcurrentTranslations,
translationTasks.length
)
);
const taskLimiter = getTaskLimiter(nbConcurrentTasks);
const runners = translationTasks.map((task) =>
taskLimiter(async () => {
const relativePath = relative(
configuration?.content?.baseDir ?? process.cwd(),
task?.dictionaryFilePath ?? ''
);
// log AFTER acquiring a task slot
appLogger(
`${task.dictionaryPreset} Processing ${colorizePath(basename(relativePath))}`,
{ level: 'info' }
);
const translationTaskResult = await translateDictionary(
task,
configuration,
{
mode,
aiOptions: options?.aiOptions,
fillMetadata: !options?.skipMetadata,
onHandle: globalLimiter, // <= AI calls go through here
aiClient,
aiConfig,
}
);
if (!translationTaskResult?.dictionaryOutput) return;
const { dictionaryOutput, sourceLocale } = translationTaskResult;
// Determine if we should write to separate files
// - If dictionary has explicit fill setting (string or object), use it
// - If dictionary is per-locale AND has no explicit fill=false, use global fill config
// - If dictionary is multilingual (no locale property), always write to same file
const hasDictionaryLevelFill =
typeof dictionaryOutput.fill === 'string' ||
typeof dictionaryOutput.fill === 'object';
const isPerLocale = typeof dictionaryOutput.locale === 'string';
const effectiveFill = hasDictionaryLevelFill
? dictionaryOutput.fill
: isPerLocale
? (configuration.dictionary?.fill ?? true)
: false; // Multilingual dictionaries don't use fill by default
const isFillOtherFile =
typeof effectiveFill === 'string' || typeof effectiveFill === 'object';
if (isFillOtherFile) {
await writeFill(
{
...dictionaryOutput,
// Ensure fill is set on the dictionary for writeFill to use
fill: effectiveFill,
},
outputLocales,
[sourceLocale],
configuration
);
} else {
await writeContentDeclaration(dictionaryOutput, configuration);
if (dictionaryOutput.filePath) {
appLogger(
`${task.dictionaryPreset} Content declaration written to ${formatPath(basename(dictionaryOutput.filePath))}`,
{ level: 'info' }
);
}
}
})
);
await Promise.all(runners);
await (globalLimiter as any).onIdle();
};