Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/csv-key-column.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"lingo.dev": patch
"@lingo.dev/_spec": patch
---

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
3 changes: 2 additions & 1 deletion packages/cli/demo/csv/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"csv": {
"include": [
"./example.csv"
]
],
"keyColumn": "KEY"
}
},
"$schema": "https://lingo.dev/schema/i18n.json"
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default new Command()
{
defaultLocale: sourceLocale,
formatter: i18nConfig!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/cli/cmd/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export default new Command()
defaultLocale: sourceLocale,
injectLocale: bucket.injectLocale,
formatter: i18nConfig!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down Expand Up @@ -263,6 +264,7 @@ export default new Command()
defaultLocale: sourceLocale,
returnUnlocalizedKeys: true,
injectLocale: bucket.injectLocale,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down Expand Up @@ -377,6 +379,7 @@ export default new Command()
defaultLocale: sourceLocale,
injectLocale: bucket.injectLocale,
formatter: i18nConfig!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default new Command()
{
defaultLocale: sourceLocale,
formatter: i18nConfig!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/purge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default new Command()
defaultLocale: sourceLocale,
injectLocale: bucket.injectLocale,
formatter: i18nConfig!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down
8 changes: 2 additions & 6 deletions packages/cli/src/cli/cmd/run/_types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
bucketTypeSchema,
I18nConfig,
localeCodeSchema,
bucketTypes,
} from "@lingo.dev/_spec";
import { bucketTypeSchema, I18nConfig, bucketTypes } from "@lingo.dev/_spec";
import { z } from "zod";
import { ILocalizer } from "../../localizer/_types";

Expand Down Expand Up @@ -36,6 +31,7 @@ export type CmdRunTask = {
localizableKeys: string[];
onlyKeys: string[];
formatter?: "prettier" | "biome";
keyColumn?: string;
};

export const flagsSchema = z.object({
Expand Down
15 changes: 6 additions & 9 deletions packages/cli/src/cli/cmd/run/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ export default async function execute(input: CmdRunContext) {
{
title: "Initializing localization engine",
task: async (ctx, task) => {
task.title = `Localization engine ${chalk.hex(colors.green)(
"ready",
)} (${ctx.localizer!.id})`;
task.title = `Localization engine ${chalk.hex(colors.green)("ready")} (${ctx.localizer!.id})`;
},
},
{
Expand Down Expand Up @@ -124,9 +122,9 @@ function createWorkerStatusMessage(args: {
"[locale]",
args.assignedTask.targetLocale,
);
return `[${chalk.hex(colors.yellow)(
`${args.percentage}%`,
)}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(colors.yellow)(
return `[${chalk.hex(colors.yellow)(`${args.percentage}%`)}] Processing: ${chalk.dim(displayPath)} (${chalk.hex(
colors.yellow,
)(
args.assignedTask.sourceLocale,
)} -> ${chalk.hex(colors.yellow)(args.assignedTask.targetLocale)})`;
}
Expand All @@ -147,9 +145,7 @@ function createExecutionProgressMessage(ctx: CmdRunContext) {

return `Processed ${chalk.green(succeededTasksCount)}/${
ctx.tasks.length
}, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(
skippedTasksCount,
)}`;
}, Failed ${chalk.red(failedTasksCount)}, Skipped ${chalk.dim(skippedTasksCount)}`;
}

function createLoaderForTask(assignedTask: CmdRunTask) {
Expand All @@ -160,6 +156,7 @@ function createLoaderForTask(assignedTask: CmdRunTask) {
defaultLocale: assignedTask.sourceLocale,
injectLocale: assignedTask.injectLocale,
formatter: assignedTask.formatter,
keyColumn: assignedTask.keyColumn,
},
assignedTask.lockedKeys,
assignedTask.lockedPatterns,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/cmd/run/frozen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default async function frozen(input: CmdRunContext) {
defaultLocale: resolvedSourceLocale,
injectLocale: bucket.injectLocale,
formatter: input.config!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down Expand Up @@ -101,6 +102,7 @@ export default async function frozen(input: CmdRunContext) {
defaultLocale: resolvedSourceLocale,
returnUnlocalizedKeys: true,
injectLocale: bucket.injectLocale,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down
29 changes: 7 additions & 22 deletions packages/cli/src/cli/cmd/run/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,15 @@ export default async function plan(
task: async (ctx, task) => {
const bucketCount = buckets.length;
const bucketFilter = input.flags.bucket
? ` ${chalk.dim(
`(filtered by: ${chalk.hex(colors.yellow)(
input.flags.bucket!.join(", "),
)})`,
)}`
? ` ${chalk.dim(`(filtered by: ${chalk.hex(colors.yellow)(input.flags.bucket!.join(", "))})`)}`
: "";
task.title = `Found ${chalk.hex(colors.yellow)(
bucketCount.toString(),
)} bucket(s)${bucketFilter}`;
task.title = `Found ${chalk.hex(colors.yellow)(bucketCount.toString())} bucket(s)${bucketFilter}`;
},
},
{
title: "Detecting locales",
task: async (ctx, task) => {
task.title = `Found ${chalk.hex(colors.yellow)(
_targetLocales.length.toString(),
)} target locale(s)`;
task.title = `Found ${chalk.hex(colors.yellow)(_targetLocales.length.toString())} target locale(s)`;
},
},
{
Expand All @@ -82,15 +74,9 @@ export default async function plan(
}

const fileFilter = input.flags.file
? ` ${chalk.dim(
`(filtered by: ${chalk.hex(colors.yellow)(
input.flags.file.join(", "),
)})`,
)}`
? ` ${chalk.dim(`(filtered by: ${chalk.hex(colors.yellow)(input.flags.file.join(", "))})`)}`
: "";
task.title = `Found ${chalk.hex(colors.yellow)(
patterns.length.toString(),
)} path pattern(s)${fileFilter}`;
task.title = `Found ${chalk.hex(colors.yellow)(patterns.length.toString())} path pattern(s)${fileFilter}`;
},
},
{
Expand Down Expand Up @@ -137,14 +123,13 @@ export default async function plan(
localizableKeys: bucket.localizableKeys || [],
onlyKeys: input.flags.key || [],
formatter: input.config!.formatter,
keyColumn: bucket.keyColumn,
});
}
}
}

task.title = `Prepared ${chalk.hex(colors.green)(
ctx.tasks.length.toString(),
)} translation task(s)`;
task.title = `Prepared ${chalk.hex(colors.green)(ctx.tasks.length.toString())} translation task(s)`;
},
},
],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/show/_shared-key-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export async function executeKeyCommand(
{
defaultLocale: sourceLocale,
injectLocale: bucket.injectLocale,
keyColumn: bucket.keyColumn,
},
[], // Don't apply any filtering when reading
[],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export default new Command()
defaultLocale: sourceLocale,
injectLocale: bucket.injectLocale,
formatter: i18nConfig!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/cli/loaders/csv.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,106 @@ describe("csv loader", () => {
"The first pull must be for the default locale",
);
});

