First implementation of Plimi
This commit is contained in:
75
src/tools/csv-tools/index.ts
Normal file
75
src/tools/csv-tools/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||
import { runCsvTools } from "./run";
|
||||
|
||||
export interface CsvToolsOptions {
|
||||
mode: "csv-to-json" | "json-to-csv";
|
||||
delimiter: "," | ";" | "\t";
|
||||
hasHeaderRow: boolean;
|
||||
prettyJson: boolean;
|
||||
}
|
||||
|
||||
export const csvToolsPlugin: PlimiPlugin<CsvToolsOptions> = {
|
||||
manifest: {
|
||||
id: "csv-tools",
|
||||
name: "CSV <-> JSON Converter",
|
||||
description: "Convert CSV text to JSON objects, or convert JSON array of objects/arrays to CSV.",
|
||||
category: "developer",
|
||||
version: "1.0.0",
|
||||
tags: ["csv", "json", "converter", "parser", "format"],
|
||||
input: {
|
||||
type: "text",
|
||||
label: "Input Content",
|
||||
placeholder: "Paste CSV text or JSON array here...",
|
||||
multiline: true,
|
||||
rows: 10,
|
||||
},
|
||||
output: { type: "text" },
|
||||
example: "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles",
|
||||
offlineReady: true,
|
||||
},
|
||||
|
||||
optionsSchema: {
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
key: "mode",
|
||||
label: "Conversion Mode",
|
||||
defaultValue: "csv-to-json",
|
||||
options: [
|
||||
{ label: "CSV to JSON", value: "csv-to-json" },
|
||||
{ label: "JSON to CSV", value: "json-to-csv" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "delimiter",
|
||||
label: "Delimiter / Separator",
|
||||
defaultValue: ",",
|
||||
options: [
|
||||
{ label: "Comma (,)", value: "," },
|
||||
{ label: "Semicolon (;)", value: ";" },
|
||||
{ label: "Tab (\\t)", value: "\t" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
key: "hasHeaderRow",
|
||||
label: "First row is header (CSV -> JSON)",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
key: "prettyJson",
|
||||
label: "Pretty Print JSON",
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
cancelable: false,
|
||||
worker: false,
|
||||
},
|
||||
|
||||
run: runCsvTools,
|
||||
};
|
||||
138
src/tools/csv-tools/run.test.ts
Normal file
138
src/tools/csv-tools/run.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runCsvTools } from "./run";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
|
||||
describe("CSV <-> JSON Converter Plugin", () => {
|
||||
const mockContext: ToolContext = {
|
||||
signal: new AbortController().signal,
|
||||
reportProgress: vi.fn(),
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
it("should convert simple CSV to JSON with headers", async () => {
|
||||
const csv = "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles";
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed).toEqual([
|
||||
{ name: "John Doe", age: "30", city: "New York" },
|
||||
{ name: "Jane Smith", age: "25", city: "Los Angeles" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle commas and newlines inside quoted fields", async () => {
|
||||
const csv = 'name,notes\nJohn,"Likes apples, oranges, and bananas"\nJane,"Likes reading\nand cycling"';
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed[0].notes).toBe("Likes apples, oranges, and bananas");
|
||||
expect(parsed[1].notes).toBe("Likes reading\nand cycling");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle escaped quotes inside quotes", async () => {
|
||||
const csv = 'name,description\nJohn,"Known as ""The Apple King"""';
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed[0].description).toBe('Known as "The Apple King"');
|
||||
}
|
||||
});
|
||||
|
||||
it("should support custom delimiters like Semicolon", async () => {
|
||||
const csv = "name;age\nJohn Doe;30";
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ";", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed[0]).toEqual({ name: "John Doe", age: "30" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse without header rows", async () => {
|
||||
const csv = "John,30\nJane,25";
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: false, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed).toEqual([
|
||||
["John", "30"],
|
||||
["Jane", "25"],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should convert JSON array of objects to CSV", async () => {
|
||||
const json = JSON.stringify([
|
||||
{ name: "John Doe", age: 30, city: "New York" },
|
||||
{ name: "Jane Smith", age: 25, city: "Los Angeles" },
|
||||
]);
|
||||
const result = await runCsvTools(
|
||||
{ text: json },
|
||||
{ mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
expect(result.value).toBe(
|
||||
"name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should convert JSON array of arrays to CSV", async () => {
|
||||
const json = JSON.stringify([
|
||||
["John", 30],
|
||||
["Jane", 25],
|
||||
]);
|
||||
const result = await runCsvTools(
|
||||
{ text: json },
|
||||
{ mode: "json-to-csv", delimiter: ";", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
expect(result.value).toBe("John;30\nJane;25");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error for invalid JSON in JSON-to-CSV mode", async () => {
|
||||
await expect(
|
||||
runCsvTools({ text: "not-json" }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
|
||||
).rejects.toThrow("Invalid input: Not a valid JSON string");
|
||||
});
|
||||
|
||||
it("should throw error for non-array JSON", async () => {
|
||||
await expect(
|
||||
runCsvTools({ text: '{"a": 1}' }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
|
||||
).rejects.toThrow("JSON content must be an array");
|
||||
});
|
||||
});
|
||||
199
src/tools/csv-tools/run.ts
Normal file
199
src/tools/csv-tools/run.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { ToolInput } from "../../core/io/input-types";
|
||||
import type { ToolResult } from "../../core/io/output-types";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
import type { CsvToolsOptions } from "./index";
|
||||
|
||||
export function parseCsv(text: string, delimiter: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentField = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (nextChar === '"') {
|
||||
// Escaped quote
|
||||
currentField += '"';
|
||||
i++; // Skip next quote
|
||||
} else {
|
||||
// Closing quote
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
} else {
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === delimiter) {
|
||||
currentRow.push(currentField);
|
||||
currentField = "";
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
currentRow.push(currentField);
|
||||
currentField = "";
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
|
||||
if (char === "\r" && nextChar === "\n") {
|
||||
i++; // Skip \n in \r\n
|
||||
}
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push final field/row if anything remains
|
||||
if (currentField !== "" || currentRow.length > 0) {
|
||||
currentRow.push(currentField);
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
// Filter out completely empty trailing row (e.g. from file ending with a newline)
|
||||
if (rows.length > 0) {
|
||||
const lastRow = rows[rows.length - 1];
|
||||
if (lastRow.length === 1 && lastRow[0] === "") {
|
||||
rows.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function escapeCsvField(val: unknown, delimiter: string): string {
|
||||
if (val === null || val === undefined) return "";
|
||||
const str = String(val);
|
||||
const needsQuotes =
|
||||
str.includes(delimiter) ||
|
||||
str.includes('"') ||
|
||||
str.includes("\n") ||
|
||||
str.includes("\r");
|
||||
|
||||
if (needsQuotes) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export async function runCsvTools(
|
||||
input: ToolInput,
|
||||
options: CsvToolsOptions,
|
||||
context: ToolContext
|
||||
): Promise<ToolResult> {
|
||||
const text = (input.text ?? "").trim();
|
||||
if (!text) {
|
||||
throw new Error("Please enter input text to convert.");
|
||||
}
|
||||
|
||||
const { mode, delimiter, hasHeaderRow, prettyJson } = options;
|
||||
|
||||
if (mode === "csv-to-json") {
|
||||
context.reportProgress({ percentage: 20, message: "Parsing CSV..." });
|
||||
const parsedRows = parseCsv(text, delimiter);
|
||||
|
||||
if (parsedRows.length === 0) {
|
||||
return {
|
||||
type: "text",
|
||||
value: "[]",
|
||||
language: "json",
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 60, message: "Structuring JSON..." });
|
||||
|
||||
if (hasHeaderRow) {
|
||||
const headers = parsedRows[0].map(h => h.trim());
|
||||
const objects: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < parsedRows.length; i++) {
|
||||
const row = parsedRows[i];
|
||||
const obj: Record<string, string> = {};
|
||||
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
obj[headers[j]] = row[j] ?? "";
|
||||
}
|
||||
objects.push(obj);
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: JSON.stringify(objects, null, prettyJson ? 2 : undefined),
|
||||
language: "json",
|
||||
};
|
||||
} else {
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: JSON.stringify(parsedRows, null, prettyJson ? 2 : undefined),
|
||||
language: "json",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// json-to-csv mode
|
||||
context.reportProgress({ percentage: 25, message: "Parsing JSON..." });
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw new Error("Invalid input: Not a valid JSON string. JSON to CSV mode requires a valid JSON array.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Invalid input: JSON content must be an array of objects or an array of arrays.");
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
type: "text",
|
||||
value: "",
|
||||
language: "plain",
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 65, message: "Serializing to CSV..." });
|
||||
|
||||
const firstItem = data[0];
|
||||
|
||||
if (Array.isArray(firstItem)) {
|
||||
// Array of arrays
|
||||
const csvLines = (data as unknown[][]).map(row =>
|
||||
row.map(cell => escapeCsvField(cell, delimiter)).join(delimiter)
|
||||
);
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: csvLines.join("\n"),
|
||||
language: "plain",
|
||||
};
|
||||
} else if (typeof firstItem === "object" && firstItem !== null) {
|
||||
// Array of objects
|
||||
// Collect unique keys across all objects to ensure all properties are included
|
||||
const keysSet = new Set<string>();
|
||||
data.forEach(item => {
|
||||
if (typeof item === "object" && item !== null) {
|
||||
Object.keys(item).forEach(k => keysSet.add(k));
|
||||
}
|
||||
});
|
||||
const keys = Array.from(keysSet);
|
||||
|
||||
const headerLine = keys.map(k => escapeCsvField(k, delimiter)).join(delimiter);
|
||||
const csvLines = (data as Record<string, unknown>[]).map(item =>
|
||||
keys.map(key => escapeCsvField(item[key], delimiter)).join(delimiter)
|
||||
);
|
||||
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: [headerLine, ...csvLines].join("\n"),
|
||||
language: "plain",
|
||||
};
|
||||
} else {
|
||||
throw new Error("Invalid input: Array elements must be either objects or arrays.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user