First implementation of Plimi

This commit is contained in:
achraf
2026-06-02 19:32:51 +02:00
commit be635b1828
136 changed files with 13663 additions and 0 deletions

View 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,
};

View 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
View 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.");
}
}
}