describe("key column validation", () => {
it("should throw a descriptive error when the key column has duplicate values", async () => {
// Mirrors the Salesfloor bug: first column is categorical, not unique,
// which previously caused silent row collapse.
const dupeCsv = buildCsv([
["type", "en", "es"],
["code", "Hello", ""],
["code", "Bye", ""],
["menu", "Home", ""],
["menu", "Profile", ""],
]);

const loader = createCsvLoader();
loader.setDefaultLocale("en");

await expect(loader.pull("en", dupeCsv)).rejects.toThrow(
/CSV column "type" has duplicate values/,
);
});

it("should not throw when the detected key column is unique", async () => {
const loader = createCsvLoader();
loader.setDefaultLocale("en");

// sampleCsv has unique `id` values — this should just work.
await expect(loader.pull("en", sampleCsv)).resolves.toBeDefined();
});

it("should use the `keyColumn` option instead of the first column", async () => {
// First column `type` is repeated; `id` is unique.
// With keyColumn: "id", the loader must use `id` and not fail on `type` dupes.
const csv = buildCsv([
["type", "id", "en", "es"],
["code", "hello", "Hello", ""],
["code", "bye", "Bye", ""],
["menu", "home", "Home", ""],
]);

const loader = createCsvLoader({ keyColumn: "id" });
loader.setDefaultLocale("en");

const result = await loader.pull("en", csv);
expect(result).toEqual({ hello: "Hello", bye: "Bye", home: "Home" });
});

it("should throw when `keyColumn` points to a column not in the CSV", async () => {
const loader = createCsvLoader({ keyColumn: "nonexistent" });
loader.setDefaultLocale("en");

await expect(loader.pull("en", sampleCsv)).rejects.toThrow(
/CSV key column "nonexistent" is not present/,
);
});

it("should still throw on duplicates when `keyColumn` points at a non-unique column", async () => {
const csv = buildCsv([
["id", "group", "en"],
["1", "menu", "Hello"],
["2", "menu", "Bye"],
]);

const loader = createCsvLoader({ keyColumn: "group" });
loader.setDefaultLocale("en");

await expect(loader.pull("en", csv)).rejects.toThrow(
/CSV column "group" has duplicate values/,
);
});

it("should correctly push translations when `keyColumn` is not the first column", async () => {
// Metadata columns (`type`, `group`) come first; `id` is the key in the middle.
// Verifies that push matches translations by id (not row index) and preserves
// non-key columns through the round-trip.
const csv = buildCsv([
["type", "group", "id", "en"],
["code", "ui", "hello", "Hello"],
["code", "ui", "bye", "Bye"],
["menu", "nav", "home", "Home"],
]);

const loader = createCsvLoader({ keyColumn: "id" });
loader.setDefaultLocale("en");
await loader.pull("en", csv);

const esCsv = await loader.push("es", {
hello: "Hola",
bye: "Adiós",
home: "Inicio",
});

const parsed = parse(esCsv, {
columns: true,
skip_empty_lines: true,
});
expect(parsed).toEqual([
{ type: "code", group: "ui", id: "hello", en: "Hello", es: "Hola" },
{ type: "code", group: "ui", id: "bye", en: "Bye", es: "Adiós" },
{ type: "menu", group: "nav", id: "home", en: "Home", es: "Inicio" },
]);
});
});
});
Loading
Loading