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

43
src/tools/base64/index.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runBase64 } from "./run";
export interface Base64Options {
mode: "encode" | "decode";
}
export const base64Plugin: PlimiPlugin<Base64Options> = {
manifest: {
id: "base64",
name: "Base64 Encoder / Decoder",
description: "Encode or decode Base64 text locally.",
category: "developer",
version: "1.0.0",
tags: ["base64", "encode", "decode"],
input: { type: "text" },
output: { type: "text" },
example: "Hello, Plimi!",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "mode",
label: "Mode",
defaultValue: "encode",
options: [
{ label: "Encode", value: "encode" },
{ label: "Decode", value: "decode" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false, // Runs synchronously since it's very fast for normal text
},
run: runBase64,
};

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from "vitest";
import { runBase64 } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Base64 Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
it("should encode text correctly", async () => {
const result = await runBase64(
{ text: "Hello World" },
{ mode: "encode" },
mockContext
);
expect(result).toEqual({ type: "text", value: "SGVsbG8gV29ybGQ=" });
});
it("should decode text correctly", async () => {
const result = await runBase64(
{ text: "SGVsbG8gV29ybGQ=" },
{ mode: "decode" },
mockContext
);
expect(result).toEqual({ type: "text", value: "Hello World" });
});
it("should return empty string for empty input", async () => {
const result = await runBase64(
{ text: "" },
{ mode: "encode" },
mockContext
);
expect(result).toEqual({ type: "text", value: "" });
});
it("should throw error on invalid base64 decode", async () => {
await expect(
runBase64({ text: "NotBase64!" }, { mode: "decode" }, mockContext)
).rejects.toThrow("Invalid Base64 input string or encoding error.");
});
});

37
src/tools/base64/run.ts Normal file
View File

@@ -0,0 +1,37 @@
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 { Base64Options } from "./index";
export async function runBase64(
input: ToolInput,
options: Base64Options,
context: ToolContext
): Promise<ToolResult> {
const text = input.text ?? "";
if (!text) {
return {
type: "text",
value: "",
};
}
context.reportProgress({ percentage: 50, message: "Processing..." });
try {
const resultValue =
options.mode === "encode"
? btoa(unescape(encodeURIComponent(text)))
: decodeURIComponent(escape(atob(text)));
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "text",
value: resultValue,
};
} catch (cause) {
throw new Error("Invalid Base64 input string or encoding error.", { cause });
}
}

View File

@@ -0,0 +1,44 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runColorConverter } from "./run";
export interface ColorConverterOptions {
format: "hex" | "rgb" | "hsl";
}
export const colorConverterPlugin: PlimiPlugin<ColorConverterOptions> = {
manifest: {
id: "dev-color",
name: "Color Converter",
description: "Convert colors between HEX, RGB, and HSL formats.",
category: "developer",
version: "1.0.0",
tags: ["color", "hex", "rgb", "hsl", "converter"],
input: { type: "text", placeholder: "#ff0000 or rgb(255,0,0) or hsl(0,100%,50%)", multiline: false },
output: { type: "json" },
example: "#3b82f6",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "format",
label: "Output Format",
defaultValue: "hex",
options: [
{ label: "HEX", value: "hex" },
{ label: "RGB", value: "rgb" },
{ label: "HSL", value: "hsl" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runColorConverter,
};

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { runColorConverter } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Color Converter", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should parse a 6-digit hex color and return all formats", async () => {
const result = await runColorConverter(
{ text: "#ff0000" },
{ format: "hex" },
mockContext
);
expect(result.type).toBe("json");
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.hex).toBe("#ff0000");
expect(value.rgb).toBe("rgb(255, 0, 0)");
expect(value.channels).toEqual({ r: 255, g: 0, b: 0 });
expect(value.primary).toBe("#ff0000");
});
it("should parse an RGB color string", async () => {
const result = await runColorConverter(
{ text: "rgb(0, 128, 255)" },
{ format: "hex" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.channels).toEqual({ r: 0, g: 128, b: 255 });
expect(value.hex).toBe("#0080ff");
});
it("should parse an HSL color string", async () => {
const result = await runColorConverter(
{ text: "hsl(0, 100%, 50%)" },
{ format: "rgb" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
const channels = value.channels as Record<string, unknown>;
expect(channels.r).toBe(255);
expect(channels.g).toBe(0);
expect(channels.b).toBe(0);
expect(value.primary).toBe("rgb(255, 0, 0)");
});
it("should parse a 3-digit shorthand hex", async () => {
const result = await runColorConverter(
{ text: "#f00" },
{ format: "hex" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.hex).toBe("#ff0000");
});
it("should parse hex without hash prefix", async () => {
const result = await runColorConverter(
{ text: "00ff00" },
{ format: "rgb" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.channels).toEqual({ r: 0, g: 255, b: 0 });
expect(value.primary).toBe("rgb(0, 255, 0)");
});
it("should return primary in hsl format when requested", async () => {
const result = await runColorConverter(
{ text: "#ffffff" },
{ format: "hsl" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.primary).toContain("hsl(");
});
it("should throw on unrecognized color format", async () => {
await expect(
runColorConverter({ text: "not-a-color" }, { format: "hex" }, mockContext)
).rejects.toThrow("Unrecognized color format");
});
it("should throw on empty input", async () => {
await expect(
runColorConverter({ text: "" }, { format: "hex" }, mockContext)
).rejects.toThrow("No color value provided");
});
});

View File

@@ -0,0 +1,151 @@
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 { ColorConverterOptions } from "./index";
interface RGB { r: number; g: number; b: number; }
function parseHex(input: string): RGB | null {
const match = input.match(/^#?([0-9a-f]{3,8})$/i);
if (!match) return null;
let hex = match[1];
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (hex.length === 4) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (hex.length >= 6) {
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
};
}
return null;
}
function parseRgb(input: string): RGB | null {
const match = input.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/);
if (!match) return null;
return {
r: Math.min(255, parseInt(match[1], 10)),
g: Math.min(255, parseInt(match[2], 10)),
b: Math.min(255, parseInt(match[3], 10)),
};
}
function parseHsl(input: string): RGB | null {
const match = input.match(/hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3})%?\s*,\s*(\d{1,3})%?/);
if (!match) return null;
const h = parseInt(match[1], 10) / 360;
const s = parseInt(match[2], 10) / 100;
const l = parseInt(match[3], 10) / 100;
if (s === 0) {
const v = Math.round(l * 255);
return { r: v, g: v, b: v };
}
const hue2rgb = (p: number, q: number, t: number): number => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
};
}
function parseColor(input: string): RGB | null {
const trimmed = input.trim().toLowerCase();
return parseHex(trimmed) || parseRgb(trimmed) || parseHsl(trimmed);
}
function rgbToHex(rgb: RGB): string {
const toHex = (n: number) => n.toString(16).padStart(2, "0");
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
}
function rgbToHsl(rgb: RGB): { h: number; s: number; l: number } {
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
const h =
max === r
? ((g - b) / d + (g < b ? 6 : 0)) / 6
: max === g
? ((b - r) / d + 2) / 6
: ((r - g) / d + 4) / 6;
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
export async function runColorConverter(
input: ToolInput,
options: ColorConverterOptions,
context?: ToolContext
): Promise<ToolResult> {
void context;
const text = input.text || "";
if (!text.trim()) {
throw new Error("No color value provided.");
}
const rgb = parseColor(text);
if (!rgb) {
throw new Error("Unrecognized color format. Use HEX (#ff0000), RGB (rgb(255,0,0)), or HSL (hsl(0,100%,50%)).");
}
const hex = rgbToHex(rgb);
const hsl = rgbToHsl(rgb);
let primary: string;
switch (options.format) {
case "hex":
primary = hex;
break;
case "rgb":
primary = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
break;
case "hsl":
primary = `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
break;
default:
primary = hex;
}
return {
type: "json",
value: {
hex,
rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`,
primary,
channels: { r: rgb.r, g: rgb.g, b: rgb.b },
},
};
}

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

View File

@@ -0,0 +1,27 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runExifScrubber } from "./run";
export const exifScrubberPlugin: PlimiPlugin = {
manifest: {
id: "exif-scrubber",
name: "EXIF Scrubber",
description: "Instantly strip all GPS coordinates, camera logs, and timestamps from your photos locally.",
category: "privacy",
version: "1.0.0",
tags: ["privacy", "metadata", "exif", "gps", "strip", "clear", "photo"],
input: {
type: "files",
accept: ["image/jpeg", "image/png", "image/webp"],
multiple: true,
},
output: { type: "files" },
offlineReady: true,
},
capabilities: {
cancelable: true,
worker: false,
},
run: runExifScrubber,
};

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from "vitest";
import { runExifScrubber } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("EXIF Scrubber Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
it("should throw error if no files provided", async () => {
await expect(
runExifScrubber(
{ files: [] },
null,
mockContext
)
).rejects.toThrow("No files uploaded for scrubbing.");
});
});

View File

@@ -0,0 +1,103 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { ToolContext } from "../../core/plugins/plugin-types";
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Failed to load image."));
img.src = url;
});
}
function canvasToBlob(
canvas: HTMLCanvasElement,
mimeType: string,
quality = 0.95
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Failed to export image from canvas."));
}
},
mimeType,
quality
);
});
}
export async function runExifScrubber(
input: ToolInput,
_options: unknown,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
throw new Error("No files uploaded for scrubbing.");
}
const outFiles = [];
for (let i = 0; i < files.length; i++) {
if (context.signal?.aborted) {
throw new DOMException("Operation cancelled", "AbortError");
}
const file = files[i];
context.reportProgress({
percentage: (i / files.length) * 100,
message: `Scrubbing metadata for ${file.name} (${i + 1}/${files.length})...`,
});
const url = URL.createObjectURL(file);
try {
const img = await loadImage(url);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not acquire 2D canvas context.");
}
// Drawing to canvas discards EXIF headers
ctx.drawImage(img, 0, 0);
const mimeType = file.type || "image/jpeg";
const blob = await canvasToBlob(canvas, mimeType);
// Create new clean file name
const dotIdx = file.name.lastIndexOf(".");
const name = dotIdx !== -1
? `${file.name.substring(0, dotIdx)}_scrubbed${file.name.substring(dotIdx)}`
: `${file.name}_scrubbed.jpg`;
outFiles.push({
name: name,
mimeType: mimeType,
blob: blob,
sizeAfter: blob.size,
sizeBefore: file.size,
});
} catch (err) {
console.error(`Error scrubbing file ${file.name}:`, err);
// Fallback or rethrow depending on strict requirements; we rethrow for safety
throw err;
} finally {
URL.revokeObjectURL(url);
}
}
context.reportProgress({ percentage: 100, message: "Metadata scrubbed successfully!" });
return {
type: "files",
files: outFiles,
};
}

View File

