Skip to content

Commit bb295f7

Browse files
cherkanovartclaude
andauthored
feat(cli): add keyColumn option for CSV buckets with duplicate key validation (#2076)
* feat(cli): add keyColumn option for CSV loaders and update related commands * chore: add changeset for csv keyColumn feature Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): handle empty CSV and sparse first-row edge cases in key column validation - Use split(/\r?\n/) with trim check instead of non-null assertion for empty CSV safety - Parse header row separately (to_line: 1) for column validation to avoid missing columns when first data row is sparse with relax_column_count_less Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): add keyColumn configuration to CSV i18n settings --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 873e773 commit bb295f7

18 files changed

Lines changed: 274 additions & 45 deletions

File tree

.changeset/csv-key-column.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"lingo.dev": patch
3+
"@lingo.dev/_spec": patch
4+
---
5+
6+
feat(cli): add `keyColumn` option for CSV buckets to specify which column is the unique row identifier, and validate key uniqueness to prevent silent data loss from duplicate keys

packages/cli/demo/csv/i18n.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"csv": {
1111
"include": [
1212
"./example.csv"
13-
]
13+
],
14+
"keyColumn": "KEY"
1415
}
1516
},
1617
"$schema": "https://lingo.dev/schema/i18n.json"

packages/cli/src/cli/cmd/cleanup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export default new Command()
6969
{
7070
defaultLocale: sourceLocale,
7171
formatter: i18nConfig!.formatter,
72+
keyColumn: bucket.keyColumn,
7273
},
7374
bucket.lockedKeys,
7475
bucket.lockedPatterns,

packages/cli/src/cli/cmd/i18n.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export default new Command()
222222
defaultLocale: sourceLocale,
223223
injectLocale: bucket.injectLocale,
224224
formatter: i18nConfig!.formatter,
225+
keyColumn: bucket.keyColumn,
225226
},
226227
bucket.lockedKeys,
227228
bucket.lockedPatterns,
@@ -263,6 +264,7 @@ export default new Command()
263264
defaultLocale: sourceLocale,
264265
returnUnlocalizedKeys: true,
265266
injectLocale: bucket.injectLocale,
267+
keyColumn: bucket.keyColumn,
266268
},
267269
bucket.lockedKeys,
268270
bucket.lockedPatterns,
@@ -377,6 +379,7 @@ export default new Command()
377379
defaultLocale: sourceLocale,
378380
injectLocale: bucket.injectLocale,
379381
formatter: i18nConfig!.formatter,
382+
keyColumn: bucket.keyColumn,
380383
},
381384
bucket.lockedKeys,
382385
bucket.lockedPatterns,

packages/cli/src/cli/cmd/lockfile.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default new Command()
4242
{
4343
defaultLocale: sourceLocale,
4444
formatter: i18nConfig!.formatter,
45+
keyColumn: bucket.keyColumn,
4546
},
4647
bucket.lockedKeys,
4748
bucket.lockedPatterns,

packages/cli/src/cli/cmd/purge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export default new Command()
102102
defaultLocale: sourceLocale,
103103
injectLocale: bucket.injectLocale,
104104
formatter: i18nConfig!.formatter,
105+
keyColumn: bucket.keyColumn,
105106
},
106107
bucket.lockedKeys,
107108
bucket.lockedPatterns,

packages/cli/src/cli/cmd/run/_types.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
bucketTypeSchema,
3-
I18nConfig,
4-
localeCodeSchema,
5-
bucketTypes,
6-
} from "@lingo.dev/_spec";
1+
import { bucketTypeSchema, I18nConfig, bucketTypes } from "@lingo.dev/_spec";
72
import { z } from "zod";
83
import { ILocalizer } from "../../localizer/_types";
94

@@ -36,6 +31,7 @@ export type CmdRunTask = {
3631
localizableKeys: string[];
3732
onlyKeys: string[];
3833
formatter?: "prettier" | "biome";
34+
keyColumn?: string;
3935
};
4036

