Release Text Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Text Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Text Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>Transform and inspect plain text locally in the browser.</p>
|
||||
<p>
|
||||
All processing is performed in this browser. Imported data is treated as
|
||||
untrusted and bounded before parsing.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
decodeText,
|
||||
encodeText,
|
||||
triggerBlobDownload,
|
||||
type TextEncoding,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
applyPipeline,
|
||||
createStep,
|
||||
textInventory,
|
||||
type PipelineResult,
|
||||
type StepType,
|
||||
type TransformStep,
|
||||
} from "../text/pipeline";
|
||||
|
||||
const initial = " Crème brûlée \r\nAlpha\nalpha\r\n Cedar \n";
|
||||
const initialSteps: TransformStep[] = [
|
||||
createStep("trim-lines"),
|
||||
createStep("dedupe-lines"),
|
||||
createStep("normalize"),
|
||||
];
|
||||
const STEP_LABELS: Record<StepType, string> = {
|
||||
"line-endings": "Line endings",
|
||||
"trim-lines": "Trim every line",
|
||||
"trim-document": "Trim document",
|
||||
"collapse-whitespace": "Collapse whitespace",
|
||||
"sort-lines": "Sort lines",
|
||||
"dedupe-lines": "Deduplicate lines",
|
||||
case: "Change case",
|
||||
normalize: "Unicode normalization",
|
||||
transliterate: "Best-effort transliteration",
|
||||
escape: "Escape / encode",
|
||||
wrap: "Wrap text",
|
||||
columns: "Select/reorder columns",
|
||||
};
|
||||
|
||||
function Option({
|
||||
step,
|
||||
change,
|
||||
}: {
|
||||
step: TransformStep;
|
||||
change: (option: string) => void;
|
||||
}) {
|
||||
if (
|
||||
["trim-lines", "trim-document", "dedupe-lines", "transliterate"].includes(
|
||||
step.type,
|
||||
)
|
||||
)
|
||||
return <span className="muted">No options</span>;
|
||||
if (step.type === "line-endings")
|
||||
return (
|
||||
<select
|
||||
aria-label="Line ending"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
>
|
||||
<option value="lf">LF</option>
|
||||
<option value="crlf">CRLF</option>
|
||||
<option value="cr">CR</option>
|
||||
</select>
|
||||
);
|
||||
if (step.type === "collapse-whitespace")
|
||||
return (
|
||||
<select
|
||||
aria-label="Whitespace mode"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
>
|
||||
<option value="line">Spaces/tabs within lines</option>
|
||||
<option value="all">All whitespace including newlines</option>
|
||||
</select>
|
||||
);
|
||||
if (step.type === "case")
|
||||
return (
|
||||
<select
|
||||
aria-label="Case transformation"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
>
|
||||
{[
|
||||
"upper",
|
||||
"lower",
|
||||
"title",
|
||||
"sentence",
|
||||
"camel",
|
||||
"pascal",
|
||||
"snake",
|
||||
"kebab",
|
||||
].map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (step.type === "normalize")
|
||||
return (
|
||||
<select
|
||||
aria-label="Normalization form"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
>
|
||||
{["NFC", "NFD", "NFKC", "NFKD"].map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (step.type === "escape")
|
||||
return (
|
||||
<select
|
||||
aria-label="Escape target"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
>
|
||||
{["json", "html", "url", "base64", "hex"].map((value) => (
|
||||
<option key={value}>{value.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (step.type === "sort-lines")
|
||||
return (
|
||||
<input
|
||||
aria-label="Sort locale"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
placeholder="BCP 47 locale, e.g. en"
|
||||
/>
|
||||
);
|
||||
if (step.type === "wrap")
|
||||
return (
|
||||
<input
|
||||
aria-label="Wrap width"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<input
|
||||
aria-label="Column settings"
|
||||
value={step.option}
|
||||
onChange={(event) => change(event.target.value)}
|
||||
placeholder=",|3,1,2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Inventory({ value }: { value: string }) {
|
||||
const facts = textInventory(value);
|
||||
return (
|
||||
<dl className="inventory">
|
||||
<div>
|
||||
<dt>UTF-16 units</dt>
|
||||
<dd>{facts.utf16Units.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Code points</dt>
|
||||
<dd>{facts.codePoints.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lines</dt>
|
||||
<dd>{facts.lines.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>CRLF / LF / CR</dt>
|
||||
<dd>
|
||||
{facts.crlf} / {facts.bareLf} / {facts.bareCr}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Final newline</dt>
|
||||
<dd>{facts.finalNewline ? "Yes" : "No"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const [source, setSource] = useState(initial);
|
||||
const [steps, setSteps] = useState(initialSteps);
|
||||
const [result, setResult] = useState<PipelineResult>(() =>
|
||||
applyPipeline(initial, initialSteps),
|
||||
);
|
||||
const [newType, setNewType] = useState<StepType>("line-endings");
|
||||
const [inputEncoding, setInputEncoding] = useState<TextEncoding>("utf-8");
|
||||
const [outputEncoding, setOutputEncoding] = useState<TextEncoding>("utf-8");
|
||||
const [fatalDecode, setFatalDecode] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [recipe, setRecipe] = useState("");
|
||||
const outputLoss =
|
||||
outputEncoding === "latin1" &&
|
||||
[...result.output].some((character) => character.codePointAt(0)! > 255);
|
||||
const updateStep = (id: string, changes: Partial<TransformStep>) =>
|
||||
setSteps((current) =>
|
||||
current.map((step) => (step.id === id ? { ...step, ...changes } : step)),
|
||||
);
|
||||
const move = (index: number, direction: -1 | 1) =>
|
||||
setSteps((current) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next;
|
||||
});
|
||||
const apply = () => {
|
||||
try {
|
||||
setResult(applyPipeline(source, steps));
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Pipeline failed.");
|
||||
}
|
||||
};
|
||||
const open = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
if (file.size > 16 * 1024 * 1024) {
|
||||
setError("File exceeds the 16 MiB byte-input limit.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const decoded = decodeText(
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
inputEncoding,
|
||||
fatalDecode,
|
||||
);
|
||||
if (decoded.length > 2_000_000)
|
||||
throw new Error(
|
||||
"Decoded text exceeds the 2,000,000 UTF-16-unit pipeline limit.",
|
||||
);
|
||||
setSource(decoded);
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: `File is not valid ${inputEncoding} in ${fatalDecode ? "fatal" : "replacement"} mode.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const download = () => {
|
||||
if (outputLoss) {
|
||||
setError(
|
||||
"ISO-8859-1 output would lose characters above U+00FF. Choose another encoding or transform the text explicitly.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
triggerBlobDownload(
|
||||
new Blob([encodeText(result.output, outputEncoding) as BlobPart], {
|
||||
type: "text/plain",
|
||||
}),
|
||||
`transformed-${outputEncoding}.txt`,
|
||||
);
|
||||
};
|
||||
const exportRecipe = () => {
|
||||
const value = JSON.stringify(
|
||||
{ schemaVersion: 1, app: "text-tools", version: "0.1.0", steps },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
setRecipe(value);
|
||||
triggerBlobDownload(
|
||||
new Blob([value], { type: "application/json" }),
|
||||
"text-tools-recipe.json",
|
||||
);
|
||||
};
|
||||
const importRecipe = () => {
|
||||
try {
|
||||
if (recipe.length > 1_000_000)
|
||||
throw new Error("Recipe exceeds 1,000,000 UTF-16 units.");
|
||||
const parsed = JSON.parse(recipe) as {
|
||||
schemaVersion?: unknown;
|
||||
app?: unknown;
|
||||
steps?: unknown;
|
||||
};
|
||||
if (
|
||||
parsed.schemaVersion !== 1 ||
|
||||
parsed.app !== "text-tools" ||
|
||||
!Array.isArray(parsed.steps) ||
|
||||
parsed.steps.length > 100
|
||||
)
|
||||
throw new Error("Recipe envelope is invalid.");
|
||||
const accepted = parsed.steps.map((entry): TransformStep => {
|
||||
if (!entry || typeof entry !== "object")
|
||||
throw new Error("Recipe step is invalid.");
|
||||
const value = entry as Partial<TransformStep>;
|
||||
if (
|
||||
typeof value.type !== "string" ||
|
||||
!Object.hasOwn(STEP_LABELS, value.type) ||
|
||||
typeof value.option !== "string" ||
|
||||
typeof value.enabled !== "boolean"
|
||||
)
|
||||
throw new Error("Recipe contains an unsupported step.");
|
||||
return {
|
||||
...createStep(value.type),
|
||||
option: value.option.slice(0, 10_000),
|
||||
enabled: value.enabled,
|
||||
};
|
||||
});
|
||||
setSteps(accepted);
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Recipe could not be imported.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="workbench">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Exact local text transformations</p>
|
||||
<h1>Text Tools</h1>
|
||||
<p>
|
||||
Build an ordered, visible transformation pipeline for normalization,
|
||||
lines, casing, escaping, wrapping, and columns.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">Browser-local</span>
|
||||
</header>
|
||||
<div className="editor-grid">
|
||||
<section className="panel workspace" aria-labelledby="source-heading">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Source</p>
|
||||
<h2 id="source-heading">Exact input</h2>
|
||||
</div>
|
||||
<label className="button file-button">
|
||||
Open bytes
|
||||
<input
|
||||
type="file"
|
||||
accept="text/*,.txt,.csv,.log,.md"
|
||||
onChange={(event) => void open(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="encoding-row">
|
||||
<label className="field">
|
||||
<span>Decode as</span>
|
||||
<select
|
||||
value={inputEncoding}
|
||||
onChange={(event) =>
|
||||
setInputEncoding(event.target.value as TextEncoding)
|
||||
}
|
||||
>
|
||||
<option value="utf-8">UTF-8</option>
|
||||
<option value="utf-16le">UTF-16 LE</option>
|
||||
<option value="utf-16be">UTF-16 BE</option>
|
||||
<option value="latin1">ISO-8859-1</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fatalDecode}
|
||||
onChange={(event) => setFatalDecode(event.target.checked)}
|
||||
/>{" "}
|
||||
Reject malformed byte sequences
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
spellCheck={false}
|
||||
aria-label="Text source"
|
||||
/>
|
||||
<Inventory value={source} />
|
||||
</section>
|
||||
<section className="panel workspace" aria-labelledby="output-heading">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Last successful output</p>
|
||||
<h2 id="output-heading">Transformed text</h2>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(result.output)
|
||||
}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" onClick={download}>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={result.output}
|
||||
readOnly
|
||||
aria-label="Transformed output"
|
||||
/>
|
||||
<div className="encoding-row">
|
||||
<label className="field">
|
||||
<span>Download encoding</span>
|
||||
<select
|
||||
value={outputEncoding}
|
||||
onChange={(event) =>
|
||||
setOutputEncoding(event.target.value as TextEncoding)
|
||||
}
|
||||
>
|
||||
<option value="utf-8">UTF-8</option>
|
||||
<option value="utf-16le">UTF-16 LE</option>
|
||||
<option value="utf-16be">UTF-16 BE</option>
|
||||
<option value="latin1">ISO-8859-1</option>
|
||||
</select>
|
||||
</label>
|
||||
{outputLoss && (
|
||||
<p className="warning">
|
||||
This output cannot be represented losslessly in ISO-8859-1.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Inventory value={result.output} />
|
||||
</section>
|
||||
</div>
|
||||
<section className="panel workspace" aria-labelledby="pipeline-heading">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Pipeline</p>
|
||||
<h2 id="pipeline-heading">Ordered steps</h2>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<select
|
||||
aria-label="New transformation"
|
||||
value={newType}
|
||||
onChange={(event) => setNewType(event.target.value as StepType)}
|
||||
>
|
||||
{Object.entries(STEP_LABELS).map(([value, label]) => (
|
||||
<option value={value} key={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSteps((current) => [...current, createStep(newType)])
|
||||
}
|
||||
>
|
||||
Add step
|
||||
</button>
|
||||
<button className="primary" type="button" onClick={apply}>
|
||||
Apply pipeline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="steps">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step.id}>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.enabled}
|
||||
onChange={(event) =>
|
||||
updateStep(step.id, { enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<strong>{STEP_LABELS[step.type]}</strong>
|
||||
</label>
|
||||
<Option
|
||||
step={step}
|
||||
change={(option) => updateStep(step.id, { option })}
|
||||
/>
|
||||
<div className="step-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move ${STEP_LABELS[step.type]} up`}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move ${STEP_LABELS[step.type]} down`}
|
||||
disabled={index === steps.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${STEP_LABELS[step.type]}`}
|
||||
onClick={() =>
|
||||
setSteps((current) =>
|
||||
current.filter((candidate) => candidate.id !== step.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{result.warnings.map((warning) => (
|
||||
<p className="warning" key={warning}>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Step</th>
|
||||
<th>Before</th>
|
||||
<th>After</th>
|
||||
<th>Changed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.steps.map((report) => (
|
||||
<tr key={report.id}>
|
||||
<td>{STEP_LABELS[report.type]}</td>
|
||||
<td>{report.beforeUnits}</td>
|
||||
<td>{report.afterUnits}</td>
|
||||
<td>{report.changed ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section className="panel workspace">
|
||||
<details>
|
||||
<summary>Versioned recipe import/export</summary>
|
||||
<div className="recipe">
|
||||
<textarea
|
||||
value={recipe}
|
||||
onChange={(event) => setRecipe(event.target.value)}
|
||||
placeholder="Paste a Text Tools recipe JSON here."
|
||||
/>
|
||||
<div className="actions">
|
||||
<button type="button" onClick={exportRecipe}>
|
||||
Export current recipe
|
||||
</button>
|
||||
<button type="button" onClick={importRecipe}>
|
||||
Import recipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<p className="notice">
|
||||
Encoding detection is limited to an explicit choice; no arbitrary
|
||||
charset guess is made. Transliteration, compatibility normalization,
|
||||
escaping, column omission, and narrow encodings can be lossy, so the
|
||||
exact source and output remain visible.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user