@@ -0,0 +1,60 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runFileChecksumVerifier } from "./run";
export interface FileChecksumVerifierOptions {
algorithm: "SHA-256" | "SHA-512";
}
export const fileChecksumVerifierPlugin: PlimiPlugin<FileChecksumVerifierOptions> = {
manifest: {
id: "file-checksum-verifier",
name: "File Checksum Verifier",
description: "Calculate cryptographic SHA-256 or SHA-512 checksums of local files entirely offline in the browser.",
category: "crypto",
version: "1.0.0",
tags: ["checksum", "verifier", "hash", "sha256", "sha512", "file"],
input: {
type: "group",
fields: [
{
type: "files",
key: "files",
label: "Select File(s)",
multiple: true,
description: "Select one or more files to calculate checksums for.",
},
{
type: "text",
key: "expectedChecksum",
label: "Expected Checksum (Optional)",
placeholder: "Paste expected hash to compare against...",
description: "Case-insensitive. Will be compared against computed hashes.",
},
],
},
output: { type: "table" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "algorithm",
label: "Hash Algorithm",
defaultValue: "SHA-256",
options: [
{ label: "SHA-256", value: "SHA-256" },
{ label: "SHA-512", value: "SHA-512" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runFileChecksumVerifier,
};

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from "vitest";
import { runFileChecksumVerifier } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("File Checksum Verifier Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
const textFile = new File(
[new TextEncoder().encode("hello world")],
"test.txt",
{ type: "text/plain" }
);
const helloWorldSha256 = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
it("should calculate SHA-256 checksum and verify successfully", async () => {
const result = await runFileChecksumVerifier(
{
values: {
files: { files: [textFile] },
expectedChecksum: { text: helloWorldSha256 },
},
},
{ algorithm: "SHA-256" },
mockContext
);
expect(result.type).toBe("table");
if (result.type === "table") {
expect(result.columns).toEqual(["File Name", "Size", "Computed Hash", "Expected Hash", "Status"]);
expect(result.rows).toHaveLength(1);
expect(result.rows[0][0]).toBe("test.txt");
expect(result.rows[0][1]).toBe("11 B");
expect(result.rows[0][2]).toBe(helloWorldSha256);
expect(result.rows[0][3]).toBe(helloWorldSha256);
expect(result.rows[0][4]).toBe("✅ Match");
}
});
it("should detect checksum mismatch", async () => {
const result = await runFileChecksumVerifier(
{
values: {
files: { files: [textFile] },
expectedChecksum: { text: "wrongchecksum" },
},
},
{ algorithm: "SHA-256" },
mockContext
);
if (result.type === "table") {
expect(result.rows[0][4]).toBe("❌ Mismatch");
}
});
it("should run with no expected checksum and mark status as N/A", async () => {
const result = await runFileChecksumVerifier(
{
values: {
files: { files: [textFile] },
},
},
{ algorithm: "SHA-256" },
mockContext
);
if (result.type === "table") {
expect(result.rows[0][3]).toBe("(none)");
expect(result.rows[0][4]).toBe("N/A");
}
});
it("should throw error if no files are supplied", async () => {
await expect(
runFileChecksumVerifier({}, { algorithm: "SHA-256" }, mockContext)
).rejects.toThrow("Please select at least one file to verify.");
});
});

View File

@@ -0,0 +1,82 @@
import { getFilesInput, getTextInput } from "../../core/io/input-types";
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 { FileChecksumVerifierOptions } from "./index";
function formatSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
export async function runFileChecksumVerifier(
input: ToolInput,
options: FileChecksumVerifierOptions,
context: ToolContext
): Promise<ToolResult> {
const files = getFilesInput(input, "files");
const expectedChecksum = getTextInput(input, "expectedChecksum").trim().toLowerCase();
if (!files || files.length === 0) {
throw new Error("Please select at least one file to verify.");
}
const { algorithm } = options;
const rows: Array<[string, string, string, string, string]> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileNum = i + 1;
const totalFiles = files.length;
context.reportProgress({
percentage: Math.round(((i) / totalFiles) * 100),
message: `Reading ${file.name} (${fileNum}/${totalFiles})...`,
});
let arrayBuffer: ArrayBuffer;
try {
arrayBuffer = await file.arrayBuffer();
} catch (err: any) {
throw new Error(`Failed to read file "${file.name}": ${err.message ?? err}`);
}
context.reportProgress({
percentage: Math.round(((i + 0.5) / totalFiles) * 100),
message: `Computing ${algorithm} for ${file.name}...`,
});
let hashHex = "";
try {
const hashBuffer = await crypto.subtle.digest(algorithm, arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
} catch (err: any) {
throw new Error(`Failed to compute hash for file "${file.name}": ${err.message ?? err}`);
}
let matchStatus = "N/A";
if (expectedChecksum) {
matchStatus = hashHex === expectedChecksum ? "✅ Match" : "❌ Mismatch";
}
rows.push([
file.name,
formatSize(file.size),
hashHex,
expectedChecksum || "(none)",
matchStatus,
]);
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "table",
columns: ["File Name", "Size", "Computed Hash", "Expected Hash", "Status"],
rows,
};
}

View File

@@ -0,0 +1,56 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runHashGenerator } from "./run";
export interface HashOptions {
algorithm: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
output: "hex" | "base64";
}
export const hashGeneratorPlugin: PlimiPlugin<HashOptions> = {
manifest: {
id: "crypto-hash",
name: "Hash Generator",
description: "Generate cryptographic hashes securely in your browser.",
category: "crypto",
version: "1.0.0",
tags: ["hash", "sha256", "crypto", "digest"],
input: { type: "text" },
output: { type: "text" },
example: "Hello, Plimi!",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "algorithm",
label: "Algorithm",
defaultValue: "SHA-256",
options: [
{ label: "SHA-1", value: "SHA-1" },
{ label: "SHA-256", value: "SHA-256" },
{ label: "SHA-384", value: "SHA-384" },
{ label: "SHA-512", value: "SHA-512" },
],
},
{
type: "select",
key: "output",
label: "Output Format",
defaultValue: "hex",
options: [
{ label: "Hex", value: "hex" },
{ label: "Base64", value: "base64" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runHashGenerator,
};

View File

@@ -0,0 +1,33 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { HashOptions } from "./index";
export async function runHashGenerator(
input: ToolInput,
options: HashOptions
): Promise<ToolResult> {
const text = input.text || "";
if (!text) {
throw new Error("No text provided.");
}
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest(options.algorithm, data);
let outputStr = "";
if (options.output === "hex") {
const hashArray = Array.from(new Uint8Array(hashBuffer));
outputStr = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
} else if (options.output === "base64") {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const binStr = String.fromCharCode(...hashArray);
outputStr = btoa(binStr);
}
return {
type: "text",
value: outputStr,
};
}

View File

@@ -0,0 +1,43 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runHtmlEntity } from "./run";
export interface HtmlEntityOptions {
mode: "encode" | "decode";
}
export const htmlEntityPlugin: PlimiPlugin<HtmlEntityOptions> = {
manifest: {
id: "dev-htmlentity",
name: "HTML Entity Encoder",
description: "Encode special characters to HTML entities or decode them back.",
category: "developer",
version: "1.0.0",
tags: ["html", "entity", "encode", "decode", "escape"],
input: { type: "text", placeholder: "Enter text with < > & \" characters..." },
output: { type: "text" },
example: '<div class="hero">Hello & "World"</div>',
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "mode",
label: "Mode",
defaultValue: "encode",
options: [
{ label: "Encode", value: "encode" },
{ label: "Decode", value: "decode" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runHtmlEntity,
};

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi } from "vitest";
import { runHtmlEntity } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("HTML Entity Encoder/Decoder", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should encode basic HTML special characters", async () => {
const result = await runHtmlEntity(
{ text: '<div class="test">Hello & World</div>' },
{ mode: "encode" },
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
expect(value).toBe("&lt;div class=&quot;test&quot;&gt;Hello &amp; World&lt;/div&gt;");
});
it("should decode basic HTML entities", async () => {
const result = await runHtmlEntity(
{ text: "&lt;div&gt;Hello &amp; World&lt;/div&gt;" },
{ mode: "decode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toBe("<div>Hello & World</div>");
});
it("should encode special symbols", async () => {
const result = await runHtmlEntity(
{ text: "Price: 10€ © 2024" },
{ mode: "encode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("&euro;");
expect(value).toContain("&copy;");
});
it("should decode special symbol entities", async () => {
const result = await runHtmlEntity(
{ text: "&copy; 2024 &mdash; All rights reserved" },
{ mode: "decode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("©");
expect(value).toContain("—");
});
it("should decode numeric character references (decimal)", async () => {
const result = await runHtmlEntity(
{ text: "&#65;&#66;&#67;" },
{ mode: "decode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toBe("ABC");
});
it("should decode numeric character references (hex)", async () => {
const result = await runHtmlEntity(
{ text: "&#x41;&#x42;&#x43;" },
{ mode: "decode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toBe("ABC");
});
it("should encode single quotes", async () => {
const result = await runHtmlEntity(
{ text: "it's a test" },
{ mode: "encode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("&#39;");
});
it("should return empty string for empty input", async () => {
const result = await runHtmlEntity(
{ text: "" },
{ mode: "encode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toBe("");
});
it("should be reversible: encode then decode gives original", async () => {
const original = '<p>Hello & "World"</p>';
const encoded = await runHtmlEntity(
{ text: original },
{ mode: "encode" },
mockContext
);
const decoded = await runHtmlEntity(
{ text: (encoded as { type: "text"; value: string }).value },
{ mode: "decode" },
mockContext
);
expect((decoded as { type: "text"; value: string }).value).toBe(original);
});
});

View File

@@ -0,0 +1,82 @@
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 { HtmlEntityOptions } from "./index";
const ENTITY_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"©": "&copy;",
"®": "&reg;",
"™": "&trade;",
"—": "&mdash;",
"": "&ndash;",
"«": "&laquo;",
"»": "&raquo;",
"°": "&deg;",
"±": "&plusmn;",
"×": "&times;",
"÷": "&divide;",
"€": "&euro;",
"£": "&pound;",
"¥": "&yen;",
"¢": "&cent;",
"§": "&sect;",
"¶": "&para;",
"•": "&bull;",
"…": "&hellip;",
};
const REVERSE_ENTITY_MAP: Record<string, string> = {};
for (const [char, entity] of Object.entries(ENTITY_MAP)) {
REVERSE_ENTITY_MAP[entity] = char;
}
function encodeHtmlEntities(text: string): string {
return text.replace(/[&<>"']|[©®™—–«»°±×÷€£¥¢§¶•…]/g, (char) => {
return ENTITY_MAP[char] || char;
});
}
function decodeHtmlEntities(text: string): string {
let result = text;
for (const [entity, char] of Object.entries(REVERSE_ENTITY_MAP)) {
result = result.replaceAll(entity, char);
}
result = result.replace(/&#(\d+);/g, (_, code) => {
return String.fromCharCode(parseInt(code, 10));
});
result = result.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => {
return String.fromCharCode(parseInt(code, 16));
});
return result;
}
export async function runHtmlEntity(
input: ToolInput,
options: HtmlEntityOptions,
context: ToolContext
): Promise<ToolResult> {
const text = input.text ?? "";
if (!text) {
return { type: "text", value: "" };
}
context.reportProgress({ percentage: 50, message: "Processing..." });
const result = options.mode === "encode" ? encodeHtmlEntities(text) : decodeHtmlEntities(text);
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "text",
value: result,
};
}

View File

@@ -0,0 +1,780 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
} from "react";
import {
Canvas,
Circle,
FabricImage,
Line,
PencilBrush,
Rect,
Textbox,
Triangle,
type FabricObject,
} from "fabric";
import type { ToolUiProps } from "../../core/plugins/plugin-types";
import { Button } from "../../components/ui/Button";
import { Dropzone } from "../../components/ui/Dropzone";
import { Select } from "../../components/ui/Select";
import { Slider } from "../../components/ui/Slider";
import { ToolResultPanel } from "../../components/tool/ToolResultPanel";
import { useToolExecution } from "../../components/tool/useToolExecution";
import type { ImageEditorOptions } from "./index";
type ToolMode = "select" | "draw" | "pan";
type ShapeKind = "rect" | "circle" | "triangle" | "line";
interface EditorState {
selectedType: string;
fill: string;
stroke: string;
fontSize: number;
brushWidth: number;
mode: ToolMode;
zoom: number;
}
interface LayerRow {
object: FabricObject;
id: string;
label: string;
}
const DEFAULT_WIDTH = 960;
const DEFAULT_HEIGHT = 640;
const HISTORY_LIMIT = 40;
const FONT_OPTIONS = [
{ label: "Inter", value: "Inter, system-ui, sans-serif" },
{ label: "Georgia", value: "Georgia, serif" },
{ label: "Mono", value: "ui-monospace, SFMono-Regular, Menlo, monospace" },
{ label: "Arial", value: "Arial, sans-serif" },
];
const FORMAT_OPTIONS = [
{ label: "PNG", value: "image/png" },
{ label: "JPEG", value: "image/jpeg" },
{ label: "WebP", value: "image/webp" },
];
function objectType(object: FabricObject | undefined): string {
if (!object) return "None";
if (object.type === "textbox" || object.type === "i-text") return "Text";
if (object.type === "path") return "Drawing";
return object.type ? object.type.charAt(0).toUpperCase() + object.type.slice(1) : "Object";
}
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return { ref, size };
}
export default function ImageEditorUi({
plugin,
}: ToolUiProps<ImageEditorOptions>) {
const canvasElRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<Canvas | null>(null);
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const restoringRef = useRef(false);
const sourceFileRef = useRef<File | undefined>(undefined);
const isPanningRef = useRef(false);
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
const { ref: workspaceRef, size: workspaceSize } = useElementSize<HTMLDivElement>();
const [sourceFile, setSourceFile] = useState<File | undefined>();
const [canvasReady, setCanvasReady] = useState(false);
const [canvasSize, setCanvasSize] = useState({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
});
const [activeObject, setActiveObject] = useState<FabricObject | undefined>();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [layerRows, setLayerRows] = useState<LayerRow[]>([]);
const [exportFormat, setExportFormat] =
useState<ImageEditorOptions["format"]>("image/png");
const [quality, setQuality] = useState(92);
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
const [state, setState] = useState<EditorState>({
selectedType: "None",
fill: "#ffffff",
stroke: "#1a1714",
fontSize: 48,
brushWidth: 8,
mode: "select",
zoom: 1,
});
const { run, result, isExecuting, error } = useToolExecution(plugin);
const refreshHistoryControls = useCallback(() => {
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
}, []);
const refreshLayerRows = useCallback(() => {
const canvas = canvasRef.current;
const rows = (canvas?.getObjects() ?? [])
.filter((object) => object.selectable !== false)
.map((object, index) => ({
object,
id: `${object.type}-${index}`,
label: `${objectType(object)} ${index + 1}`,
}))
.reverse();
setLayerRows(rows);
}, []);
const updateActiveState = useCallback(() => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
setActiveObject(object);
setState((prev) => ({
...prev,
selectedType: objectType(object),
fill: typeof object?.get("fill") === "string" ? String(object.get("fill")) : prev.fill,
stroke: typeof object?.get("stroke") === "string" ? String(object.get("stroke")) : prev.stroke,
fontSize: typeof object?.get("fontSize") === "number" ? Number(object.get("fontSize")) : prev.fontSize,
}));
refreshLayerRows();
}, [refreshLayerRows]);
const pushHistory = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || restoringRef.current) return;
const snapshot = JSON.stringify(canvas.toJSON());
const current = historyRef.current[historyIndexRef.current];
if (snapshot === current) return;
const nextHistory = historyRef.current.slice(0, historyIndexRef.current + 1);
nextHistory.push(snapshot);
if (nextHistory.length > HISTORY_LIMIT) {
nextHistory.shift();
}
historyRef.current = nextHistory;
historyIndexRef.current = nextHistory.length - 1;
refreshHistoryControls();
refreshLayerRows();
}, [refreshHistoryControls, refreshLayerRows]);
const loadHistory = useCallback(async (index: number) => {
const canvas = canvasRef.current;
const snapshot = historyRef.current[index];
if (!canvas || !snapshot) return;
restoringRef.current = true;
await canvas.loadFromJSON(snapshot);
canvas.renderAll();
restoringRef.current = false;
historyIndexRef.current = index;
updateActiveState();
refreshHistoryControls();
refreshLayerRows();
}, [refreshHistoryControls, refreshLayerRows, updateActiveState]);
const applyDisplayZoom = useCallback((
zoom: number,
size = canvasSize
) => {
const canvas = canvasRef.current;
if (!canvas) return;
const nextZoom = Math.max(0.12, Math.min(3, zoom));
const displayWidth = Math.max(1, Math.round(size.width * nextZoom));
const displayHeight = Math.max(1, Math.round(size.height * nextZoom));
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
canvas.setDimensions({
width: `${displayWidth}px`,
height: `${displayHeight}px`,
}, {
cssOnly: true,
});
canvas.calcOffset();
setState((prev) => ({ ...prev, zoom: nextZoom }));
canvas.requestRenderAll();
}, [canvasSize]);
const fitCanvasToWorkspace = useCallback(() => {
if (workspaceSize.width === 0 || workspaceSize.height === 0) return;
const zoom = Math.max(0.12, Math.min(
1,
(workspaceSize.width - 56) / canvasSize.width,
(workspaceSize.height - 56) / canvasSize.height
));
applyDisplayZoom(zoom);
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
const setCanvasZoom = useCallback((zoom: number) => {
applyDisplayZoom(zoom);
}, [applyDisplayZoom]);
useEffect(() => {
if (!canvasElRef.current || canvasRef.current) return;
const canvas = new Canvas(canvasElRef.current, {
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
backgroundColor: "#ffffff",
preserveObjectStacking: true,
selection: true,
});
canvas.freeDrawingBrush = new PencilBrush(canvas);
canvas.freeDrawingBrush.color = "#1a1714";
canvas.freeDrawingBrush.width = 8;
canvasRef.current = canvas;
const handleModified = () => {
updateActiveState();
pushHistory();
};
canvas.on("selection:created", updateActiveState);
canvas.on("selection:updated", updateActiveState);
canvas.on("selection:cleared", updateActiveState);
canvas.on("object:modified", handleModified);
canvas.on("object:added", pushHistory);
canvas.on("object:removed", pushHistory);
canvas.on("path:created", pushHistory);
pushHistory();
setCanvasReady(true);
return () => {
canvas.dispose();
canvasRef.current = null;
};
}, [pushHistory, updateActiveState]);
useEffect(() => {
fitCanvasToWorkspace();
}, [fitCanvasToWorkspace]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas?.freeDrawingBrush) return;
canvas.isDrawingMode = state.mode === "draw";
canvas.selection = state.mode === "select";
canvas.skipTargetFind = state.mode === "pan";
canvas.freeDrawingBrush.color = state.stroke;
canvas.freeDrawingBrush.width = state.brushWidth;
}, [state.brushWidth, state.mode, state.stroke]);
const loadImageFile = useCallback(async (file: File) => {
const canvas = canvasRef.current;
if (!canvas) return;
sourceFileRef.current = file;
setSourceFile(file);
const url = URL.createObjectURL(file);
try {
const image = await FabricImage.fromURL(url);
const maxWidth = 1400;
const maxHeight = 1000;
const scale = Math.min(1, maxWidth / image.width, maxHeight / image.height);
const width = Math.max(320, Math.round(image.width * scale));
const height = Math.max(240, Math.round(image.height * scale));
canvas.clear();
canvas.setDimensions({ width, height });
setCanvasSize({ width, height });
canvas.backgroundColor = "#ffffff";
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
image.set({
left: 0,
top: 0,
originX: "left",
originY: "top",
selectable: false,
evented: false,
scaleX: width / image.width,
scaleY: height / image.height,
});
canvas.add(image);
canvas.sendObjectToBack(image);
canvas.renderAll();
historyRef.current = [];
historyIndexRef.current = -1;
pushHistory();
refreshHistoryControls();
refreshLayerRows();
const zoom = Math.max(0.12, Math.min(
1,
workspaceSize.width > 0 ? (workspaceSize.width - 56) / width : 1,
workspaceSize.height > 0 ? (workspaceSize.height - 56) / height : 1
));
applyDisplayZoom(zoom, { width, height });
} finally {
URL.revokeObjectURL(url);
}
}, [applyDisplayZoom, pushHistory, refreshHistoryControls, refreshLayerRows, workspaceSize.height, workspaceSize.width]);
const applyToActive = useCallback((props: Record<string, unknown>) => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
if (!canvas || !object) return;
object.set(props);
object.setCoords();
canvas.requestRenderAll();
updateActiveState();
pushHistory();
}, [pushHistory, updateActiveState]);
const addText = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const text = new Textbox("Edit text", {
left: 80,
top: 80,
width: 320,
fontSize: state.fontSize,
fontFamily,
fill: state.stroke,
backgroundColor: "rgba(255,255,255,0)",
});
canvas.add(text);
canvas.setActiveObject(text);
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [fontFamily, refreshLayerRows, state.fontSize, state.stroke, updateActiveState]);
const addShape = useCallback((kind: ShapeKind) => {
const canvas = canvasRef.current;
if (!canvas) return;
const common = {
left: 110,
top: 110,
fill: kind === "line" ? "" : state.fill,
stroke: state.stroke,
strokeWidth: 4,
};
const object =
kind === "rect"
? new Rect({ ...common, width: 180, height: 120, rx: 4, ry: 4 })
: kind === "circle"
? new Circle({ ...common, radius: 72 })
: kind === "triangle"
? new Triangle({ ...common, width: 160, height: 140 })
: new Line([120, 120, 320, 120], {
stroke: state.stroke,
strokeWidth: 5,
});
canvas.add(object);
canvas.setActiveObject(object);
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, state.fill, state.stroke, updateActiveState]);
const deleteActive = useCallback(() => {
const canvas = canvasRef.current;
const objects = canvas?.getActiveObjects() ?? [];
if (!canvas || objects.length === 0) return;
objects.forEach((object) => {
if (object.selectable !== false) canvas.remove(object);
});
canvas.discardActiveObject();
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, updateActiveState]);
const duplicateActive = useCallback(async () => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
if (!canvas || !object) return;
const clone = await object.clone();
clone.set({
left: (clone.left ?? 0) + 24,
top: (clone.top ?? 0) + 24,
evented: true,
});
canvas.add(clone);
canvas.setActiveObject(clone);
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, updateActiveState]);
const moveLayer = useCallback((direction: "forward" | "backward") => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
if (!canvas || !object) return;
if (direction === "forward") {
canvas.bringObjectForward(object);
} else {
canvas.sendObjectBackwards(object);
}
canvas.requestRenderAll();
pushHistory();
refreshLayerRows();
}, [pushHistory, refreshLayerRows]);
const clearObjects = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.getObjects().forEach((object) => {
if (object.selectable !== false) canvas.remove(object);
});
canvas.discardActiveObject();
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, updateActiveState]);
const exportImage = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const dataUrl = canvas.toDataURL({
format: exportFormat.replace("image/", "") as "png" | "jpeg" | "webp",
quality: quality / 100,
multiplier: 1,
});
await run(
{ files: sourceFileRef.current ? [sourceFileRef.current] : undefined },
{ format: exportFormat, quality, dataUrl }
);
}, [exportFormat, quality, run]);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTyping = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA";
if (isTyping) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
const nextCanRedo = historyIndexRef.current < historyRef.current.length - 1;
const nextCanUndo = historyIndexRef.current > 0;
if (event.shiftKey && nextCanRedo) {
void loadHistory(historyIndexRef.current + 1);
} else if (nextCanUndo) {
void loadHistory(historyIndexRef.current - 1);
}
return;
}
if ((event.key === "Delete" || event.key === "Backspace") && activeObject) {
event.preventDefault();
deleteActive();
}
}, [activeObject, deleteActive, loadHistory]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const handleWorkspacePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (state.mode !== "pan") return;
const workspace = workspaceRef.current;
if (!workspace) return;
isPanningRef.current = true;
panStartRef.current = {
x: event.clientX,
y: event.clientY,
scrollLeft: workspace.scrollLeft,
scrollTop: workspace.scrollTop,
};
workspace.setPointerCapture(event.pointerId);
}, [state.mode, workspaceRef]);
const handleWorkspacePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isPanningRef.current) return;
const workspace = workspaceRef.current;
if (!workspace) return;
const start = panStartRef.current;
workspace.scrollLeft = start.scrollLeft - (event.clientX - start.x);
workspace.scrollTop = start.scrollTop - (event.clientY - start.y);
}, [workspaceRef]);
const handleWorkspacePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isPanningRef.current) return;
isPanningRef.current = false;
workspaceRef.current?.releasePointerCapture(event.pointerId);
}, [workspaceRef]);
const handleWorkspaceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const delta = event.deltaY > 0 ? -0.08 : 0.08;
setCanvasZoom(state.zoom + delta);
}, [setCanvasZoom, state.zoom]);
return (
<div className="flex h-full min-h-[620px] flex-col bg-[var(--p-surface)]">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--p-border)] px-4 py-3">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple={false}
maxSizeMb={25}
onFilesDrop={(files) => {
if (files[0]) void loadImageFile(files[0]);
}}
className="min-h-0 w-full p-3 sm:w-[260px]"
/>
<div className="flex flex-wrap gap-2">
<Button
variant={state.mode === "select" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
>
Select
</Button>
<Button
variant={state.mode === "draw" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
>
Draw
</Button>
<Button
variant={state.mode === "pan" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
>
Pan
</Button>
<Button variant="secondary" onClick={addText}>Text</Button>
<Button variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
<Button variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
<Button variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
<Button variant="secondary" onClick={() => addShape("line")}>Line</Button>
</div>
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
-
</Button>
<button
onClick={fitCanvasToWorkspace}
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
>
{Math.round(state.zoom * 100)}%
</button>
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
+
</Button>
<Button variant="secondary" onClick={() => setCanvasZoom(1)}>
100%
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
<div
ref={workspaceRef}
onPointerDown={handleWorkspacePointerDown}
onPointerMove={handleWorkspacePointerMove}
onPointerUp={handleWorkspacePointerUp}
onPointerCancel={handleWorkspacePointerUp}
onWheel={handleWorkspaceWheel}
className={`min-h-[520px] overflow-auto bg-[var(--p-bg)] p-6 ${
state.mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
}`}
>
<div className="flex min-h-full min-w-max items-center justify-center">
<div
className="shrink-0 border border-[var(--p-border)] bg-white shadow-[0_24px_60px_-36px_var(--p-shadow-soft)]"
style={{
width: canvasSize.width * state.zoom,
height: canvasSize.height * state.zoom,
}}
>
<canvas ref={canvasElRef} />
</div>
</div>
</div>
<aside className="flex min-h-0 flex-col gap-4 overflow-y-auto border-l border-[var(--p-border)] bg-[var(--p-surface-2)] p-4">
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
properties
</div>
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
{state.selectedType}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Fill
<input
type="color"
value={state.fill}
onChange={(event) => {
setState((prev) => ({ ...prev, fill: event.target.value }));
applyToActive({ fill: event.target.value });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Stroke / text
<input
type="color"
value={state.stroke}
onChange={(event) => {
setState((prev) => ({ ...prev, stroke: event.target.value }));
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
</div>
<div className="mt-3 flex flex-col gap-3">
<Select
label="Font"
value={fontFamily}
options={FONT_OPTIONS}
onChange={(event) => {
setFontFamily(event.target.value);
applyToActive({ fontFamily: event.target.value });
}}
/>
<Slider
label="Text size"
min={12}
max={160}
value={state.fontSize}
onChange={(event) => {
const fontSize = Number(event.target.value);
setState((prev) => ({ ...prev, fontSize }));
applyToActive({ fontSize });
}}
/>
<Slider
label="Brush"
min={1}
max={48}
value={state.brushWidth}
onChange={(event) => {
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
}}
/>
</div>
</div>
</section>
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
arrange
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
</div>
<Button variant="secondary" onClick={clearObjects}>
Clear editable objects
</Button>
</section>
<section className="flex min-h-0 flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
layers
</div>
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
{layerRows.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
No editable layers yet
</div>
) : (
layerRows.map((row) => (
<button
key={row.id}
onClick={() => {
canvasRef.current?.setActiveObject(row.object);
canvasRef.current?.requestRenderAll();
updateActiveState();
}}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
activeObject === row.object
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "bg-[var(--p-chip)] text-[var(--p-text)]"
}`}
>
{row.label}
</button>
))
)}
</div>
</section>
<section className="mt-auto flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
export
</div>
<Select
label="Format"
value={exportFormat}
options={FORMAT_OPTIONS}
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
/>
<Slider
label="Quality"
min={10}
max={100}
value={quality}
onChange={(event) => setQuality(Number(event.target.value))}
/>
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
{isExecuting ? "Exporting..." : "Export image"}
</Button>
{sourceFile && (
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
source: {sourceFile.name}
</div>
)}
{error && <div className="text-sm text-red-700">{error}</div>}
{result && <ToolResultPanel result={result} />}
</section>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runImageEditor } from "./run";
export interface ImageEditorOptions {
format: "image/png" | "image/jpeg" | "image/webp";
quality: number;
dataUrl?: string;
}
const ImageEditorUi = lazy(() => import("./ImageEditorUi"));
export const imageEditorPlugin: PlimiPlugin<ImageEditorOptions> = {
manifest: {
id: "image-editor",
name: "Image Editor",
description:
"Edit images with text, drawing, shapes, colors, layers, and export locally.",
category: "image",
version: "1.0.0",
tags: ["image", "editor", "text", "draw", "shapes", "fabric"],
input: {
type: "files",
accept: ["image/jpeg", "image/png", "image/webp"],
multiple: false,
maxSizeMb: 25,
},
output: { type: "files" },
offlineReady: true,
},
capabilities: {
cancelable: false,
worker: false,
customUi: true,
preview: true,
},
permissions: {
network: "none",
fileSystem: "read-write",
clipboard: "none",
},
run: runImageEditor,
customUi: ImageEditorUi,
};

View File

@@ -0,0 +1,60 @@
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 { ImageEditorOptions } from "./index";
function dataUrlToBlob(dataUrl: string): Blob {
const [header, payload] = dataUrl.split(",");
const mimeMatch = header.match(/^data:(.*?);base64$/);
if (!mimeMatch || !payload) {
throw new Error("Invalid image export data.");
}
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mimeMatch[1] });
}
function extensionForMime(mimeType: ImageEditorOptions["format"]): string {
if (mimeType === "image/jpeg") return "jpg";
if (mimeType === "image/webp") return "webp";
return "png";
}
export async function runImageEditor(
input: ToolInput,
options: ImageEditorOptions,
context: ToolContext
): Promise<ToolResult> {
if (!options.dataUrl) {
throw new Error("No edited image is ready to export.");
}
context.reportProgress({ percentage: 60, message: "Preparing export..." });
const blob = dataUrlToBlob(options.dataUrl);
const sourceName = input.files?.[0]?.name ?? "image";
const baseName = sourceName.includes(".")
? sourceName.slice(0, sourceName.lastIndexOf("."))
: sourceName;
const name = `${baseName}_edited.${extensionForMime(options.format)}`;
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "files",
files: [
{
name,
mimeType: options.format,
blob,
sizeBefore: input.files?.[0]?.size,
sizeAfter: blob.size,
},
],
};
}

View File

@@ -0,0 +1,224 @@
import { useState, useCallback, useEffect } from "react";
import type { ToolUiProps } from "../../core/plugins/plugin-types";
import type { ImageOptimizerOptions } from "./index";
import { Button } from "../../components/ui/Button";
import { Card } from "../../components/ui/Card";
import { Dropzone } from "../../components/ui/Dropzone";
import { Slider } from "../../components/ui/Slider";
import { Select } from "../../components/ui/Select";
import { useToolExecution } from "../../components/tool/useToolExecution";
const IMAGE_FORMATS = ["image/jpeg", "image/webp", "image/png"] as const;
type ImageFormat = (typeof IMAGE_FORMATS)[number];
function isImageFormat(value: string): value is ImageFormat {
return IMAGE_FORMATS.includes(value as ImageFormat);
}
export default function ImageOptimizerUi({
plugin,
}: ToolUiProps<ImageOptimizerOptions>) {
const [files, setFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [quality, setQuality] = useState(80);
const [format, setFormat] = useState<
"image/jpeg" | "image/webp" | "image/png"
>("image/webp");
const { run, result, isExecuting, error, reset } = useToolExecution(plugin);
useEffect(() => {
return () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [previewUrls]);
const handleFilesDrop = useCallback((nextFiles: File[]) => {
setFiles(nextFiles);
setPreviewUrls(nextFiles.map((file) => URL.createObjectURL(file)));
reset();
}, [reset]);
const handleRun = useCallback(async () => {
if (files.length === 0) return;
await run({ files }, { quality, format });
}, [files, format, quality, run]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!isExecuting && files.length > 0) {
handleRun();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleRun, isExecuting, files]);
const downloadBlob = useCallback((blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
return (
<div className="p-[18px] md:p-[28px] max-w-5xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<Card title="Upload Images">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple
maxSizeMb={20}
onFilesDrop={handleFilesDrop}
/>
{previewUrls.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 mb-3">
Previews:
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{previewUrls.map((url, i) => (
<div
key={i}
className="relative aspect-square rounded-lg overflow-hidden border border-gray-200"
>
<img
src={url}
alt="Preview"
className="object-cover w-full h-full"
/>
</div>
))}
</div>
</div>
)}
</Card>
{error && (
<Card className="border-red-200">
<div className="text-sm text-red-700">{error}</div>
</Card>
)}
{result && result.type === "files" && (
<Card
title="Optimized Results"
className="border-green-200 shadow-md"
>
{result.files.length > 1 && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 pb-4 border-b border-gray-100">
<span className="text-sm text-gray-500 font-medium">
Successfully optimized {result.files.length} images
</span>
<Button
variant="primary"
onClick={() => {
result.files.forEach((file) => {
downloadBlob(file.blob, file.name);
});
}}
className="w-full sm:w-auto shrink-0"
>
Download All ({result.files.length})
</Button>
</div>
)}
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg">
{result.files.map((file, i) => {
const savedBytes =
(file.sizeBefore || 0) - (file.sizeAfter || 0);
const savedPercentage = file.sizeBefore
? (savedBytes / file.sizeBefore) * 100
: 0;
return (
<li
key={i}
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-gray-50 transition-colors"
>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-gray-900 truncate" title={file.name}>
{file.name}
</span>
<div className="text-sm text-gray-500 mt-1 flex flex-wrap items-center gap-2">
<span className="line-through">
{((file.sizeBefore || 0) / 1024).toFixed(1)} KB
</span>
<span className="text-green-600 font-medium">
{((file.sizeAfter || 0) / 1024).toFixed(1)} KB
</span>
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
-{savedPercentage.toFixed(0)}%
</span>
</div>
</div>
<Button
variant="secondary"
onClick={() => downloadBlob(file.blob, file.name)}
className="w-full sm:w-auto shrink-0 text-center"
>
Download
</Button>
</li>
);
})}
</ul>
</Card>
)}
</div>
<div className="space-y-6">
<Card title="Settings">
<div className="space-y-6">
<Select
label="Output Format"
value={format}
onChange={(e) => {
if (isImageFormat(e.target.value)) {
setFormat(e.target.value);
}
}}
options={[
{ label: "WebP (Recommended)", value: "image/webp" },
{ label: "JPEG", value: "image/jpeg" },
{ label: "PNG", value: "image/png" },
]}
/>
<Slider
label="Quality"
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
min={1}
max={100}
/>
<div className="pt-4 border-t border-gray-100">
<Button
onClick={handleRun}
disabled={isExecuting || files.length === 0}
className="w-full"
>
{isExecuting ? "Optimizing..." : "Optimize Images"}
</Button>
</div>
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runImageOptimizer } from "./run";
export interface ImageOptimizerOptions {
quality: number;
format: "image/jpeg" | "image/webp" | "image/png";
}
// Lazy load the custom UI to keep the main bundle small
const ImageOptimizerUi = lazy(() => import("./ImageOptimizerUi"));
export const imageOptimizerPlugin: PlimiPlugin<ImageOptimizerOptions> = {
manifest: {
id: "image-optimizer",
name: "Image Optimizer",
description:
"Compress and convert images entirely in your browser using the Canvas API.",
category: "image",
version: "1.0.0",
tags: ["image", "compress", "resize", "webp", "jpeg"],
input: {
type: "files",
accept: ["image/jpeg", "image/png", "image/webp"],
multiple: true,
maxSizeMb: 20,
},
output: { type: "files" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "slider",
key: "quality",
label: "Quality",
defaultValue: 80,
min: 1,
max: 100,
step: 1,
},
{
type: "select",
key: "format",
label: "Output Format",
defaultValue: "image/webp",
options: [
{ label: "WebP", value: "image/webp" },
{ label: "JPEG", value: "image/jpeg" },
{ label: "PNG", value: "image/png" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
customUi: true,
},
run: runImageOptimizer,
// Provide the custom UI
customUi: ImageOptimizerUi,
};

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { runImageOptimizer } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Image Optimizer Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
it("should throw error if no files provided", async () => {
await expect(
runImageOptimizer(
{ files: [] },
{ quality: 80, format: "image/webp" },
mockContext
)
).rejects.toThrow("No files provided for optimization.");
});
it("should throw error if file is not an image", async () => {
const textFile = new File(["hello"], "hello.txt", { type: "text/plain" });
await expect(
runImageOptimizer(
{ files: [textFile] },
{ quality: 80, format: "image/webp" },
mockContext
)
).rejects.toThrow("File hello.txt is not an image.");
});
// Note: Testing actual Canvas API compression requires a full browser environment
// or a mock canvas library in Vitest. For V1 MVP, we test the core validation logic.
});

View File

@@ -0,0 +1,108 @@
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 { ImageOptimizerOptions } from "./index";
export async function runImageOptimizer(
input: ToolInput,
options: ImageOptimizerOptions,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || files.length === 0) {
throw new Error("No files provided for optimization.");
}
const results = [];
let processed = 0;
for (const file of files) {
// Check if it's an image
if (!file.type.startsWith("image/")) {
throw new Error(`File ${file.name} is not an image.`);
}
context.reportProgress({
percentage: (processed / files.length) * 100,
message: `Optimizing ${file.name}...`,
});
const optimizedBlob = await optimizeImage(file, options);
let ext = "jpg";
if (options.format === "image/webp") ext = "webp";
if (options.format === "image/png") ext = "png";
const originalNameWithoutExt =
file.name.substring(0, file.name.lastIndexOf(".")) || file.name;
const newName = `${originalNameWithoutExt}_optimized.${ext}`;
results.push({
name: newName,
mimeType: options.format,
blob: optimizedBlob,
sizeBefore: file.size,
sizeAfter: optimizedBlob.size,
});
processed++;
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "files",
files: results,
};
}
function optimizeImage(
file: File,
options: ImageOptimizerOptions
): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
const width = img.width;
const height = img.height;
// Optional: resize logic could go here based on max width/height options
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
return reject(new Error("Could not get 2D context"));
}
ctx.drawImage(img, 0, 0, width, height);
const quality = options.quality / 100;
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Failed to create blob from canvas"));
}
},
options.format,
quality
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image for optimization"));
};
img.src = url;
});
}

View File

@@ -0,0 +1,304 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { ToolUiProps } from "../../core/plugins/plugin-types";
import type { ImageRedactorOptions, RedactRect } from "./index";
import { Button } from "../../components/ui/Button";
import { Card } from "../../components/ui/Card";
import { Dropzone } from "../../components/ui/Dropzone";
import { useToolExecution } from "../../components/tool/useToolExecution";
export default function ImageRedactorUi({
plugin,
}: ToolUiProps<ImageRedactorOptions>) {
const [files, setFiles] = useState<File[]>([]);
const [previewUrl, setPreviewUrl] = useState<string>("");
const [rects, setRects] = useState<RedactRect[]>([]);
// Drawing state
const [isDrawing, setIsDrawing] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [currentPos, setCurrentPos] = useState({ x: 0, y: 0 });
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const { run, result, isExecuting, error, reset } = useToolExecution(plugin);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!previewUrl) return;
return () => {
URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const handleFilesDrop = useCallback((nextFiles: File[]) => {
setFiles(nextFiles);
setPreviewUrl(nextFiles.length > 0 ? URL.createObjectURL(nextFiles[0]) : "");
setRects([]);
reset();
}, [reset]);
const handleClearFiles = useCallback(() => {
setFiles([]);
setPreviewUrl("");
setRects([]);
reset();
}, [reset]);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageSize({ width: img.naturalWidth, height: img.naturalHeight });
};
const getPercentageCoords = (e: React.PointerEvent<HTMLDivElement>) => {
if (!containerRef.current) return { x: 0, y: 0 };
const rect = containerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Clamp to 0-100 range
return {
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
};
};
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (result) return; // Disable drawing if result is shown
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const coords = getPercentageCoords(e);
setStartPos(coords);
setCurrentPos(coords);
setIsDrawing(true);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDrawing) return;
const coords = getPercentageCoords(e);
setCurrentPos(coords);
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDrawing) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
setIsDrawing(false);
const coords = getPercentageCoords(e);
const x = Math.min(startPos.x, coords.x);
const y = Math.min(startPos.y, coords.y);
const w = Math.abs(coords.x - startPos.x);
const h = Math.abs(coords.y - startPos.y);
// Save rectangle if it has some minimum size to avoid tiny single click boxes
if (w > 0.4 && h > 0.4) {
setRects((prev) => [...prev, { x, y, w, h }]);
}
};
const handleUndo = () => {
setRects((prev) => prev.slice(0, -1));
};
const handleClear = () => {
setRects([]);
};
const handleRun = useCallback(async () => {
if (files.length === 0) return;
await run({ files }, { rects, color: "#000000" });
}, [files, rects, run]);
const downloadBlob = useCallback((blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
// Hotkeys: Ctrl+Enter to redact
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!isExecuting && files.length > 0 && !result) {
handleRun();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [files.length, handleRun, isExecuting, result]);
// Temporary rectangle styles (while drawing)
const tempRectStyle = isDrawing
? {
left: `${Math.min(startPos.x, currentPos.x)}%`,
top: `${Math.min(startPos.y, currentPos.y)}%`,
width: `${Math.abs(currentPos.x - startPos.x)}%`,
height: `${Math.abs(currentPos.y - startPos.y)}%`,
}
: null;
return (
<div className="p-[18px] md:p-[28px] max-w-5xl mx-auto">
{files.length === 0 ? (
<Card title="Upload Image to Redact">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple={false}
onFilesDrop={handleFilesDrop}
/>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<Card title="Redaction Canvas">
<div className="text-xs text-gray-400 mb-3">
Pointer drawing active. Click and drag to place black redaction blocks.
</div>
{/* Bounding box wrapper */}
<div
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className="relative select-none overflow-hidden cursor-crosshair border border-[var(--p-border)] rounded-lg bg-[var(--p-bg)] max-w-full flex items-center justify-center"
style={{ touchAction: "none" }}
>
<img
src={previewUrl}
alt="Redaction source"
onLoad={handleImageLoad}
onDragStart={(e) => e.preventDefault()}
className="max-w-full max-h-[60vh] object-contain pointer-events-none"
/>
{/* Draw saved rectangles */}
{rects.map((r, i) => (
<div
key={i}
className="absolute bg-black pointer-events-none"
style={{
left: `${r.x}%`,
top: `${r.y}%`,
width: `${r.w}%`,
height: `${r.h}%`,
boxShadow: "0 0 0 1px rgba(255, 255, 255, 0.15)",
}}
/>
))}
{/* Draw active temporary rectangle */}
{tempRectStyle && (
<div
className="absolute bg-black/60 border-2 border-red-500 border-dashed pointer-events-none"
style={tempRectStyle}
/>
)}
</div>
</Card>
{error && (
<Card className="border-red-200">
<div className="text-sm text-red-700">{error}</div>
</Card>
)}
{result && result.type === "files" && (
<Card
title="Redacted Result"
className="border-green-200 shadow-md"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 border border-green-200 bg-green-50/50 rounded-xl">
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-green-950 truncate" title={result.files[0].name}>
{result.files[0].name}
</span>
<span className="text-xs text-green-700 mt-1">
Metadata flattened & redacted successfully.
</span>
</div>
<Button
variant="primary"
onClick={() => downloadBlob(result.files[0].blob, result.files[0].name)}
className="w-full sm:w-auto shrink-0"
>
Download Redacted Image
</Button>
</div>
</Card>
)}
</div>
<div className="space-y-6">
<Card title="Controls">
<div className="space-y-6">
<div>
<div className="text-sm font-semibold text-gray-800">
File details
</div>
<div className="text-xs text-gray-500 mt-1 truncate">
Name: {files[0].name}
</div>
{imageSize.width > 0 && (
<div className="text-xs text-gray-500">
Dimensions: {imageSize.width} &times; {imageSize.height} px
</div>
)}
<div className="text-xs text-gray-500">
Active redactions: {rects.length}
</div>
</div>
<div className="flex flex-col gap-2">
<Button
variant="secondary"
onClick={handleUndo}
disabled={rects.length === 0 || isExecuting}
className="w-full justify-center"
>
Undo Last Block
</Button>
<Button
variant="secondary"
onClick={handleClear}
disabled={rects.length === 0 || isExecuting}
className="w-full justify-center text-red-600 hover:text-red-700 hover:bg-red-50"
>
Clear All Blocks
</Button>
</div>
<div className="pt-4 border-t border-gray-100 flex flex-col gap-2">
<Button
variant="primary"
onClick={handleRun}
disabled={isExecuting || rects.length === 0 || result !== null}
className="w-full justify-center"
>
{isExecuting ? "Processing..." : "Apply Redactions"}
</Button>
<Button
variant="secondary"
onClick={handleClearFiles}
disabled={isExecuting}
className="w-full justify-center"
>
Upload Another Image
</Button>
</div>
</div>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runImageRedactor } from "./run";
export interface RedactRect {
x: number; // Absolute X coordinate in image natural pixels
y: number; // Absolute Y coordinate in image natural pixels
w: number; // Absolute width in image natural pixels
h: number; // Absolute height in image natural pixels
}
export interface ImageRedactorOptions {
rects: RedactRect[];
color: string;
}
const ImageRedactorUi = lazy(() => import("./ImageRedactorUi"));
export const imageRedactorPlugin: PlimiPlugin<ImageRedactorOptions> = {
manifest: {
id: "image-redactor",
name: "Image Redactor",
description: "Safely paint black boxes over sensitive data like addresses or credit cards entirely in your browser.",
category: "privacy",
version: "1.0.0",
tags: ["privacy", "redact", "hide", "censor", "blur", "image"],
input: {
type: "files",
accept: ["image/jpeg", "image/png", "image/webp"],
multiple: false,
},
output: { type: "files" },
offlineReady: true,
},
capabilities: {
cancelable: false,
worker: false,
customUi: true,
},
run: runImageRedactor,
customUi: ImageRedactorUi,
};

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from "vitest";
import { runImageRedactor } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Image Redactor Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
it("should throw error if no files provided", async () => {
await expect(
runImageRedactor(
{ files: [] },
{ rects: [], color: "#000000" },
mockContext
)
).rejects.toThrow("No image file uploaded.");
});
});

View File

@@ -0,0 +1,106 @@
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 { ImageRedactorOptions } from "./index";
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Failed to load image."));
img.src = url;
});
}
function canvasToBlob(
canvas: HTMLCanvasElement,
mimeType: string,
quality = 0.95
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Failed to export image from canvas."));
}
},
mimeType,
quality
);
});
}
export async function runImageRedactor(
input: ToolInput,
options: ImageRedactorOptions,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
throw new Error("No image file uploaded.");
}
const file = files[0];
const url = URL.createObjectURL(file);
try {
context.reportProgress({ percentage: 20, message: "Loading image file..." });
const img = await loadImage(url);
context.reportProgress({ percentage: 50, message: "Drawing base canvas..." });
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not acquire 2D canvas context.");
}
// Draw original image
ctx.drawImage(img, 0, 0);
// Apply redactions
context.reportProgress({ percentage: 70, message: "Applying redaction blocks..." });
ctx.fillStyle = options.color || "#000000";
const rects = options.rects || [];
for (const rect of rects) {
const rx = (rect.x / 100) * img.naturalWidth;
const ry = (rect.y / 100) * img.naturalHeight;
const rw = (rect.w / 100) * img.naturalWidth;
const rh = (rect.h / 100) * img.naturalHeight;
ctx.fillRect(rx, ry, rw, rh);
}
context.reportProgress({ percentage: 90, message: "Exporting redacted image..." });
const mimeType = file.type || "image/png";
const blob = await canvasToBlob(canvas, mimeType);
// Generate output file name
const dotIdx = file.name.lastIndexOf(".");
const name = dotIdx !== -1
? `${file.name.substring(0, dotIdx)}_redacted${file.name.substring(dotIdx)}`
: `${file.name}_redacted.png`;
context.reportProgress({ percentage: 100, message: "Completed!" });
return {
type: "files",
files: [
{
name: name,
mimeType: mimeType,
blob: blob,
sizeAfter: blob.size,
},
],
};
} catch (error) {
console.error("Image Redaction Error:", error);
throw error instanceof Error ? error : new Error("Unknown error occurred during image redaction");
} finally {
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,45 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runJsonFormatter } from "./run";
export interface JsonOptions {
indent: "2 spaces" | "4 spaces" | "tabs" | "minified";
}
export const jsonFormatterPlugin: PlimiPlugin<JsonOptions> = {
manifest: {
id: "dev-json",
name: "JSON Formatter",
description: "Format, minify, and validate JSON strings.",
category: "developer",
version: "1.0.0",
tags: ["json", "format", "minify", "validate"],
input: { type: "text" },
output: { type: "text" },
example: '{"name":"Plimi","version":1,"tools":["base64","hash"],"offline":true}',
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "indent",
label: "Indent",
defaultValue: "2 spaces",
options: [
{ label: "2 Spaces", value: "2 spaces" },
{ label: "4 Spaces", value: "4 spaces" },
{ label: "Tabs", value: "tabs" },
{ label: "Minified", value: "minified" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runJsonFormatter,
};

View File

@@ -0,0 +1,35 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { JsonOptions } from "./index";
export async function runJsonFormatter(
input: ToolInput,
options: JsonOptions
): Promise<ToolResult> {
const text = input.text || "";
if (!text) {
throw new Error("No text provided.");
}
let indentSpace: string | number = 2;
if (options.indent === "4 spaces") {
indentSpace = 4;
} else if (options.indent === "tabs") {
indentSpace = "\t";
} else if (options.indent === "minified") {
indentSpace = 0;
}
try {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, indentSpace);
return {
type: "text",
value: formatted,
};
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`Invalid JSON: ${message}`, { cause });
}
}

View File

@@ -0,0 +1,30 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runJwtDecoder } from "./run";
export const jwtDecoderPlugin: PlimiPlugin = {
manifest: {
id: "jwt-decoder",
name: "JWT Decoder",
description: "Decode and inspect JSON Web Tokens (JWT) locally in your browser.",
category: "developer",
version: "1.0.0",
tags: ["jwt", "token", "decoder", "auth", "json"],
input: {
type: "text",
label: "JWT Token",
placeholder: "Paste your JWT token here (encoded header.payload.signature)...",
multiline: true,
rows: 6,
},
output: { type: "json" },
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE4MTYyMzkwMjJ9.signature-placeholder",
offlineReady: true,
},
capabilities: {
cancelable: false,
worker: false,
},
run: runJwtDecoder,
};

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from "vitest";
import { runJwtDecoder } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("JWT Decoder Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
// {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// {"sub":"123","name":"John Doe","iat":1516239022} -> eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9
const validNoExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.sig";
// {"sub":"123","exp":32503680000} (Year 3000) -> eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ
const futureExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ.sig";
// {"sub":"123","exp":946684800} (Year 2000) -> eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0
const expiredExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0.sig";
it("should decode a valid JWT without exp claim", async () => {
const result = await runJwtDecoder({ text: validNoExp }, {}, mockContext);
expect(result.type).toBe("json");
if (result.type === "json") {
const data = result.value as any;
expect(data.status).toBe("valid");
expect(data.primary).toContain("Valid (No Expiration claim)");
expect(data.header.alg).toBe("HS256");
expect(data.payload.sub).toBe("123");
expect(data.issuedAt).toBe("2018-01-18T01:30:22.000Z");
expect(data.expiresAt).toBeNull();
}
});
it("should decode a valid JWT with future exp claim", async () => {
const result = await runJwtDecoder({ text: futureExp }, {}, mockContext);
expect(result.type).toBe("json");
if (result.type === "json") {
const data = result.value as any;
expect(data.status).toBe("valid");
expect(data.primary).toContain("Valid (Expires:");
expect(data.expiresAt).toBe("3000-01-01T00:00:00.000Z");
}
});
it("should decode an expired JWT with past exp claim", async () => {
const result = await runJwtDecoder({ text: expiredExp }, {}, mockContext);
expect(result.type).toBe("json");
if (result.type === "json") {
const data = result.value as any;
expect(data.status).toBe("expired");
expect(data.primary).toContain("Expired (at");
expect(data.expiresAt).toBe("2000-01-01T00:00:00.000Z");
}
});
it("should throw error for empty token", async () => {
await expect(
runJwtDecoder({ text: "" }, {}, mockContext)
).rejects.toThrow("Please enter a JWT token to decode.");
});
it("should throw error for malformed token format", async () => {
await expect(
runJwtDecoder({ text: "one.two" }, {}, mockContext)
).rejects.toThrow("A JWT must consist of three parts");
});
it("should throw error for invalid JSON payload", async () => {
// Header ok, payload invalid base64/json: "invalid" -> aW52YWxpZA==
const invalidJson = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aW52YWxpZA.sig";
await expect(
runJwtDecoder({ text: invalidJson }, {}, mockContext)
).rejects.toThrow("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
});
});

View File

@@ -0,0 +1,92 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { ToolContext } from "../../core/plugins/plugin-types";
function base64urlDecode(str: string): string {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const paddingNeeded = (4 - (base64.length % 4)) % 4;
const padded = base64 + "=".repeat(paddingNeeded);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
export async function runJwtDecoder(
input: ToolInput,
_options: unknown,
context: ToolContext
): Promise<ToolResult> {
const token = (input.text ?? "").trim();
if (!token) {
throw new Error("Please enter a JWT token to decode.");
}
context.reportProgress({ percentage: 20, message: "Parsing token parts..." });
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT token format. A JWT must consist of three parts (header, payload, signature) separated by dots.");
}
const [headerB64, payloadB64, signatureHex] = parts;
context.reportProgress({ percentage: 50, message: "Decoding JSON content..." });
let header: Record<string, unknown>;
let payload: Record<string, unknown>;
try {
const decodedHeader = base64urlDecode(headerB64);
header = JSON.parse(decodedHeader);
} catch (err) {
throw new Error("Failed to decode JWT Header: invalid Base64URL encoding or malformed JSON content.");
}
try {
const decodedPayload = base64urlDecode(payloadB64);
payload = JSON.parse(decodedPayload);
} catch (err) {
throw new Error("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
}
context.reportProgress({ percentage: 85, message: "Checking claims..." });
let expiresAt: string | null = null;
let issuedAt: string | null = null;
let status: "valid" | "expired" = "valid";
let primary = "Valid (No Expiration claim)";
if (typeof payload.exp === "number") {
const expTime = payload.exp * 1000;
expiresAt = new Date(expTime).toISOString();
if (Date.now() > expTime) {
status = "expired";
primary = `Expired (at ${expiresAt})`;
} else {
primary = `Valid (Expires: ${expiresAt})`;
}
}
if (typeof payload.iat === "number") {
issuedAt = new Date(payload.iat * 1000).toISOString();
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "json",
value: {
primary,
status,
expiresAt,
issuedAt,
header,
payload,
signature: signatureHex || "(empty)",
},
};
}

View File

@@ -0,0 +1,54 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runLoremIpsum } from "./run";
export interface LoremIpsumOptions {
type: "paragraphs" | "sentences" | "words";
count: number;
}
export const loremIpsumPlugin: PlimiPlugin<LoremIpsumOptions> = {
manifest: {
id: "txt-lorem",
name: "Lorem Ipsum Generator",
description: "Generate placeholder text in paragraphs, sentences, or words.",
category: "text",
version: "1.0.0",
tags: ["lorem", "ipsum", "placeholder", "text"],
input: { type: "none" },
output: { type: "text" },
example: "Click 'Try example' to generate placeholder text.",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "type",
label: "Unit",
defaultValue: "paragraphs",
options: [
{ label: "Paragraphs", value: "paragraphs" },
{ label: "Sentences", value: "sentences" },
{ label: "Words", value: "words" },
],
},
{
type: "slider",
key: "count",
label: "Count",
defaultValue: 3,
min: 1,
max: 50,
step: 1,
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runLoremIpsum,
};

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from "vitest";
import { runLoremIpsum } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
import type { LoremIpsumOptions } from "./index";
describe("Lorem Ipsum Generator", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should generate the requested number of words", async () => {
const result = await runLoremIpsum(
{},
{ type: "words", count: 5 },
mockContext
);
expect(result.type).toBe("text");
const words = (result as { type: "text"; value: string }).value.split(" ");
expect(words).toHaveLength(5);
words.forEach((w) => expect(w.length).toBeGreaterThan(0));
});
it("should generate the requested number of sentences", async () => {
const result = await runLoremIpsum(
{},
{ type: "sentences", count: 3 },
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
const sentences = value.split(". ").filter((s) => s.trim().length > 0);
expect(sentences).toHaveLength(3);
});
it("should generate the requested number of paragraphs", async () => {
const result = await runLoremIpsum(
{},
{ type: "paragraphs", count: 4 },
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
const paragraphs = value.split("\n\n").filter((p) => p.trim().length > 0);
expect(paragraphs).toHaveLength(4);
});
it("should start sentences with a capital letter", async () => {
const result = await runLoremIpsum(
{},
{ type: "sentences", count: 5 },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
const sentences = value.split(". ").filter((s) => s.trim().length > 0);
sentences.forEach((s) => {
const trimmed = s.trim();
expect(trimmed[0]).toBe(trimmed[0].toUpperCase());
});
});
it("should end sentences with a period", async () => {
const result = await runLoremIpsum(
{},
{ type: "sentences", count: 3 },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value.endsWith(".")).toBe(true);
});
it("should default to paragraphs when type is unrecognized", async () => {
const result = await runLoremIpsum(
{},
{ type: "unknown", count: 2 } as unknown as LoremIpsumOptions,
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,89 @@
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 { LoremIpsumOptions } from "./index";
const LOREM_WORDS = [
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
"consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate",
"velit", "esse", "cillum", "fugiat", "nulla", "pariatur", "excepteur", "sint",
"occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui", "officia",
"deserunt", "mollit", "anim", "id", "est", "laborum", "perspiciatis", "unde",
"omnis", "iste", "natus", "error", "voluptatem", "accusantium", "doloremque",
"laudantium", "totam", "rem", "aperiam", "eaque", "ipsa", "quae", "ab", "illo",
"inventore", "veritatis", "quasi", "architecto", "beatae", "vitae", "dicta",
"explicabo", "nemo", "ipsam", "quia", "voluptas", "aspernatur", "aut", "odit",
"fugit", "consequuntur", "magni", "dolores", "eos", "ratione", "sequi", "nesciunt",
];
function randomWord(): string {
return LOREM_WORDS[Math.floor(Math.random() * LOREM_WORDS.length)];
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function generateSentence(wordCount: number): string {
const count = wordCount || (Math.floor(Math.random() * 10) + 6);
const words: string[] = [];
for (let i = 0; i < count; i++) {
words.push(randomWord());
}
words[0] = capitalize(words[0]);
return words.join(" ") + ".";
}
function generateParagraph(sentenceCount: number): string {
const count = sentenceCount || (Math.floor(Math.random() * 4) + 4);
const sentences: string[] = [];
for (let i = 0; i < count; i++) {
sentences.push(generateSentence(0));
}
return sentences.join(" ");
}
export async function runLoremIpsum(
_input: ToolInput,
options: LoremIpsumOptions,
context?: ToolContext
): Promise<ToolResult> {
void context;
const count = options.count || 3;
const result = (() => {
switch (options.type) {
case "words": {
const words: string[] = [];
for (let i = 0; i < count; i++) {
words.push(randomWord());
}
return words.join(" ");
}
case "sentences": {
const sentences: string[] = [];
for (let i = 0; i < count; i++) {
sentences.push(generateSentence(0));
}
return sentences.join(" ");
}
case "paragraphs":
default: {
const paragraphs: string[] = [];
for (let i = 0; i < count; i++) {
paragraphs.push(generateParagraph(0));
}
return paragraphs.join("\n\n");
}
}
})();
return {
type: "text",
value: result,
};
}

View File

@@ -0,0 +1,32 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runMarkdownToHtml } from "./run";
export interface MarkdownToHtmlOptions {
headingStyle?: "atx" | "setext";
}
export const markdownToHtmlPlugin: PlimiPlugin<MarkdownToHtmlOptions> = {
manifest: {
id: "txt-markdown",
name: "Markdown to HTML",
description: "Convert Markdown text to raw HTML without sending anything to a server.",
category: "text",
version: "1.0.0",
tags: ["markdown", "html", "convert", "md"],
input: { type: "text", placeholder: "Type or paste Markdown here..." },
output: { type: "text" },
example: "# Hello World\n\nThis is **bold** and *italic*.\n\n- Item 1\n- Item 2\n\n[Visit Plimi](https://plimi.app)",
offlineReady: true,
},
optionsSchema: {
fields: [],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runMarkdownToHtml,
};

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from "vitest";
import { runMarkdownToHtml } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Markdown to HTML Converter", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should convert headings h1, h2, h3", async () => {
const result = await runMarkdownToHtml(
{ text: "# Hello\n## World\n### Sub" },
{},
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<h1>Hello</h1>");
expect(value).toContain("<h2>World</h2>");
expect(value).toContain("<h3>Sub</h3>");
});
it("should convert bold and italic text", async () => {
const result = await runMarkdownToHtml(
{ text: "This is **bold** and *italic* and ***both***" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<strong>bold</strong>");
expect(value).toContain("<em>italic</em>");
expect(value).toContain("<strong><em>both</em></strong>");
});
it("should convert inline code", async () => {
const result = await runMarkdownToHtml(
{ text: "Use `console.log` to debug" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<code>console.log</code>");
});
it("should convert links", async () => {
const result = await runMarkdownToHtml(
{ text: "Visit [Google](https://google.com)" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain('<a href="https://google.com">Google</a>');
});
it("should convert images", async () => {
const result = await runMarkdownToHtml(
{ text: "![Alt text](image.png)" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain('<img src="image.png" alt="Alt text">');
});
it("should convert horizontal rules", async () => {
const result = await runMarkdownToHtml(
{ text: "Above\n---\nBelow" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<hr>");
});
it("should convert unordered lists", async () => {
const result = await runMarkdownToHtml(
{ text: "- item 1\n- item 2\n- item 3" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<ul>");
expect(value).toContain("<li>item 1</li>");
expect(value).toContain("<li>item 2</li>");
expect(value).toContain("<li>item 3</li>");
expect(value).toContain("</ul>");
});
it("should convert strikethrough", async () => {
const result = await runMarkdownToHtml(
{ text: "This is ~~deleted~~ text" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<del>deleted</del>");
});
it("should throw on empty input", async () => {
await expect(
runMarkdownToHtml({ text: "" }, {}, mockContext)
).rejects.toThrow("No Markdown text provided");
});
});

View File

@@ -0,0 +1,65 @@
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 { MarkdownToHtmlOptions } from "./index";
function convertMarkdown(md: string): string {
let html = md;
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
html = html.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
html = html.replace(/^>\s(.+)$/gm, "<blockquote>$1</blockquote>");
html = html.replace(/^---$/gm, "<hr>");
html = html.replace(/^\s*[-*+]\s(.+)$/gm, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>\n${match}</ul>\n`);
html = html.replace(/^\s*\d+\.\s(.+)$/gm, "<li>$1</li>");
html = html.replace(/\n{2,}/g, "\n</p>\n<p>\n");
html = `<p>${html}</p>`;
html = html.replace(/<p>\s*<(h[1-6]|ul|ol|blockquote|hr)/g, "<$1");
html = html.replace(/<\/(h[1-6]|ul|ol|blockquote|hr)>\s*<\/p>/g, "</$1>");
html = html.replace(/<p>\s*<\/p>/g, "");
return html.trim();
}
export async function runMarkdownToHtml(
input: ToolInput,
_options: MarkdownToHtmlOptions,
context: ToolContext
): Promise<ToolResult> {
const text = input.text || "";
if (!text.trim()) {
throw new Error("No Markdown text provided.");
}
context.reportProgress({ percentage: 50, message: "Converting..." });
const html = convertMarkdown(text);
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "text",
value: html,
language: "html",
};
}

View File

@@ -0,0 +1,58 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runNumberBase } from "./run";
export interface NumberBaseOptions {
fromBase: "2" | "8" | "10" | "16";
toBase: "2" | "8" | "10" | "16";
}
export const numberBasePlugin: PlimiPlugin<NumberBaseOptions> = {
manifest: {
id: "dev-numbase",
name: "Number Base Converter",
description: "Convert numbers between binary, octal, decimal, and hexadecimal.",
category: "developer",
version: "1.0.0",
tags: ["number", "binary", "hex", "decimal", "octal", "base"],
input: { type: "text", placeholder: "Enter a number...", multiline: false },
output: { type: "json" },
example: "255",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "fromBase",
label: "From Base",
defaultValue: "10",
options: [
{ label: "Binary (2)", value: "2" },
{ label: "Octal (8)", value: "8" },
{ label: "Decimal (10)", value: "10" },
{ label: "Hexadecimal (16)", value: "16" },
],
},
{
type: "select",
key: "toBase",
label: "To Base",
defaultValue: "16",
options: [
{ label: "Binary (2)", value: "2" },
{ label: "Octal (8)", value: "8" },
{ label: "Decimal (10)", value: "10" },
{ label: "Hexadecimal (16)", value: "16" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runNumberBase,
};

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi } from "vitest";
import { runNumberBase } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Number Base Converter", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should convert decimal to hexadecimal", async () => {
const result = await runNumberBase(
{ text: "255" },
{ fromBase: "10", toBase: "16" },
mockContext
);
expect(result.type).toBe("json");
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.decimal).toBe("255");
expect(value.hexadecimal).toBe("0xFF");
expect(value.primary).toBe("0xFF");
});
it("should convert hexadecimal to decimal", async () => {
const result = await runNumberBase(
{ text: "0xFF" },
{ fromBase: "16", toBase: "10" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.decimal).toBe("255");
expect(value.primary).toBe("255");
});
it("should convert decimal to binary", async () => {
const result = await runNumberBase(
{ text: "10" },
{ fromBase: "10", toBase: "2" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.binary).toBe("0b1010");
expect(value.primary).toBe("0b1010");
});
it("should convert binary to decimal", async () => {
const result = await runNumberBase(
{ text: "1010" },
{ fromBase: "2", toBase: "10" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.decimal).toBe("10");
});
it("should convert decimal to octal", async () => {
const result = await runNumberBase(
{ text: "64" },
{ fromBase: "10", toBase: "8" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.octal).toBe("0o100");
});
it("should return all base representations in every conversion", async () => {
const result = await runNumberBase(
{ text: "42" },
{ fromBase: "10", toBase: "16" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.binary).toBeDefined();
expect(value.octal).toBeDefined();
expect(value.decimal).toBeDefined();
expect(value.hexadecimal).toBeDefined();
});
it("should throw on empty input", async () => {
await expect(
runNumberBase({ text: "" }, { fromBase: "10", toBase: "16" }, mockContext)
).rejects.toThrow("No number provided");
});
it("should throw on invalid number for given base", async () => {
await expect(
runNumberBase({ text: "xyz" }, { fromBase: "10", toBase: "16" }, mockContext)
).rejects.toThrow();
});
it("should handle zero", async () => {
const result = await runNumberBase(
{ text: "0" },
{ fromBase: "10", toBase: "2" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.decimal).toBe("0");
expect(value.binary).toBe("0b0");
});
});

View File

@@ -0,0 +1,53 @@
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 { NumberBaseOptions } from "./index";
function formatNumber(value: bigint, base: number): string {
if (base === 2) return "0b" + value.toString(2);
if (base === 8) return "0o" + value.toString(8);
if (base === 10) return value.toString(10);
if (base === 16) return "0x" + value.toString(16).toUpperCase();
return value.toString(base);
}
export async function runNumberBase(
input: ToolInput,
options: NumberBaseOptions,
context?: ToolContext
): Promise<ToolResult> {
void context;
const text = (input.text || "").trim();
if (!text) {
throw new Error("No number provided.");
}
const fromBase = parseInt(options.fromBase, 10);
const toBase = parseInt(options.toBase, 10);
const cleaned = text.replace(/^0[box]/i, "");
let value: bigint;
try {
value = BigInt(parseInt(cleaned, fromBase));
} catch {
throw new Error(`Invalid number "${text}" for base ${fromBase}.`);
}
if (isNaN(Number(value))) {
throw new Error(`Invalid number "${text}" for base ${fromBase}.`);
}
return {
type: "json",
value: {
input: text,
binary: formatNumber(value, 2),
octal: formatNumber(value, 8),
decimal: formatNumber(value, 10),
hexadecimal: formatNumber(value, 16),
primary: formatNumber(value, toBase),
},
};
}

View File

@@ -0,0 +1,97 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runPasswordGenerator } from "./run";
export interface PasswordGeneratorOptions {
mode: "password" | "passphrase";
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
symbols: boolean;
excludeAmbiguous: boolean;
count: number;
}
export const passwordGeneratorPlugin: PlimiPlugin<PasswordGeneratorOptions> = {
manifest: {
id: "password-generator",
name: "Password & Passphrase Generator",
description: "Generate highly secure, random passwords or word-based passphrases locally in your browser.",
category: "crypto",
version: "1.0.0",
tags: ["password", "passphrase", "security", "generator", "random"],
input: { type: "none" },
output: { type: "table" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "mode",
label: "Mode",
defaultValue: "password",
options: [
{ label: "Random Password", value: "password" },
{ label: "Word Passphrase", value: "passphrase" },
],
},
{
type: "slider",
key: "length",
label: "Password Length / Word Count",
defaultValue: 16,
min: 6,
max: 64,
step: 1,
},
{
type: "boolean",
key: "uppercase",
label: "Include Uppercase Letters (A-Z)",
defaultValue: true,
},
{
type: "boolean",
key: "lowercase",
label: "Include Lowercase Letters (a-z)",
defaultValue: true,
},
{
type: "boolean",
key: "numbers",
label: "Include Numbers (0-9)",
defaultValue: true,
},
{
type: "boolean",
key: "symbols",
label: "Include Symbols (!@#$%^&*)",
defaultValue: true,
},
{
type: "boolean",
key: "excludeAmbiguous",
label: "Exclude Ambiguous Characters (e.g. l, 1, I, o, 0, O)",
defaultValue: false,
},
{
type: "slider",
key: "count",
label: "Quantity to Generate",
defaultValue: 5,
min: 1,
max: 20,
step: 1,
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runPasswordGenerator,
};

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, vi } from "vitest";
import { runPasswordGenerator } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Password & Passphrase Generator Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
const defaultOpts = {
mode: "password" as const,
length: 16,
uppercase: true,
lowercase: true,
numbers: true,
symbols: true,
excludeAmbiguous: false,
count: 5,
};
it("should generate the requested quantity of passwords", async () => {
const result = await runPasswordGenerator({}, defaultOpts, mockContext);
expect(result.type).toBe("table");
if (result.type === "table") {
expect(result.columns).toEqual(["Password", "Length", "Entropy (bits)"]);
expect(result.rows).toHaveLength(5);
expect(result.rows[0][0]).toBeTypeOf("string");
expect(result.rows[0][1]).toBe(16);
expect(result.rows[0][2]).toContain("bits");
}
});
it("should enforce selected character rules", async () => {
// Generate only numbers
const result = await runPasswordGenerator(
{},
{
...defaultOpts,
uppercase: false,
lowercase: false,
numbers: true,
symbols: false,
},
mockContext
);
if (result.type === "table") {
result.rows.forEach(row => {
const password = String(row[0]);
expect(password).toMatch(/^[0-9]+$/);
});
}
});
it("should exclude ambiguous characters", async () => {
const result = await runPasswordGenerator(
{},
{
...defaultOpts,
uppercase: true,
lowercase: true,
numbers: true,
symbols: false,
excludeAmbiguous: true,
count: 20, // larger sample size
},
mockContext
);
if (result.type === "table") {
result.rows.forEach(row => {
const password = String(row[0]);
// Ambiguous characters list: l, 1, I, o, 0, O
expect(password).not.toContain("l");
expect(password).not.toContain("1");
expect(password).not.toContain("I");
expect(password).not.toContain("o");
expect(password).not.toContain("0");
expect(password).not.toContain("O");
});
}
});
it("should generate correct passphrase count and word length", async () => {
const result = await runPasswordGenerator(
{},
{
...defaultOpts,
mode: "passphrase",
length: 6, // 6 words
count: 3,
},
mockContext
);
expect(result.type).toBe("table");
if (result.type === "table") {
expect(result.columns).toEqual(["Passphrase", "Length (chars)", "Entropy (bits)"]);
expect(result.rows).toHaveLength(3);
const passphrase = String(result.rows[0][0]);
const words = passphrase.split("-");
expect(words).toHaveLength(6);
}
});
it("should throw error if all character classes are disabled", async () => {
await expect(
runPasswordGenerator(
{},
{
...defaultOpts,
uppercase: false,
lowercase: false,
numbers: false,
symbols: false,
},
mockContext
)
).rejects.toThrow("Please select at least one character set");
});
});

View File

@@ -0,0 +1,146 @@
import { PASSPHRASE_WORDS } from "./words";
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 { PasswordGeneratorOptions } from "./index";
function secureRandomInt(max: number): number {
const arr = new Uint32Array(1);
// Ensure we don't introduce modulo bias for small numbers
// max is pool size which is typically small (< 100), so standard modulo is perfectly safe in practice.
crypto.getRandomValues(arr);
return arr[0] % max;
}
function secureShuffle(arr: string[]): string[] {
const result = [...arr];
const rand = new Uint32Array(result.length);
crypto.getRandomValues(rand);
for (let i = result.length - 1; i > 0; i--) {
const j = rand[i] % (i + 1);
const temp = result[i];
result[i] = result[j];
result[j] = temp;
}
return result;
}
export async function runPasswordGenerator(
_input: ToolInput,
options: PasswordGeneratorOptions,
context: ToolContext
): Promise<ToolResult> {
const {
mode,
length,
uppercase,
lowercase,
numbers,
symbols,
excludeAmbiguous,
count,
} = options;
const qty = count || 5;
if (mode === "passphrase") {
context.reportProgress({ percentage: 20, message: "Generating passphrases..." });
const rows: Array<[string, number, string]> = [];
const wordlistSize = PASSPHRASE_WORDS.length;
// log2(250) is ~7.96578
const entropyPerWord = Math.log2(wordlistSize);
const totalEntropy = Math.round(length * entropyPerWord * 10) / 10;
for (let i = 0; i < qty; i++) {
const words: string[] = [];
for (let j = 0; j < length; j++) {
const idx = secureRandomInt(wordlistSize);
words.push(PASSPHRASE_WORDS[idx]);
}
const passphrase = words.join("-");
rows.push([passphrase, passphrase.length, `${totalEntropy} bits`]);
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "table",
columns: ["Passphrase", "Length (chars)", "Entropy (bits)"],
rows,
};
} else {
// Password mode
context.reportProgress({ percentage: 20, message: "Building character pools..." });
let uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let lowercaseChars = "abcdefghijklmnopqrstuvwxyz";
let numberChars = "0123456789";
let symbolChars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
if (excludeAmbiguous) {
const ambiguous = /[l1Io0O]/g;
uppercaseChars = uppercaseChars.replace(ambiguous, "");
lowercaseChars = lowercaseChars.replace(ambiguous, "");
numberChars = numberChars.replace(ambiguous, "");
// Also remove |, :, ; etc. from symbols if they are considered ambiguous,
// but standard is to remove characters resembling each other: l, 1, I, o, 0, O, etc.
}
const pools: string[] = [];
if (uppercase) pools.push(uppercaseChars);
if (lowercase) pools.push(lowercaseChars);
if (numbers) pools.push(numberChars);
if (symbols) pools.push(symbolChars);
if (pools.length === 0) {
throw new Error("Invalid options: Please select at least one character set (Uppercase, Lowercase, Numbers, or Symbols) to generate passwords.");
}
const fullPool = pools.join("");
const poolSize = fullPool.length;
const entropyPerChar = Math.log2(poolSize);
const totalEntropy = Math.round(length * entropyPerChar * 10) / 10;
context.reportProgress({ percentage: 50, message: "Generating random passwords..." });
const rows: Array<[string, number, string]> = [];
for (let i = 0; i < qty; i++) {
const passwordChars: string[] = [];
// Guarantee at least one character from each selected character class
// if password length permits it
if (length >= pools.length) {
pools.forEach(pool => {
const idx = secureRandomInt(pool.length);
passwordChars.push(pool[idx]);
});
// Fill remaining length
const remainingLength = length - pools.length;
for (let j = 0; j < remainingLength; j++) {
const idx = secureRandomInt(poolSize);
passwordChars.push(fullPool[idx]);
}
// Shuffle securely to mix the guaranteed characters
const shuffled = secureShuffle(passwordChars);
rows.push([shuffled.join(""), length, `${totalEntropy} bits`]);
} else {
// Fallback for short length: pick entirely randomly from combined pool
for (let j = 0; j < length; j++) {
const idx = secureRandomInt(poolSize);
passwordChars.push(fullPool[idx]);
}
rows.push([passwordChars.join(""), length, `${totalEntropy} bits`]);
}
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "table",
columns: ["Password", "Length", "Entropy (bits)"],
rows,
};
}
}

View File

@@ -0,0 +1,30 @@
export const PASSPHRASE_WORDS = [
"actor", "agree", "album", "alert", "apple", "armor", "arrow", "badge", "baker", "beach",
"beast", "berry", "blend", "block", "board", "brain", "brave", "brick", "bride", "brown",
"brush", "cabin", "cable", "camel", "cargo", "chain", "chair", "chalk", "charm", "chart",
"chief", "child", "clock", "clown", "coach", "coast", "cream", "crown", "cycle", "dance",
"depth", "diary", "draft", "drama", "dream", "dress", "drift", "drill", "drink", "earth",
"elbow", "empty", "entry", "equal", "event", "faith", "fancy", "feast", "fiber", "field",
"flame", "flash", "flute", "focus", "forest", "frame", "frost", "fruit", "giant", "glass",
"globe", "glove", "grace", "grand", "grape", "grass", "green", "group", "guard", "guide",
"habit", "heart", "heavy", "honey", "horse", "hotel", "house", "image", "index", "irony",
"ivory", "jeans", "joint", "judge", "juice", "knife", "labor", "lemon", "light", "limit",
"lunch", "magic", "major", "maple", "march", "match", "metal", "model", "money", "motor",
"mount", "mouse", "mouth", "music", "night", "noise", "north", "novel", "nurse", "ocean",
"onion", "order", "organ", "owner", "paint", "paper", "party", "patch", "peach", "pearl",
"pedal", "piano", "pilot", "pitch", "pizza", "plant", "plate", "poem", "poet", "point",
"pound", "power", "press", "price", "pride", "prize", "proud", "pulse", "queen", "quick",
"quiet", "radio", "rainy", "range", "ratio", "reply", "river", "rough", "round", "route",
"royal", "rugby", "ruler", "salad", "scale", "scene", "scent", "score", "scout", "shade",
"shadow", "shark", "sharp", "sheep", "shelf", "shell", "shirt", "shock", "shore", "sight",
"silk", "silver", "skate", "skill", "skirt", "slate", "sleep", "slice", "slide", "slope",
"smart", "smile", "smoke", "snake", "solid", "sound", "south", "space", "speak", "speed",
"spend", "spice", "spider", "spill", "spine", "spoon", "sport", "stage", "stamp", "stand",
"stare", "start", "state", "steam", "steel", "steer", "stick", "still", "stone", "store",
"storm", "story", "strap", "straw", "strip", "study", "sugar", "suite", "sunny", "super",
"sweet", "swift", "swing", "table", "tiger", "title", "toast", "token", "topic", "torch",
"tower", "track", "trade", "train", "trash", "trend", "trial", "truck", "truth", "tulip",
"uncle", "union", "unity", "value", "vapor", "vault", "venue", "voice", "vowel", "wagon",
"waste", "watch", "water", "wheel", "white", "width", "windy", "witch", "woman", "world",
"wrist", "write", "yeast", "young", "zebra", "zones"
];

View File

@@ -0,0 +1,30 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runPdfMerger } from "./run";
import PdfWorker from "./worker?worker";
export const pdfMergerPlugin: PlimiPlugin = {
manifest: {
id: "pdf-merger",
name: "PDF Merger",
description:
"Merge multiple PDF files securely in your browser. Large files will not freeze your UI.",
category: "pdf",
version: "1.0.0",
tags: ["pdf", "merge", "join"],
input: {
type: "files",
accept: ["application/pdf"],
multiple: true,
},
output: { type: "files" },
offlineReady: true,
},
capabilities: {
cancelable: true,
worker: true,
},
run: runPdfMerger,
worker: () => new PdfWorker(),
};

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, vi } from "vitest";
import { pdfMergerPlugin } from "./index";
import { runPdfMerger } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("PDF Merger Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
it("should have correct manifest", () => {
expect(pdfMergerPlugin.manifest.id).toBe("pdf-merger");
expect(pdfMergerPlugin.manifest.input.type).toBe("files");
expect(pdfMergerPlugin.manifest.output.type).toBe("files");
});
it("should have run function", () => {
expect(pdfMergerPlugin.run).toBeDefined();
});
it("should throw error if no files provided", async () => {
await expect(
runPdfMerger({ files: [] }, {}, mockContext)
).rejects.toThrow("No files provided for merging.");
});
});

View File

@@ -0,0 +1,62 @@
import { PDFDocument } from "pdf-lib";
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { ToolContext } from "../../core/plugins/plugin-types";
export async function runPdfMerger(
input: ToolInput,
_options: unknown,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
throw new Error("No files provided for merging.");
}
try {
const mergedPdf = await PDFDocument.create();
for (let i = 0; i < files.length; i++) {
const file = files[i];
context.reportProgress({
percentage: (i / files.length) * 100,
message: `Merging ${file.name} (${i + 1}/${files.length})...`,
});
const fileBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(new Uint8Array(fileBuffer));
const copiedPages = await mergedPdf.copyPages(
pdfDoc,
pdfDoc.getPageIndices()
);
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
context.reportProgress({
percentage: 100,
message: "Finalizing PDF...",
});
const mergedPdfBytes = await mergedPdf.save();
// TS DOM lib workaround for Uint8Array
const blob = new Blob([mergedPdfBytes as unknown as BlobPart], { type: "application/pdf" });
return {
type: "files",
files: [
{
name: "merged_document.pdf",
mimeType: "application/pdf",
blob: blob,
sizeAfter: blob.size,
},
],
};
} catch (error) {
console.error("PDF Merge Error:", error);
throw error instanceof Error ? error : new Error("Unknown error occurred during PDF merging");
}
}

View File

@@ -0,0 +1,87 @@
import { PDFDocument } from "pdf-lib";
import type {
ToolWorkerRequest,
ToolWorkerResponse,
} from "../../core/plugins/worker-protocol";
function post(response: ToolWorkerResponse) {
self.postMessage(response);
}
self.addEventListener("message", async (e: MessageEvent<ToolWorkerRequest>) => {
const { id, input } = e.data;
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
post({
type: "error",
id,
error: "No files provided for merging.",
});
return;
}
try {
const mergedPdf = await PDFDocument.create();
for (let i = 0; i < files.length; i++) {
const file = files[i];
post({
type: "progress",
id,
progress: {
percentage: (i / files.length) * 100,
message: `Merging ${file.name} (${i + 1}/${files.length})...`,
},
});
const fileBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(new Uint8Array(fileBuffer));
const copiedPages = await mergedPdf.copyPages(
pdfDoc,
pdfDoc.getPageIndices()
);
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
post({
type: "progress",
id,
progress: {
percentage: 100,
message: "Finalizing PDF...",
},
});
const mergedPdfBytes = await mergedPdf.save();
const blob = new Blob([mergedPdfBytes as unknown as BlobPart], {
type: "application/pdf",
});
post({
type: "success",
id,
result: {
type: "files",
files: [
{
name: "merged_document.pdf",
mimeType: "application/pdf",
blob,
sizeAfter: blob.size,
},
],
},
});
} catch (error) {
post({
type: "error",
id,
error:
error instanceof Error
? error.message
: "Unknown error occurred during PDF merging",
});
}
});

View File

@@ -0,0 +1,54 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runPdfSplitter } from "./run";
export interface PdfSplitterOptions {
splitMode: "extract" | "burst";
pageRange: string;
}
export const pdfSplitterPlugin: PlimiPlugin<PdfSplitterOptions> = {
manifest: {
id: "pdf-splitter",
name: "PDF Splitter",
description: "Extract page ranges or split a PDF document into individual pages safely in your browser.",
category: "pdf",
version: "1.0.0",
tags: ["pdf", "split", "extract", "pages"],
input: {
type: "files",
accept: ["application/pdf"],
multiple: false,
},
output: { type: "files" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "splitMode",
label: "Split Mode",
defaultValue: "extract",
options: [
{ label: "Extract Range", value: "extract" },
{ label: "Split Every Page", value: "burst" },
],
},
{
type: "text",
key: "pageRange",
label: "Page Range",
defaultValue: "1",
placeholder: "e.g. 1-3, 5 (for Extract mode)",
},
],
},
capabilities: {
cancelable: true,
worker: false,
},
run: runPdfSplitter,
};

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from "vitest";
import { parsePageRange, runPdfSplitter } from "./run";
import { PDFDocument } from "pdf-lib";
import type { ToolResult } from "../../core/io/output-types";
describe("PDF Splitter Utilities", () => {
describe("parsePageRange", () => {
it("should parse single pages correctly", () => {
expect(parsePageRange("1", 5)).toEqual([0]);
expect(parsePageRange("3", 5)).toEqual([2]);
});
it("should parse simple ranges correctly", () => {
expect(parsePageRange("1-3", 5)).toEqual([0, 1, 2]);
});
it("should parse reverse ranges correctly", () => {
expect(parsePageRange("3-1", 5)).toEqual([2, 1, 0]);
});
it("should parse comma-separated lists and ranges", () => {
expect(parsePageRange("1-3, 5", 5)).toEqual([0, 1, 2, 4]);
expect(parsePageRange("1, 3, 5", 10)).toEqual([0, 2, 4]);
});
it("should filter out pages out of bounds", () => {
expect(parsePageRange("1-10", 3)).toEqual([0, 1, 2]);
expect(parsePageRange("0, 5", 3)).toEqual([]); // 0 is invalid (1-indexed page bounds)
});
});
describe("runPdfSplitter", () => {
const createMockPdf = async (pagesCount: number): Promise<Uint8Array> => {
const pdfDoc = await PDFDocument.create();
for (let i = 0; i < pagesCount; i++) {
pdfDoc.addPage([200, 200]);
}
return pdfDoc.save();
};
it("should extract a range of pages into a single PDF", async () => {
const pdfBytes = await createMockPdf(5);
const mockFile = new File([pdfBytes as unknown as BlobPart], "source.pdf", { type: "application/pdf" });
const mockContext = {
reportProgress: vi.fn(),
signal: new AbortController().signal,
logger: console,
};
const result = await runPdfSplitter(
{ files: [mockFile] },
{ splitMode: "extract", pageRange: "2-4" },
mockContext
) as Extract<ToolResult, { type: "files" }>;
expect(result.type).toBe("files");
expect(result.files).toHaveLength(1);
expect(result.files[0].name).toBe("source_extracted.pdf");
expect(result.files[0].blob).toBeInstanceOf(Blob);
// Verify the generated PDF has exactly 3 pages
const resBytes = await result.files[0].blob.arrayBuffer();
const resDoc = await PDFDocument.load(new Uint8Array(resBytes));
expect(resDoc.getPageCount()).toBe(3);
});
it("should burst a PDF into separate page files", async () => {
const pdfBytes = await createMockPdf(3);
const mockFile = new File([pdfBytes as unknown as BlobPart], "source.pdf", { type: "application/pdf" });
const mockContext = {
reportProgress: vi.fn(),
signal: new AbortController().signal,
logger: console,
};
const result = await runPdfSplitter(
{ files: [mockFile] },
{ splitMode: "burst", pageRange: "" },
mockContext
) as Extract<ToolResult, { type: "files" }>;
expect(result.type).toBe("files");
expect(result.files).toHaveLength(3);
expect(result.files[0].name).toBe("source_page_1.pdf");
expect(result.files[1].name).toBe("source_page_2.pdf");
expect(result.files[2].name).toBe("source_page_3.pdf");
// Verify each generated PDF has exactly 1 page
for (const fileObj of result.files) {
const resBytes = await fileObj.blob.arrayBuffer();
const resDoc = await PDFDocument.load(new Uint8Array(resBytes));
expect(resDoc.getPageCount()).toBe(1);
}
});
it("should throw error if invalid range is supplied", async () => {
const pdfBytes = await createMockPdf(3);
const mockFile = new File([pdfBytes as unknown as BlobPart], "source.pdf", { type: "application/pdf" });
const mockContext = {
reportProgress: vi.fn(),
signal: new AbortController().signal,
logger: console,
};
await expect(
runPdfSplitter(
{ files: [mockFile] },
{ splitMode: "extract", pageRange: "10-12" }, // Out of bounds
mockContext
)
).rejects.toThrow("No valid pages selected.");
});
});
});

View File

@@ -0,0 +1,134 @@
import { PDFDocument } from "pdf-lib";
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 { PdfSplitterOptions } from "./index";
export function parsePageRange(rangeStr: string, maxPages: number): number[] {
const indices: number[] = [];
const parts = rangeStr.replace(/\s+/g, "").split(",");
for (const part of parts) {
if (!part) continue;
if (part.includes("-")) {
const bounds = part.split("-");
if (bounds.length === 2) {
const start = parseInt(bounds[0], 10);
const end = parseInt(bounds[1], 10);
if (!isNaN(start) && !isNaN(end)) {
const step = start <= end ? 1 : -1;
for (let i = start; start <= end ? i <= end : i >= end; i += step) {
// Convert to 0-based index
const idx = i - 1;
if (idx >= 0 && idx < maxPages) {
indices.push(idx);
}
}
}
}
} else {
const page = parseInt(part, 10);
if (!isNaN(page)) {
const idx = page - 1;
if (idx >= 0 && idx < maxPages) {
indices.push(idx);
}
}
}
}
return indices;
}
export async function runPdfSplitter(
input: ToolInput,
options: PdfSplitterOptions,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
throw new Error("No PDF file uploaded.");
}
const file = files[0];
const fileBuffer = await file.arrayBuffer();
try {
context.reportProgress({ percentage: 10, message: "Loading PDF document..." });
const srcDoc = await PDFDocument.load(new Uint8Array(fileBuffer));
const totalPages = srcDoc.getPageCount();
const baseName = file.name.endsWith(".pdf")
? file.name.substring(0, file.name.length - 4)
: file.name;
if (options.splitMode === "burst") {
const outFiles = [];
for (let i = 0; i < totalPages; i++) {
if (context.signal?.aborted) {
throw new DOMException("Operation cancelled", "AbortError");
}
context.reportProgress({
percentage: 10 + (i / totalPages) * 80,
message: `Splitting page ${i + 1} of ${totalPages}...`,
});
const newDoc = await PDFDocument.create();
const [copiedPage] = await newDoc.copyPages(srcDoc, [i]);
newDoc.addPage(copiedPage);
const bytes = await newDoc.save();
const blob = new Blob([bytes as unknown as BlobPart], { type: "application/pdf" });
outFiles.push({
name: `${baseName}_page_${i + 1}.pdf`,
mimeType: "application/pdf",
blob: blob,
sizeAfter: blob.size,
});
}
context.reportProgress({ percentage: 100, message: "Done!" });
return {
type: "files",
files: outFiles,
};
} else {
// Extract Range mode
context.reportProgress({ percentage: 30, message: "Parsing page range..." });
const targetIndices = parsePageRange(options.pageRange || "1", totalPages);
if (targetIndices.length === 0) {
throw new Error(`No valid pages selected. The PDF only has ${totalPages} page(s).`);
}
context.reportProgress({ percentage: 50, message: "Copying requested pages..." });
const newDoc = await PDFDocument.create();
const copiedPages = await newDoc.copyPages(srcDoc, targetIndices);
copiedPages.forEach((page) => newDoc.addPage(page));
context.reportProgress({ percentage: 80, message: "Saving new PDF..." });
const bytes = await newDoc.save();
const blob = new Blob([bytes as unknown as BlobPart], { type: "application/pdf" });
context.reportProgress({ percentage: 100, message: "Done!" });
return {
type: "files",
files: [
{
name: `${baseName}_extracted.pdf`,
mimeType: "application/pdf",
blob: blob,
sizeAfter: blob.size,
},
],
};
}
} catch (error) {
console.error("PDF Split Error:", error);
throw error instanceof Error ? error : new Error("Unknown error occurred during PDF splitting");
}
}

View File

@@ -0,0 +1,82 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runQrCodeGenerator } from "./run";
export interface QrCodeGeneratorOptions {
size: number;
margin: number;
errorCorrection: "L" | "M" | "Q" | "H";
format: "svg" | "png";
}
export const qrCodeGeneratorPlugin: PlimiPlugin<QrCodeGeneratorOptions> = {
manifest: {
id: "qr-code-generator",
name: "QR Code Generator (WASM)",
description: "Generate high-performance, offline-ready QR codes locally in the browser using WebAssembly.",
category: "image",
version: "1.0.0",
tags: ["qr", "qrcode", "generator", "wasm", "barcode"],
input: {
type: "text",
label: "Text / URL",
placeholder: "Enter text or URL to encode...",
multiline: true,
rows: 4,
},
output: { type: "files" },
example: "https://plimi.app",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "slider",
key: "size",
label: "Size (pixels)",
defaultValue: 256,
min: 128,
max: 1024,
step: 32,
},
{
type: "slider",
key: "margin",
label: "Margin (modules)",
defaultValue: 4,
min: 0,
max: 10,
step: 1,
},
{
type: "select",
key: "errorCorrection",
label: "Error Correction Level",
defaultValue: "M",
options: [
{ label: "Low (7% recovery)", value: "L" },
{ label: "Medium (15% recovery)", value: "M" },
{ label: "Quartile (25% recovery)", value: "Q" },
{ label: "High (30% recovery)", value: "H" },
],
},
{
type: "select",
key: "format",
label: "Output Format",
defaultValue: "svg",
options: [
{ label: "SVG Vector File", value: "svg" },
{ label: "PNG Image File", value: "png" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runQrCodeGenerator,
};

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { runQrCodeGenerator } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("QR Code Generator Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
it("should generate SVG QR Code successfully", async () => {
const result = await runQrCodeGenerator(
{ text: "https://plimi.app" },
{ size: 256, margin: 4, errorCorrection: "M", format: "svg" },
mockContext
);
expect(result.type).toBe("files");
if (result.type === "files") {
expect(result.files).toHaveLength(1);
expect(result.files[0].name).toBe("qrcode.svg");
expect(result.files[0].mimeType).toBe("image/svg+xml");
expect(result.files[0].blob).toBeInstanceOf(Blob);
}
});
it("should generate PNG QR Code successfully", async () => {
const result = await runQrCodeGenerator(
{ text: "https://plimi.app" },
{ size: 256, margin: 4, errorCorrection: "M", format: "png" },
mockContext
);
expect(result.type).toBe("files");
if (result.type === "files") {
expect(result.files).toHaveLength(1);
expect(result.files[0].name).toBe("qrcode.png");
expect(result.files[0].mimeType).toBe("image/png");
expect(result.files[0].blob).toBeInstanceOf(Blob);
}
});
it("should throw error for empty input text", async () => {
await expect(
runQrCodeGenerator({ text: "" }, { size: 256, margin: 4, errorCorrection: "M", format: "svg" }, mockContext)
).rejects.toThrow("Please enter text or a URL to encode in the QR Code.");
});
});

View File

@@ -0,0 +1,107 @@
import { prepareZXingModule, writeBarcode } from "zxing-wasm/writer";
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 { QrCodeGeneratorOptions } from "./index";
const isBrowser = typeof window !== "undefined";
let isPrepared = false;
async function prepareModuleOnce() {
if (isPrepared) return;
if (isBrowser) {
prepareZXingModule({
overrides: {
locateFile: (pathName: string) => {
if (pathName.endsWith(".wasm")) {
return "/zxing_writer.wasm";
}
return pathName;
},
},
});
} else {
const path = (await import("path" as string)) as any;
const fs = (await import("fs" as string)) as any;
const resolvedPath = path.resolve(
(globalThis as any).process.cwd(),
"node_modules/zxing-wasm/dist/writer/zxing_writer.wasm"
);
const wasmBinary = fs.readFileSync(resolvedPath);
prepareZXingModule({
overrides: {
wasmBinary,
},
});
}
isPrepared = true;
}
export async function runQrCodeGenerator(
input: ToolInput,
options: QrCodeGeneratorOptions,
context: ToolContext
): Promise<ToolResult> {
await prepareModuleOnce();
const text = (input.text ?? "").trim();
if (!text) {
throw new Error("Please enter text or a URL to encode in the QR Code.");
}
const { size, margin, errorCorrection, format } = options;
context.reportProgress({ percentage: 30, message: "Encoding QR Code data..." });
try {
const result = await writeBarcode(text, {
format: "QRCode",
scale: -size,
ecLevel: errorCorrection,
options: `margin=${margin}`,
});
if (result.error) {
throw new Error(result.error);
}
context.reportProgress({ percentage: 75, message: "Creating output file..." });
if (format === "svg") {
const blob = new Blob([result.svg], { type: "image/svg+xml" });
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "files",
files: [
{
name: "qrcode.svg",
mimeType: "image/svg+xml",
blob,
sizeAfter: blob.size,
},
],
};
} else {
// png format
const blob = result.image;
if (!blob) {
throw new Error("WebAssembly failed to render the PNG image blob.");
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "files",
files: [
{
name: "qrcode.png",
mimeType: "image/png",
blob,
sizeAfter: blob.size,
},
],
};
}
} catch (err: any) {
throw new Error(`Failed to generate QR Code: ${err.message ?? err}`);
}
}

View File

@@ -0,0 +1,58 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runRegexTester } from "./run";
export interface RegexTesterOptions {
flags: string;
}
export const regexTesterPlugin: PlimiPlugin<RegexTesterOptions> = {
manifest: {
id: "dev-regex",
name: "Regex Tester",
description: "Test regular expressions against text and see matches highlighted.",
category: "developer",
version: "1.0.0",
tags: ["regex", "regexp", "test", "match"],
input: {
type: "group",
fields: [
{
key: "pattern",
label: "Regex Pattern",
type: "text",
multiline: false,
placeholder: "Enter regex pattern here (e.g. [A-Z]\\w+)...",
example: "[A-Z]\\w+",
},
{
key: "text",
label: "Test Text",
type: "text",
placeholder: "Enter test text here...",
example: "Hello World, This Is A Test.",
},
],
},
output: { type: "json" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "text",
key: "flags",
label: "Regex Flags",
defaultValue: "gi",
placeholder: "e.g. gi, g, i, gm",
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runRegexTester,
};

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from "vitest";
import { runRegexTester } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Regex Tester", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should find all matches with global flag", async () => {
const result = await runRegexTester(
{ text: "\\d+\nabc123def456ghi789" },
{ flags: "gi" },
mockContext
);
expect(result.type).toBe("json");
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
const matches = value.matches as unknown[];
expect(value.matchCount).toBe(3);
expect(matches[0]).toEqual({ match: "123", index: 3, groups: null });
expect(matches[1]).toEqual({ match: "456", index: 9, groups: null });
expect(matches[2]).toEqual({ match: "789", index: 15, groups: null });
});
it("should find a single match without global flag", async () => {
const result = await runRegexTester(
{ text: "hello\nsay hello world" },
{ flags: "i" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
const matches = value.matches as Array<Record<string, unknown>>;
expect(value.matchCount).toBe(1);
expect(matches[0].match).toBe("hello");
expect(matches[0].index).toBe(4);
});
it("should return empty matches when test text is empty", async () => {
const result = await runRegexTester(
{ text: "\\d+" },
{ flags: "gi" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.matchCount).toBe(0);
expect(value.matches).toEqual([]);
});
it("should throw on invalid regex pattern", async () => {
await expect(
runRegexTester({ text: "[invalid" }, { flags: "gi" }, mockContext)
).rejects.toThrow("Invalid regex");
});
it("should throw when no pattern is provided", async () => {
await expect(
runRegexTester({ text: "" }, { flags: "gi" }, mockContext)
).rejects.toThrow("Enter a regex pattern");
});
it("should parse /pattern/flags syntax from input", async () => {
const result = await runRegexTester(
{ text: "/hello/gi\nhello Hello HELLO" },
{ flags: "" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.matchCount).toBe(3);
});
it("should handle no matches gracefully", async () => {
const result = await runRegexTester(
{ text: "xyz\nhello world" },
{ flags: "gi" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.matchCount).toBe(0);
expect(value.matches).toEqual([]);
});
it("should find matches when using group input values", async () => {
const result = await runRegexTester(
{
values: {
pattern: { text: "\\d+" },
text: { text: "abc123def456ghi789" },
},
},
{ flags: "gi" },
mockContext
);
expect(result.type).toBe("json");
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
const matches = value.matches as unknown[];
expect(value.matchCount).toBe(3);
expect(matches[0]).toEqual({ match: "123", index: 3, groups: null });
});
});

View File

@@ -0,0 +1,107 @@
import { getTextInput, 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 { RegexTesterOptions } from "./index";
export async function runRegexTester(
input: ToolInput,
options: RegexTesterOptions,
context?: ToolContext
): Promise<ToolResult> {
void context;
let pattern = getTextInput(input, "pattern");
let testText = getTextInput(input, "text");
// Fallback to legacy single text parsing (for backwards compatibility / unit tests)
if ((!pattern || !testText) && input.text) {
const text = input.text;
const firstLine = text.indexOf("\n") >= 0 ? text.substring(0, text.indexOf("\n")).trim() : text.trim();
const rest = text.indexOf("\n") >= 0 ? text.substring(text.indexOf("\n") + 1) : "";
pattern = pattern || firstLine;
testText = testText || rest;
}
if (!pattern) {
throw new Error("Enter a regex pattern.");
}
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
const lastSlash = pattern.lastIndexOf("/");
const inlineFlags = pattern.substring(lastSlash + 1);
pattern = pattern.substring(1, lastSlash);
if (!options.flags) {
(options as unknown as Record<string, string>).flags = inlineFlags;
}
}
const flags = options.flags || "gi";
let regex: RegExp;
try {
regex = new RegExp(pattern, flags);
} catch (cause) {
const msg = cause instanceof Error ? cause.message : String(cause);
throw new Error(`Invalid regex: ${msg}`, { cause });
}
if (!testText) {
return {
type: "json",
value: {
pattern,
flags,
matches: [],
matchCount: 0,
groups: [],
},
};
}
const matches: Array<{ match: string; index: number; groups: Record<string, string> | null }> = [];
let matchResult: RegExpExecArray | null;
if (regex.global) {
while ((matchResult = regex.exec(testText)) !== null) {
matches.push({
match: matchResult[0],
index: matchResult.index,
groups: matchResult.groups ? { ...matchResult.groups } : null,
});
if (matchResult[0].length === 0) {
regex.lastIndex++;
}
}
} else {
matchResult = regex.exec(testText);
if (matchResult) {
matches.push({
match: matchResult[0],
index: matchResult.index,
groups: matchResult.groups ? { ...matchResult.groups } : null,
});
}
}
const allGroups = new Set<string>();
const groupRegex = /\(\?<(?<name>[a-zA-Z][a-zA-Z0-9]*)>/g;
let groupMatch: RegExpExecArray | null;
const tempRegex = new RegExp(groupRegex);
while ((groupMatch = tempRegex.exec(pattern)) !== null) {
if (groupMatch.groups?.name) {
allGroups.add(groupMatch.groups.name);
}
}
return {
type: "json",
value: {
pattern,
flags,
matches,
matchCount: matches.length,
testTextLength: testText.length,
groups: Array.from(allGroups),
},
};
}

View File

@@ -0,0 +1,46 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runTextCase } from "./run";
export interface TextCaseOptions {
to: "UPPERCASE" | "lowercase" | "camelCase" | "snake_case" | "kebab-case";
}
export const textCasePlugin: PlimiPlugin<TextCaseOptions> = {
manifest: {
id: "txt-case",
name: "Case Converter",
description: "Convert text between camelCase, snake_case, etc.",
category: "text",
version: "1.0.0",
tags: ["case", "text", "camel", "snake"],
input: { type: "text" },
output: { type: "text" },
example: "hello world example text",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "to",
label: "Convert to",
defaultValue: "snake_case",
options: [
{ label: "UPPERCASE", value: "UPPERCASE" },
{ label: "lowercase", value: "lowercase" },
{ label: "camelCase", value: "camelCase" },
{ label: "snake_case", value: "snake_case" },
{ label: "kebab-case", value: "kebab-case" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runTextCase,
};

View File

@@ -0,0 +1,41 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { TextCaseOptions } from "./index";
export async function runTextCase(
input: ToolInput,
options: TextCaseOptions
): Promise<ToolResult> {
const text = input.text || "";
if (!text) {
throw new Error("No text provided.");
}
// A simple tokenizer that splits by whitespace or punctuation
const words = text.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) || [];
const outputStr = (() => {
switch (options.to) {
case "UPPERCASE":
return text.toUpperCase();
case "lowercase":
return text.toLowerCase();
case "camelCase":
return words.map((w, i) => {
const lower = w.toLowerCase();
return i === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1);
}).join("");
case "snake_case":
return words.map(w => w.toLowerCase()).join("_");
case "kebab-case":
return words.map(w => w.toLowerCase()).join("-");
default:
return text;
}
})();
return {
type: "text",
value: outputStr,
};
}

View File

@@ -0,0 +1,56 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runTextDiff } from "./run";
export interface TextDiffOptions {
ignoreWhitespace: boolean;
}
export const textDiffPlugin: PlimiPlugin<TextDiffOptions> = {
manifest: {
id: "txt-diff",
name: "Text Diff",
description: "Compare two texts and see the differences line by line.",
category: "text",
version: "1.0.0",
tags: ["diff", "compare", "text"],
input: {
type: "group",
fields: [
{
key: "original",
label: "original",
type: "text",
placeholder: "Paste the original text…",
example: "function greet(name) {\n return 'Hello ' + name;\n}",
},
{
key: "modified",
label: "modified",
type: "text",
placeholder: "Paste the modified text…",
example: "function greet(name) {\n return `Hello, ${name}!`;\n}\n\nfunction farewell(name) {\n return `Bye, ${name}!`;\n}",
},
],
},
output: { type: "text" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "boolean",
key: "ignoreWhitespace",
label: "Ignore whitespace",
defaultValue: false,
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runTextDiff,
};

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi } from "vitest";
import { runTextDiff } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Text Diff", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should detect added lines", async () => {
const result = await runTextDiff(
{ values: { original: { text: "line 1" }, modified: { text: "line 1\nline 2" } } },
{ ignoreWhitespace: false },
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("+ line 2");
expect(value).toContain(" line 1");
});
it("should detect removed lines", async () => {
const result = await runTextDiff(
{ values: { original: { text: "line 1\nline 2" }, modified: { text: "line 1" } } },
{ ignoreWhitespace: false },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("- line 2");
expect(value).toContain(" line 1");
});
it("should detect identical texts", async () => {
const result = await runTextDiff(
{ values: { original: { text: "same text" }, modified: { text: "same text" } } },
{ ignoreWhitespace: false },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain(" same text");
expect(value).toContain("0 added, 0 removed");
});
it("should show summary with counts", async () => {
const result = await runTextDiff(
{ values: { original: { text: "a\nb\nc" }, modified: { text: "a\nx\nc\nd" } } },
{ ignoreWhitespace: false },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("Summary:");
expect(value).toContain("added");
expect(value).toContain("removed");
expect(value).toContain("unchanged");
});
it("should ignore whitespace when option is set", async () => {
const result = await runTextDiff(
{ values: { original: { text: "hello world" }, modified: { text: "hello world" } } },
{ ignoreWhitespace: true },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("0 added, 0 removed");
});
it("should throw when one side is missing", async () => {
await expect(
runTextDiff(
{ values: { original: { text: "just one text" } } },
{ ignoreWhitespace: false },
mockContext
)
).rejects.toThrow("Both original and modified text are required");
});
it("should throw on empty input", async () => {
await expect(
runTextDiff({ values: {} }, { ignoreWhitespace: false }, mockContext)
).rejects.toThrow("No text provided");
});
it("should handle completely different texts", async () => {
const result = await runTextDiff(
{ values: { original: { text: "aaa\nbbb" }, modified: { text: "xxx\nyyy" } } },
{ ignoreWhitespace: false },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("- aaa");
expect(value).toContain("- bbb");
expect(value).toContain("+ xxx");
expect(value).toContain("+ yyy");
});
it("should still support legacy separator-based input", async () => {
const result = await runTextDiff(
{ text: "line 1\n---\nline 1\nline 2" },
{ ignoreWhitespace: false },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("+ line 2");
});
});

103
src/tools/text-diff/run.ts Normal file
View File

@@ -0,0 +1,103 @@
import { getTextInput, 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 { TextDiffOptions } from "./index";
function lcs(a: string[], b: string[]): number[][] {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp;
}
function computeDiff(a: string[], b: string[], dp: number[][]): Array<{ type: "same" | "added" | "removed"; line: string }> {
const result: Array<{ type: "same" | "added" | "removed"; line: string }> = [];
let i = a.length;
let j = b.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
result.unshift({ type: "same", line: a[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({ type: "added", line: b[j - 1] });
j--;
} else if (i > 0) {
result.unshift({ type: "removed", line: a[i - 1] });
i--;
}
}
return result;
}
export async function runTextDiff(
input: ToolInput,
options: TextDiffOptions,
_context: ToolContext
): Promise<ToolResult> {
void _context;
let textA = getTextInput(input, "original");
let textB = getTextInput(input, "modified");
if ((!textA || !textB) && input.text) {
const separatorIndex = input.text.indexOf("\n---\n");
if (separatorIndex >= 0) {
textA = input.text.substring(0, separatorIndex);
textB = input.text.substring(separatorIndex + 5);
}
}
if (!textA.trim() && !textB.trim()) {
throw new Error("No text provided. Fill both original and modified text.");
}
if (!textA.trim() || !textB.trim()) {
throw new Error("Both original and modified text are required.");
}
if (options.ignoreWhitespace) {
textA = textA.replace(/\s+/g, " ").trim();
textB = textB.replace(/\s+/g, " ").trim();
}
const linesA = textA.split("\n");
const linesB = textB.split("\n");
const dp = lcs(linesA, linesB);
const diff = computeDiff(linesA, linesB, dp);
const output = diff.map((entry) => {
switch (entry.type) {
case "added":
return `+ ${entry.line}`;
case "removed":
return `- ${entry.line}`;
case "same":
return ` ${entry.line}`;
}
}).join("\n");
const added = diff.filter((d) => d.type === "added").length;
const removed = diff.filter((d) => d.type === "removed").length;
const same = diff.filter((d) => d.type === "same").length;
const summary = `\n\n--- Summary: ${added} added, ${removed} removed, ${same} unchanged ---`;
return {
type: "text",
value: output + summary,
};
}

View File

@@ -0,0 +1,54 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runTimestampConverter } from "./run";
export interface TimestampOptions {
mode: "timestamp-to-date" | "date-to-timestamp";
unit: "seconds" | "milliseconds";
}
export const timestampConverterPlugin: PlimiPlugin<TimestampOptions> = {
manifest: {
id: "dev-timestamp",
name: "Timestamp Converter",
description: "Convert between Unix timestamps and human-readable dates.",
category: "developer",
version: "1.0.0",
tags: ["timestamp", "unix", "date", "time", "epoch"],
input: { type: "text", placeholder: "e.g. 1700000000 or 2024-01-15T12:00:00Z", multiline: false },
output: { type: "json" },
example: "1700000000",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "mode",
label: "Mode",
defaultValue: "timestamp-to-date",
options: [
{ label: "Timestamp → Date", value: "timestamp-to-date" },
{ label: "Date → Timestamp", value: "date-to-timestamp" },
],
},
{
type: "select",
key: "unit",
label: "Timestamp Unit",
defaultValue: "seconds",
options: [
{ label: "Seconds", value: "seconds" },
{ label: "Milliseconds", value: "milliseconds" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runTimestampConverter,
};

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi } from "vitest";
import { runTimestampConverter } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Timestamp Converter", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should convert a Unix seconds timestamp to date", async () => {
const result = await runTimestampConverter(
{ text: "0" },
{ mode: "timestamp-to-date", unit: "seconds" },
mockContext
);
expect(result.type).toBe("json");
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.iso).toBe("1970-01-01T00:00:00.000Z");
expect(value.unixSeconds).toBe(0);
expect(value.dayOfWeek).toBe("Thursday");
});
it("should convert a known timestamp correctly", async () => {
const result = await runTimestampConverter(
{ text: "1700000000" },
{ mode: "timestamp-to-date", unit: "seconds" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.iso).toBe("2023-11-14T22:13:20.000Z");
expect(value.unixSeconds).toBe(1700000000);
});
it("should convert milliseconds timestamp to date", async () => {
const result = await runTimestampConverter(
{ text: "1700000000000" },
{ mode: "timestamp-to-date", unit: "milliseconds" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.iso).toBe("2023-11-14T22:13:20.000Z");
});
it("should convert an ISO date string to Unix timestamp (seconds)", async () => {
const result = await runTimestampConverter(
{ text: "2024-01-15T12:00:00Z" },
{ mode: "date-to-timestamp", unit: "seconds" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
const both = value.both as Record<string, unknown>;
expect(value.unixSeconds).toBe(1705320000);
expect(both.seconds).toBe(1705320000);
});
it("should convert an ISO date string to Unix timestamp (milliseconds)", async () => {
const result = await runTimestampConverter(
{ text: "2024-01-15T12:00:00Z" },
{ mode: "date-to-timestamp", unit: "milliseconds" },
mockContext
);
const value = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(value.unixMillis).toBe(1705320000000);
});
it("should throw on empty input", async () => {
await expect(
runTimestampConverter(
{ text: "" },
{ mode: "timestamp-to-date", unit: "seconds" },
mockContext
)
).rejects.toThrow("No input provided");
});
it("should throw on invalid timestamp", async () => {
await expect(
runTimestampConverter(
{ text: "not-a-number" },
{ mode: "timestamp-to-date", unit: "seconds" },
mockContext
)
).rejects.toThrow("Invalid timestamp");
});
it("should throw on invalid date string", async () => {
await expect(
runTimestampConverter(
{ text: "not-a-date" },
{ mode: "date-to-timestamp", unit: "seconds" },
mockContext
)
).rejects.toThrow("Invalid date string");
});
});

View File

@@ -0,0 +1,77 @@
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 { TimestampOptions } from "./index";
export async function runTimestampConverter(
input: ToolInput,
options: TimestampOptions,
context?: ToolContext
): Promise<ToolResult> {
void context;
const text = (input.text || "").trim();
if (!text) {
throw new Error("No input provided.");
}
if (options.mode === "timestamp-to-date") {
let ts = Number(text);
if (isNaN(ts)) {
throw new Error("Invalid timestamp. Enter a numeric Unix timestamp.");
}
if (options.unit === "seconds") {
ts = ts * 1000;
}
const date = new Date(ts);
if (isNaN(date.getTime())) {
throw new Error("Invalid timestamp value.");
}
return {
type: "json",
value: {
input: text,
iso: date.toISOString(),
utc: date.toUTCString(),
local: date.toLocaleString(),
date: date.toISOString().split("T")[0],
time: date.toISOString().split("T")[1].replace("Z", ""),
unixSeconds: Math.floor(date.getTime() / 1000),
unixMillis: date.getTime(),
dayOfWeek: date.toLocaleDateString("en-US", { weekday: "long" }),
},
};
} else {
let date: Date;
if (/^\d{4}-\d{2}-\d{2}(T|\s)/.test(text)) {
date = new Date(text);
} else {
date = new Date(text);
}
if (isNaN(date.getTime())) {
throw new Error("Invalid date string. Use ISO 8601 format like 2024-01-15T12:00:00Z.");
}
const unixSeconds = Math.floor(date.getTime() / 1000);
const unixMillis = date.getTime();
return {
type: "json",
value: {
input: text,
iso: date.toISOString(),
utc: date.toUTCString(),
unixSeconds: options.unit === "seconds" ? unixSeconds : undefined,
unixMillis: options.unit === "milliseconds" ? unixMillis : undefined,
both: {
seconds: unixSeconds,
milliseconds: unixMillis,
},
},
};
}
}

View File

@@ -0,0 +1,54 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runUrlEncoder } from "./run";
export interface UrlEncoderOptions {
mode: "encode" | "decode";
component: "full" | "component";
}
export const urlEncoderPlugin: PlimiPlugin<UrlEncoderOptions> = {
manifest: {
id: "dev-url",
name: "URL Encoder",
description: "Encode or decode URLs and URI components.",
category: "developer",
version: "1.0.0",
tags: ["url", "encode", "decode", "uri"],
input: { type: "text" },
output: { type: "text" },
example: "https://plimi.app/search?q=hello world&lang=en",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "mode",
label: "Mode",
defaultValue: "encode",
options: [
{ label: "Encode", value: "encode" },
{ label: "Decode", value: "decode" },
],
},
{
type: "select",
key: "component",
label: "Target",
defaultValue: "component",
options: [
{ label: "Full URL", value: "full" },
{ label: "URI Component", value: "component" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runUrlEncoder,
};

View File

@@ -0,0 +1,28 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { UrlEncoderOptions } from "./index";
export async function runUrlEncoder(
input: ToolInput,
options: UrlEncoderOptions
): Promise<ToolResult> {
const text = input.text || "";
if (!text) {
throw new Error("No text provided.");
}
try {
const outputStr =
options.mode === "encode"
? options.component === "full" ? encodeURI(text) : encodeURIComponent(text)
: options.component === "full" ? decodeURI(text) : decodeURIComponent(text);
return {
type: "text",
value: outputStr,
};
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`Failed to process URL: ${message}`, { cause });
}
}

View File

@@ -0,0 +1,41 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runUuidGenerator } from "./run";
export interface UuidOptions {
count: number;
}
export const uuidGeneratorPlugin: PlimiPlugin<UuidOptions> = {
manifest: {
id: "crypto-uuid",
name: "UUID Generator",
description: "Generate cryptographically secure v4 UUIDs.",
category: "crypto",
version: "1.0.0",
tags: ["uuid", "guid", "crypto", "random"],
input: { type: "none" },
output: { type: "text" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "slider",
key: "count",
label: "Count",
defaultValue: 1,
min: 1,
max: 100,
step: 1,
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runUuidGenerator,
};

View File

@@ -0,0 +1,24 @@
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 { UuidOptions } from "./index";
export async function runUuidGenerator(
_input: ToolInput,
options: UuidOptions,
_context: ToolContext
): Promise<ToolResult> {
void _input;
void _context;
const count = options.count || 1;
const uuids: string[] = [];
for (let i = 0; i < count; i++) {
uuids.push(crypto.randomUUID());
}
return {
type: "text",
value: uuids.join("\n"),
};
}

View File

@@ -0,0 +1,52 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runVariablesConverter } from "./run";
export interface VariablesConverterOptions {
toFormat: "auto" | "constant" | "dot" | "camel" | "pascal" | "snake" | "kebab";
}
export const variablesConverterPlugin: PlimiPlugin<VariablesConverterOptions> = {
manifest: {
id: "dev-varconvert",
name: "Variables Converter",
description: "Convert variable names between camelCase, PascalCase, snake_case, constant_case (A_B_C), dot.notation (a.b.c), and kebab-case.",
category: "developer",
version: "1.0.0",
tags: ["variable", "casing", "convert", "camelcase", "snakecase", "naming"],
input: {
type: "text",
placeholder: "e.g. user.profile.name or USER_PROFILE_NAME...",
multiline: false,
},
output: { type: "json" },
example: "user.profile.name",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "toFormat",
label: "Primary Format",
defaultValue: "auto",
options: [
{ label: "Auto (Detect)", value: "auto" },
{ label: "Constant Case (A_B_C)", value: "constant" },
{ label: "Dot Notation (a.b.c)", value: "dot" },
{ label: "Camel Case (aBC)", value: "camel" },
{ label: "Pascal Case (ABC)", value: "pascal" },
{ label: "Snake Case (a_b_c)", value: "snake" },
{ label: "Kebab Case (a-b-c)", value: "kebab" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runVariablesConverter,
};

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from "vitest";
import { runVariablesConverter } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Variables Converter", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should convert dot notation to constant case and others", async () => {
const result = await runVariablesConverter(
{ text: "a.b.c" },
{ toFormat: "constant" },
mockContext
);
expect(result.type).toBe("json");
const val = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(val.constant).toBe("A_B_C");
expect(val.dot).toBe("a.b.c");
expect(val.camel).toBe("aBC");
expect(val.pascal).toBe("ABC");
expect(val.snake).toBe("a_b_c");
expect(val.kebab).toBe("a-b-c");
expect(val.primary).toBe("A_B_C");
});
it("should convert screaming snake case (constant) to dot notation", async () => {
const result = await runVariablesConverter(
{ text: "USER_PROFILE_NAME" },
{ toFormat: "dot" },
mockContext
);
const val = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(val.constant).toBe("USER_PROFILE_NAME");
expect(val.dot).toBe("user.profile.name");
expect(val.camel).toBe("userProfileName");
expect(val.pascal).toBe("UserProfileName");
expect(val.primary).toBe("user.profile.name");
});
it("should handle camelCase inputs correctly", async () => {
const result = await runVariablesConverter(
{ text: "myApiKey" },
{ toFormat: "kebab" },
mockContext
);
const val = (result as { type: "json"; value: Record<string, unknown> }).value;
expect(val.constant).toBe("MY_API_KEY");
expect(val.kebab).toBe("my-api-key");
expect(val.primary).toBe("my-api-key");
});
it("should handle auto mode detection correctly", async () => {
// 1. Dot notation auto-converts to Constant Case
const resDot = await runVariablesConverter(
{ text: "a.b.c" },
{ toFormat: "auto" },
mockContext
);
expect((resDot as { type: "json"; value: Record<string, unknown> }).value.primary).toBe("A_B_C");
// 2. Kebab notation auto-converts to Constant Case
const resKebab = await runVariablesConverter(
{ text: "a-b-c" },
{ toFormat: "auto" },
mockContext
);
expect((resKebab as { type: "json"; value: Record<string, unknown> }).value.primary).toBe("A_B_C");
// 3. Constant notation auto-converts to Dot Notation
const resConstant = await runVariablesConverter(
{ text: "A_B_C" },
{ toFormat: "auto" },
mockContext
);
expect((resConstant as { type: "json"; value: Record<string, unknown> }).value.primary).toBe("a.b.c");
});
it("should throw on empty input", async () => {
await expect(
runVariablesConverter({ text: "" }, { toFormat: "auto" }, mockContext)
).rejects.toThrow("No variable name provided");
});
});

View File

@@ -0,0 +1,84 @@
import { getTextInput, 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 { VariablesConverterOptions } from "./index";
function tokenize(input: string): string[] {
// Add space boundaries for camelCase/PascalCase transitions
const spaced = input
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z])([A-Z][a-z])/g, "$1 $2");
// Split on dots, underscores, hyphens, or spaces
return spaced
.split(/[\s._-]+/)
.map((w) => w.trim().toLowerCase())
.filter(Boolean);
}
export async function runVariablesConverter(
input: ToolInput,
options: VariablesConverterOptions,
context?: ToolContext
): Promise<ToolResult> {
void context;
const text = getTextInput(input).trim();
if (!text) {
throw new Error("No variable name provided.");
}
const words = tokenize(text);
if (words.length === 0) {
throw new Error("Could not parse any variable names from input.");
}
const dot = words.join(".");
const constant = words.map((w) => w.toUpperCase()).join("_");
const camel = words
.map((w, idx) => (idx === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)))
.join("");
const pascal = words
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join("");
const snake = words.join("_");
const kebab = words.join("-");
const primary = (() => {
if (options.toFormat === "auto") {
// If input is uppercase or contains underscores, auto-convert to dot notation
const isUpperOrSnake = text === text.toUpperCase() || text.includes("_");
return isUpperOrSnake ? dot : constant;
}
switch (options.toFormat) {
case "dot":
return dot;
case "camel":
return camel;
case "pascal":
return pascal;
case "snake":
return snake;
case "kebab":
return kebab;
case "constant":
default:
return constant;
}
})();
return {
type: "json",
value: {
input: text,
constant,
dot,
camel,
pascal,
snake,
kebab,
primary,
},
};
}