Release Text Tools 0.1.0

This commit is contained in:
2026-09-01 02:53:56 +02:00
commit c69bd5af12
57 changed files with 8370 additions and 0 deletions
+27
View File
@@ -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;
}
}
+41
View File
@@ -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>
);
}
+559
View File
@@ -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>
);
}