First implementation of Plimi
This commit is contained in:
43
src/tools/base64/index.ts
Normal file
43
src/tools/base64/index.ts
Normal 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,
|
||||
};
|
||||
48
src/tools/base64/run.test.ts
Normal file
48
src/tools/base64/run.test.ts
Normal 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
37
src/tools/base64/run.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
src/tools/color-converter/index.ts
Normal file
44
src/tools/color-converter/index.ts
Normal 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,
|
||||
};
|
||||
93
src/tools/color-converter/run.test.ts
Normal file
93
src/tools/color-converter/run.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
151
src/tools/color-converter/run.ts
Normal file
151
src/tools/color-converter/run.ts
Normal 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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
75
src/tools/csv-tools/index.ts
Normal file
75
src/tools/csv-tools/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
|
||||
import { runCsvTools } from "./run";
|
||||
|
||||
export interface CsvToolsOptions {
|
||||
mode: "csv-to-json" | "json-to-csv";
|
||||
delimiter: "," | ";" | "\t";
|
||||
hasHeaderRow: boolean;
|
||||
prettyJson: boolean;
|
||||
}
|
||||
|
||||
export const csvToolsPlugin: PlimiPlugin<CsvToolsOptions> = {
|
||||
manifest: {
|
||||
id: "csv-tools",
|
||||
name: "CSV <-> JSON Converter",
|
||||
description: "Convert CSV text to JSON objects, or convert JSON array of objects/arrays to CSV.",
|
||||
category: "developer",
|
||||
version: "1.0.0",
|
||||
tags: ["csv", "json", "converter", "parser", "format"],
|
||||
input: {
|
||||
type: "text",
|
||||
label: "Input Content",
|
||||
placeholder: "Paste CSV text or JSON array here...",
|
||||
multiline: true,
|
||||
rows: 10,
|
||||
},
|
||||
output: { type: "text" },
|
||||
example: "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles",
|
||||
offlineReady: true,
|
||||
},
|
||||
|
||||
optionsSchema: {
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
key: "mode",
|
||||
label: "Conversion Mode",
|
||||
defaultValue: "csv-to-json",
|
||||
options: [
|
||||
{ label: "CSV to JSON", value: "csv-to-json" },
|
||||
{ label: "JSON to CSV", value: "json-to-csv" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: "delimiter",
|
||||
label: "Delimiter / Separator",
|
||||
defaultValue: ",",
|
||||
options: [
|
||||
{ label: "Comma (,)", value: "," },
|
||||
{ label: "Semicolon (;)", value: ";" },
|
||||
{ label: "Tab (\\t)", value: "\t" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
key: "hasHeaderRow",
|
||||
label: "First row is header (CSV -> JSON)",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
type: "boolean",
|
||||
key: "prettyJson",
|
||||
label: "Pretty Print JSON",
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
cancelable: false,
|
||||
worker: false,
|
||||
},
|
||||
|
||||
run: runCsvTools,
|
||||
};
|
||||
138
src/tools/csv-tools/run.test.ts
Normal file
138
src/tools/csv-tools/run.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runCsvTools } from "./run";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
|
||||
describe("CSV <-> JSON Converter Plugin", () => {
|
||||
const mockContext: ToolContext = {
|
||||
signal: new AbortController().signal,
|
||||
reportProgress: vi.fn(),
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
it("should convert simple CSV to JSON with headers", async () => {
|
||||
const csv = "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles";
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed).toEqual([
|
||||
{ name: "John Doe", age: "30", city: "New York" },
|
||||
{ name: "Jane Smith", age: "25", city: "Los Angeles" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle commas and newlines inside quoted fields", async () => {
|
||||
const csv = 'name,notes\nJohn,"Likes apples, oranges, and bananas"\nJane,"Likes reading\nand cycling"';
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed[0].notes).toBe("Likes apples, oranges, and bananas");
|
||||
expect(parsed[1].notes).toBe("Likes reading\nand cycling");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle escaped quotes inside quotes", async () => {
|
||||
const csv = 'name,description\nJohn,"Known as ""The Apple King"""';
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed[0].description).toBe('Known as "The Apple King"');
|
||||
}
|
||||
});
|
||||
|
||||
it("should support custom delimiters like Semicolon", async () => {
|
||||
const csv = "name;age\nJohn Doe;30";
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ";", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed[0]).toEqual({ name: "John Doe", age: "30" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse without header rows", async () => {
|
||||
const csv = "John,30\nJane,25";
|
||||
const result = await runCsvTools(
|
||||
{ text: csv },
|
||||
{ mode: "csv-to-json", delimiter: ",", hasHeaderRow: false, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
const parsed = JSON.parse(result.value);
|
||||
expect(parsed).toEqual([
|
||||
["John", "30"],
|
||||
["Jane", "25"],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should convert JSON array of objects to CSV", async () => {
|
||||
const json = JSON.stringify([
|
||||
{ name: "John Doe", age: 30, city: "New York" },
|
||||
{ name: "Jane Smith", age: 25, city: "Los Angeles" },
|
||||
]);
|
||||
const result = await runCsvTools(
|
||||
{ text: json },
|
||||
{ mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
expect(result.value).toBe(
|
||||
"name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should convert JSON array of arrays to CSV", async () => {
|
||||
const json = JSON.stringify([
|
||||
["John", 30],
|
||||
["Jane", 25],
|
||||
]);
|
||||
const result = await runCsvTools(
|
||||
{ text: json },
|
||||
{ mode: "json-to-csv", delimiter: ";", hasHeaderRow: true, prettyJson: false },
|
||||
mockContext
|
||||
);
|
||||
expect(result.type).toBe("text");
|
||||
if (result.type === "text") {
|
||||
expect(result.value).toBe("John;30\nJane;25");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error for invalid JSON in JSON-to-CSV mode", async () => {
|
||||
await expect(
|
||||
runCsvTools({ text: "not-json" }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
|
||||
).rejects.toThrow("Invalid input: Not a valid JSON string");
|
||||
});
|
||||
|
||||
it("should throw error for non-array JSON", async () => {
|
||||
await expect(
|
||||
runCsvTools({ text: '{"a": 1}' }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
|
||||
).rejects.toThrow("JSON content must be an array");
|
||||
});
|
||||
});
|
||||
199
src/tools/csv-tools/run.ts
Normal file
199
src/tools/csv-tools/run.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { ToolInput } from "../../core/io/input-types";
|
||||
import type { ToolResult } from "../../core/io/output-types";
|
||||
import type { ToolContext } from "../../core/plugins/plugin-types";
|
||||
import type { CsvToolsOptions } from "./index";
|
||||
|
||||
export function parseCsv(text: string, delimiter: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentField = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (nextChar === '"') {
|
||||
// Escaped quote
|
||||
currentField += '"';
|
||||
i++; // Skip next quote
|
||||
} else {
|
||||
// Closing quote
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
} else {
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === delimiter) {
|
||||
currentRow.push(currentField);
|
||||
currentField = "";
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
currentRow.push(currentField);
|
||||
currentField = "";
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
|
||||
if (char === "\r" && nextChar === "\n") {
|
||||
i++; // Skip \n in \r\n
|
||||
}
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push final field/row if anything remains
|
||||
if (currentField !== "" || currentRow.length > 0) {
|
||||
currentRow.push(currentField);
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
// Filter out completely empty trailing row (e.g. from file ending with a newline)
|
||||
if (rows.length > 0) {
|
||||
const lastRow = rows[rows.length - 1];
|
||||
if (lastRow.length === 1 && lastRow[0] === "") {
|
||||
rows.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function escapeCsvField(val: unknown, delimiter: string): string {
|
||||
if (val === null || val === undefined) return "";
|
||||
const str = String(val);
|
||||
const needsQuotes =
|
||||
str.includes(delimiter) ||
|
||||
str.includes('"') ||
|
||||
str.includes("\n") ||
|
||||
str.includes("\r");
|
||||
|
||||
if (needsQuotes) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export async function runCsvTools(
|
||||
input: ToolInput,
|
||||
options: CsvToolsOptions,
|
||||
context: ToolContext
|
||||
): Promise<ToolResult> {
|
||||
const text = (input.text ?? "").trim();
|
||||
if (!text) {
|
||||
throw new Error("Please enter input text to convert.");
|
||||
}
|
||||
|
||||
const { mode, delimiter, hasHeaderRow, prettyJson } = options;
|
||||
|
||||
if (mode === "csv-to-json") {
|
||||
context.reportProgress({ percentage: 20, message: "Parsing CSV..." });
|
||||
const parsedRows = parseCsv(text, delimiter);
|
||||
|
||||
if (parsedRows.length === 0) {
|
||||
return {
|
||||
type: "text",
|
||||
value: "[]",
|
||||
language: "json",
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 60, message: "Structuring JSON..." });
|
||||
|
||||
if (hasHeaderRow) {
|
||||
const headers = parsedRows[0].map(h => h.trim());
|
||||
const objects: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < parsedRows.length; i++) {
|
||||
const row = parsedRows[i];
|
||||
const obj: Record<string, string> = {};
|
||||
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
obj[headers[j]] = row[j] ?? "";
|
||||
}
|
||||
objects.push(obj);
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: JSON.stringify(objects, null, prettyJson ? 2 : undefined),
|
||||
language: "json",
|
||||
};
|
||||
} else {
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: JSON.stringify(parsedRows, null, prettyJson ? 2 : undefined),
|
||||
language: "json",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// json-to-csv mode
|
||||
context.reportProgress({ percentage: 25, message: "Parsing JSON..." });
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw new Error("Invalid input: Not a valid JSON string. JSON to CSV mode requires a valid JSON array.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Invalid input: JSON content must be an array of objects or an array of arrays.");
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
type: "text",
|
||||
value: "",
|
||||
language: "plain",
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress({ percentage: 65, message: "Serializing to CSV..." });
|
||||
|
||||
const firstItem = data[0];
|
||||
|
||||
if (Array.isArray(firstItem)) {
|
||||
// Array of arrays
|
||||
const csvLines = (data as unknown[][]).map(row =>
|
||||
row.map(cell => escapeCsvField(cell, delimiter)).join(delimiter)
|
||||
);
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: csvLines.join("\n"),
|
||||
language: "plain",
|
||||
};
|
||||
} else if (typeof firstItem === "object" && firstItem !== null) {
|
||||
// Array of objects
|
||||
// Collect unique keys across all objects to ensure all properties are included
|
||||
const keysSet = new Set<string>();
|
||||
data.forEach(item => {
|
||||
if (typeof item === "object" && item !== null) {
|
||||
Object.keys(item).forEach(k => keysSet.add(k));
|
||||
}
|
||||
});
|
||||
const keys = Array.from(keysSet);
|
||||
|
||||
const headerLine = keys.map(k => escapeCsvField(k, delimiter)).join(delimiter);
|
||||
const csvLines = (data as Record<string, unknown>[]).map(item =>
|
||||
keys.map(key => escapeCsvField(item[key], delimiter)).join(delimiter)
|
||||
);
|
||||
|
||||
context.reportProgress({ percentage: 100, message: "Done" });
|
||||
return {
|
||||
type: "text",
|
||||
value: [headerLine, ...csvLines].join("\n"),
|
||||
language: "plain",
|
||||
};
|
||||
} else {
|
||||
throw new Error("Invalid input: Array elements must be either objects or arrays.");
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/tools/exif-scrubber/index.ts
Normal file
27
src/tools/exif-scrubber/index.ts
Normal 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,
|
||||
};
|
||||
25
src/tools/exif-scrubber/run.test.ts
Normal file
25
src/tools/exif-scrubber/run.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
103
src/tools/exif-scrubber/run.ts
Normal file
103
src/tools/exif-scrubber/run.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
60
src/tools/file-checksum-verifier/index.ts
Normal file
60
src/tools/file-checksum-verifier/index.ts
Normal 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,
|
||||
};
|
||||
87
src/tools/file-checksum-verifier/run.test.ts
Normal file
87
src/tools/file-checksum-verifier/run.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
82
src/tools/file-checksum-verifier/run.ts
Normal file
82
src/tools/file-checksum-verifier/run.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
src/tools/hash-generator/index.ts
Normal file
56
src/tools/hash-generator/index.ts
Normal 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,
|
||||
};
|
||||
33
src/tools/hash-generator/run.ts
Normal file
33
src/tools/hash-generator/run.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
43
src/tools/html-entity/index.ts
Normal file
43
src/tools/html-entity/index.ts
Normal 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,
|
||||
};
|
||||
109
src/tools/html-entity/run.test.ts
Normal file
109
src/tools/html-entity/run.test.ts
Normal 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("<div class="test">Hello & World</div>");
|
||||
});
|
||||
|
||||
it("should decode basic HTML entities", async () => {
|
||||
const result = await runHtmlEntity(
|
||||
{ text: "<div>Hello & World</div>" },
|
||||
{ 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("€");
|
||||
expect(value).toContain("©");
|
||||
});
|
||||
|
||||
it("should decode special symbol entities", async () => {
|
||||
const result = await runHtmlEntity(
|
||||
{ text: "© 2024 — 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: "ABC" },
|
||||
{ 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: "ABC" },
|
||||
{ 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("'");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
82
src/tools/html-entity/run.ts
Normal file
82
src/tools/html-entity/run.ts
Normal 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"©": "©",
|
||||
"®": "®",
|
||||
"™": "™",
|
||||
"—": "—",
|
||||
"–": "–",
|
||||
"«": "«",
|
||||
"»": "»",
|
||||
"°": "°",
|
||||
"±": "±",
|
||||
"×": "×",
|
||||
"÷": "÷",
|
||||
"€": "€",
|
||||
"£": "£",
|
||||
"¥": "¥",
|
||||
"¢": "¢",
|
||||
"§": "§",
|
||||
"¶": "¶",
|
||||
"•": "•",
|
||||
"…": "…",
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
780
src/tools/image-editor/ImageEditorUi.tsx
Normal file
780
src/tools/image-editor/ImageEditorUi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/tools/image-editor/index.ts
Normal file
47
src/tools/image-editor/index.ts
Normal 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,
|
||||
};
|
||||
60
src/tools/image-editor/run.ts
Normal file
60
src/tools/image-editor/run.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
224
src/tools/image-optimizer/ImageOptimizerUi.tsx
Normal file
224
src/tools/image-optimizer/ImageOptimizerUi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/tools/image-optimizer/index.ts
Normal file
67
src/tools/image-optimizer/index.ts
Normal 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,
|
||||
};
|
||||
40
src/tools/image-optimizer/run.test.ts
Normal file
40
src/tools/image-optimizer/run.test.ts
Normal 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.
|
||||
});
|
||||
108
src/tools/image-optimizer/run.ts
Normal file
108
src/tools/image-optimizer/run.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
304
src/tools/image-redactor/ImageRedactorUi.tsx
Normal file
304
src/tools/image-redactor/ImageRedactorUi.tsx
Normal 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} × {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>
|
||||
);
|
||||
}
|
||||
45
src/tools/image-redactor/index.ts
Normal file
45
src/tools/image-redactor/index.ts
Normal 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,
|
||||
};
|
||||
25
src/tools/image-redactor/run.test.ts
Normal file
25
src/tools/image-redactor/run.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
106
src/tools/image-redactor/run.ts
Normal file
106
src/tools/image-redactor/run.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
src/tools/json-formatter/index.ts
Normal file
45
src/tools/json-formatter/index.ts
Normal 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,
|
||||
};
|
||||
35
src/tools/json-formatter/run.ts
Normal file
35
src/tools/json-formatter/run.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
30
src/tools/jwt-decoder/index.ts
Normal file
30
src/tools/jwt-decoder/index.ts
Normal 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,
|
||||
};
|
||||
81
src/tools/jwt-decoder/run.test.ts
Normal file
81
src/tools/jwt-decoder/run.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
92
src/tools/jwt-decoder/run.ts
Normal file
92
src/tools/jwt-decoder/run.ts
Normal 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)",
|
||||
},
|
||||
};
|
||||
}
|
||||
54
src/tools/lorem-ipsum/index.ts
Normal file
54
src/tools/lorem-ipsum/index.ts
Normal 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,
|
||||
};
|
||||
82
src/tools/lorem-ipsum/run.test.ts
Normal file
82
src/tools/lorem-ipsum/run.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
src/tools/lorem-ipsum/run.ts
Normal file
89
src/tools/lorem-ipsum/run.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
32
src/tools/markdown-to-html/index.ts
Normal file
32
src/tools/markdown-to-html/index.ts
Normal 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,
|
||||
};
|
||||
106
src/tools/markdown-to-html/run.test.ts
Normal file
106
src/tools/markdown-to-html/run.test.ts
Normal 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: "" },
|
||||
{},
|
||||
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");
|
||||
});
|
||||
});
|
||||
65
src/tools/markdown-to-html/run.ts
Normal file
65
src/tools/markdown-to-html/run.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
58
src/tools/number-base/index.ts
Normal file
58
src/tools/number-base/index.ts
Normal 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,
|
||||
};
|
||||
102
src/tools/number-base/run.test.ts
Normal file
102
src/tools/number-base/run.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
src/tools/number-base/run.ts
Normal file
53
src/tools/number-base/run.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
97
src/tools/password-generator/index.ts
Normal file
97
src/tools/password-generator/index.ts
Normal 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,
|
||||
};
|
||||
124
src/tools/password-generator/run.test.ts
Normal file
124
src/tools/password-generator/run.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
146
src/tools/password-generator/run.ts
Normal file
146
src/tools/password-generator/run.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
30
src/tools/password-generator/words.ts
Normal file
30
src/tools/password-generator/words.ts
Normal 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"
|
||||
];
|
||||
30
src/tools/pdf-merger/index.ts
Normal file
30
src/tools/pdf-merger/index.ts
Normal 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(),
|
||||
};
|
||||
32
src/tools/pdf-merger/run.test.ts
Normal file
32
src/tools/pdf-merger/run.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
62
src/tools/pdf-merger/run.ts
Normal file
62
src/tools/pdf-merger/run.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
87
src/tools/pdf-merger/worker.ts
Normal file
87
src/tools/pdf-merger/worker.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
54
src/tools/pdf-splitter/index.ts
Normal file
54
src/tools/pdf-splitter/index.ts
Normal 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,
|
||||
};
|
||||
117
src/tools/pdf-splitter/run.test.ts
Normal file
117
src/tools/pdf-splitter/run.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
134
src/tools/pdf-splitter/run.ts
Normal file
134
src/tools/pdf-splitter/run.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
82
src/tools/qr-code-generator/index.ts
Normal file
82
src/tools/qr-code-generator/index.ts
Normal 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,
|
||||
};
|
||||
51
src/tools/qr-code-generator/run.test.ts
Normal file
51
src/tools/qr-code-generator/run.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
107
src/tools/qr-code-generator/run.ts
Normal file
107
src/tools/qr-code-generator/run.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
58
src/tools/regex-tester/index.ts
Normal file
58
src/tools/regex-tester/index.ts
Normal 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,
|
||||
};
|
||||
101
src/tools/regex-tester/run.test.ts
Normal file
101
src/tools/regex-tester/run.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
107
src/tools/regex-tester/run.ts
Normal file
107
src/tools/regex-tester/run.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
46
src/tools/text-case/index.ts
Normal file
46
src/tools/text-case/index.ts
Normal 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,
|
||||
};
|
||||
41
src/tools/text-case/run.ts
Normal file
41
src/tools/text-case/run.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
src/tools/text-diff/index.ts
Normal file
56
src/tools/text-diff/index.ts
Normal 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,
|
||||
};
|
||||
107
src/tools/text-diff/run.test.ts
Normal file
107
src/tools/text-diff/run.test.ts
Normal 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
103
src/tools/text-diff/run.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
54
src/tools/timestamp-converter/index.ts
Normal file
54
src/tools/timestamp-converter/index.ts
Normal 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,
|
||||
};
|
||||
97
src/tools/timestamp-converter/run.test.ts
Normal file
97
src/tools/timestamp-converter/run.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
77
src/tools/timestamp-converter/run.ts
Normal file
77
src/tools/timestamp-converter/run.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
54
src/tools/url-encoder/index.ts
Normal file
54
src/tools/url-encoder/index.ts
Normal 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,
|
||||
};
|
||||
28
src/tools/url-encoder/run.ts
Normal file
28
src/tools/url-encoder/run.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
41
src/tools/uuid-generator/index.ts
Normal file
41
src/tools/uuid-generator/index.ts
Normal 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,
|
||||
};
|
||||
24
src/tools/uuid-generator/run.ts
Normal file
24
src/tools/uuid-generator/run.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
52
src/tools/variables-converter/index.ts
Normal file
52
src/tools/variables-converter/index.ts
Normal 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,
|
||||
};
|
||||
86
src/tools/variables-converter/run.test.ts
Normal file
86
src/tools/variables-converter/run.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
84
src/tools/variables-converter/run.ts
Normal file
84
src/tools/variables-converter/run.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user