4137
export const flagsSchema = z.object({

packages/cli/src/cli/cmd/run/execute.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ export default async function execute(input: CmdRunContext) {
3434
{
3535
title: "Initializing localization engine",
3636
task: async (ctx, task) => {
37-
task.title = `Localization engine ${chalk.hex(colors.green)(
38-
"ready",
39-
)} (${ctx.localizer!.id})`;
37+
task.title = `Localization engine ${chalk.hex(colors.green)("ready")} (${ctx.localizer!.id})`;
4038
},
4139
},
4240
{
@@ -124,9 +122,9 @@ function createWorkerStatusMessage(args: {
124122
"[locale]",
125123
args.assignedTask.targetLocale,
126124
);
127-
return `[${chalk.hex(colors.yellow)(
128-
`${args.percentage}%`,
129-
)}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(colors.yellow)(
125+
return `[${chalk.hex(colors.yellow)(`${args.percentage}%`)}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(
126+
colors.yellow,
127+
)(
130128
args.assignedTask.sourceLocale,
131129
)} -> ${chalk.hex(colors.yellow)(args.assignedTask.targetLocale)})`;
132130
}
@@ -147,9 +145,7 @@ function createExecutionProgressMessage(ctx: CmdRunContext) {
147145

148146
return `Processed ${chalk.green(succeededTasksCount)}/${
149147
ctx.tasks.length
150-
}, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(
151-
skippedTasksCount,
152-
)}`;
148+
}, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(skippedTasksCount)}`;
153149
}
154150

155151
function createLoaderForTask(assignedTask: CmdRunTask) {
@@ -160,6 +156,7 @@ function createLoaderForTask(assignedTask: CmdRunTask) {
160156
defaultLocale: assignedTask.sourceLocale,
161157
injectLocale: assignedTask.injectLocale,
162158
formatter: assignedTask.formatter,
159+
keyColumn: assignedTask.keyColumn,
163160
},
164161
assignedTask.lockedKeys,
165162
assignedTask.lockedPatterns,

packages/cli/src/cli/cmd/run/frozen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export default async function frozen(input: CmdRunContext) {
6060
defaultLocale: resolvedSourceLocale,
6161
injectLocale: bucket.injectLocale,
6262
formatter: input.config!.formatter,
63+
keyColumn: bucket.keyColumn,
6364
},
6465
bucket.lockedKeys,
6566
bucket.lockedPatterns,
@@ -101,6 +102,7 @@ export default async function frozen(input: CmdRunContext) {
101102
defaultLocale: resolvedSourceLocale,
102103
returnUnlocalizedKeys: true,
103104
injectLocale: bucket.injectLocale,
105+
keyColumn: bucket.keyColumn,
104106
},
105107
bucket.lockedKeys,
106108
bucket.lockedPatterns,

packages/cli/src/cli/cmd/run/plan.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,15 @@ export default async function plan(
3939
task: async (ctx, task) => {
4040
const bucketCount = buckets.length;
4141
const bucketFilter = input.flags.bucket
42-
? ` ${chalk.dim(
43-
`(filtered by: ${chalk.hex(colors.yellow)(
44-
input.flags.bucket!.join(", "),
45-
)})`,
46-
)}`
42+
? ` ${chalk.dim(`(filtered by: ${chalk.hex(colors.yellow)(input.flags.bucket!.join(", "))})`)}`
4743
: "";
48-
task.title = `Found ${chalk.hex(colors.yellow)(
49-
bucketCount.toString(),
50-
)} bucket(s)${bucketFilter}`;
44+
task.title = `Found ${chalk.hex(colors.yellow)(bucketCount.toString())} bucket(s)${bucketFilter}`;
5145
},
5246
},
5347
{
5448
title: "Detecting locales",
5549
task: async (ctx, task) => {
56-
task.title = `Found ${chalk.hex(colors.yellow)(
57-
_targetLocales.length.toString(),
58-
)} target locale(s)`;
50+
task.title = `Found ${chalk.hex(colors.yellow)(_targetLocales.length.toString())} target locale(s)`;
5951
},
6052
},
6153
{
@@ -82,15 +74,9 @@ export default async function plan(
8274
}
8375

8476
const fileFilter = input.flags.file
85-
? ` ${chalk.dim(
86-
`(filtered by: ${chalk.hex(colors.yellow)(
87-
input.flags.file.join(", "),
88-
)})`,
89-
)}`
77+
? ` ${chalk.dim(`(filtered by: ${chalk.hex(colors.yellow)(input.flags.file.join(", "))})`)}`
9078
: "";
91-
task.title = `Found ${chalk.hex(colors.yellow)(
92-
patterns.length.toString(),
93-
)} path pattern(s)${fileFilter}`;
79+
task.title = `Found ${chalk.hex(colors.yellow)(patterns.length.toString())} path pattern(s)${fileFilter}`;
9480
},
9581
},
9682
{
@@ -137,14 +123,13 @@ export default async function plan(
137123
localizableKeys: bucket.localizableKeys || [],
138124
onlyKeys: input.flags.key || [],
139125
formatter: input.config!.formatter,
126+
keyColumn: bucket.keyColumn,
140127
});
141128
}
142129
}
143130
}
144131

145-
task.title = `Prepared ${chalk.hex(colors.green)(
146-
ctx.tasks.length.toString(),
147-
)} translation task(s)`;
132+
task.title = `Prepared ${chalk.hex(colors.green)(ctx.tasks.length.toString())} translation task(s)`;
148133
},
149134
},
150135
],

0 commit comments

Comments
 (0)