Release Text Tools 0.2.0

This commit is contained in:
2026-09-02 07:24:52 +02:00
parent c69bd5af12
commit 5e962ff0f3
29 changed files with 1093 additions and 102 deletions
+204 -19
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import {
decodeText,
encodeText,
triggerBlobDownload,
triggerBlobDownloads,
type TextEncoding,
} from "@add-ideas/toolbox-helpers";
import {
@@ -13,6 +13,11 @@ import {
type StepType,
type TransformStep,
} from "../text/pipeline";
import {
createTextArtifactEvidence,
decodeTextWithEvidence,
type TextByteEvidence,
} from "../text/evidence";
const initial = " Crème brûlée \r\nAlpha\nalpha\r\n Cedar \n";
const initialSteps: TransformStep[] = [
@@ -31,8 +36,16 @@ const STEP_LABELS: Record<StepType, string> = {
normalize: "Unicode normalization",
transliterate: "Best-effort transliteration",
escape: "Escape / encode",
unescape: "Decode / unescape strictly",
wrap: "Wrap text",
columns: "Select/reorder columns",
"replace-literal": "Replace literal text",
"prefix-lines": "Prefix lines",
"suffix-lines": "Suffix lines",
"filter-lines": "Keep matching lines",
"number-lines": "Number lines",
"join-lines": "Join lines",
"reverse-lines": "Reverse line order",
};
function Option({
@@ -43,9 +56,13 @@ function Option({
change: (option: string) => void;
}) {
if (
["trim-lines", "trim-document", "dedupe-lines", "transliterate"].includes(
step.type,
)
[
"trim-lines",
"trim-document",
"dedupe-lines",
"transliterate",
"reverse-lines",
].includes(step.type)
)
return <span className="muted">No options</span>;
if (step.type === "line-endings")
@@ -104,10 +121,10 @@ function Option({
))}
</select>
);
if (step.type === "escape")
if (step.type === "escape" || step.type === "unescape")
return (
<select
aria-label="Escape target"
aria-label={step.type === "escape" ? "Escape target" : "Decode source"}
value={step.option}
onChange={(event) => change(event.target.value)}
>
@@ -136,13 +153,89 @@ function Option({
onChange={(event) => change(event.target.value)}
/>
);
if (step.type === "replace-literal") {
let values: [string, string] = ["", ""];
try {
const parsed = JSON.parse(step.option) as unknown;
if (
Array.isArray(parsed) &&
parsed.length === 2 &&
parsed.every((value) => typeof value === "string")
)
values = parsed as [string, string];
} catch {
/* Keep editable empty values; apply will report invalid stored JSON. */
}
return (
<div className="column-options">
<input
aria-label="Literal search text"
value={values[0]}
onChange={(event) =>
change(JSON.stringify([event.target.value, values[1]]))
}
/>
<input
aria-label="Literal replacement text"
value={values[1]}
onChange={(event) =>
change(JSON.stringify([values[0], event.target.value]))
}
/>
</div>
);
}
if (
[
"prefix-lines",
"suffix-lines",
"filter-lines",
"number-lines",
"join-lines",
].includes(step.type)
)
return (
<input
aria-label={`${STEP_LABELS[step.type]} option`}
value={step.option}
onChange={(event) => change(event.target.value)}
placeholder={
step.type === "join-lines"
? "Delimiter; \\n and \\t supported"
: "Text"
}
/>
);
const pieces = step.option.split("|");
const mode = pieces.length >= 3 ? pieces[0]! : "literal";
const delimiter = pieces.length >= 3 ? pieces[1]! : pieces[0] || ",";
const order =
pieces.length >= 3 ? pieces.slice(2).join("|") : pieces[1] || "1";
const encode = (nextMode: string, nextDelimiter: string, nextOrder: string) =>
change(`${nextMode}|${nextDelimiter}|${nextOrder}`);
return (
<input
aria-label="Column settings"
value={step.option}
onChange={(event) => change(event.target.value)}
placeholder=",|3,1,2"
/>
<div className="column-options">
<select
aria-label="Column parsing mode"
value={mode}
onChange={(event) => encode(event.target.value, delimiter, order)}
>
<option value="csv">Quoted CSV records</option>
<option value="literal">Literal delimiter per line</option>
</select>
<input
aria-label="Column delimiter"
value={delimiter}
onChange={(event) => encode(mode, event.target.value, order)}
placeholder=", or \\t"
/>
<input
aria-label="Column order"
value={order}
onChange={(event) => encode(mode, delimiter, event.target.value)}
placeholder="3,1,2"
/>
</div>
);
}
@@ -188,6 +281,8 @@ export function Workbench() {
const [fatalDecode, setFatalDecode] = useState(true);
const [error, setError] = useState("");
const [recipe, setRecipe] = useState("");
const [sourceEvidence, setSourceEvidence] = useState<TextByteEvidence>();
const [sourceName, setSourceName] = useState("pasted-text.txt");
const outputLoss =
outputEncoding === "latin1" &&
[...result.output].some((character) => character.codePointAt(0)! > 255);
@@ -218,16 +313,18 @@ export function Workbench() {
return;
}
try {
const decoded = decodeText(
const decoded = decodeTextWithEvidence(
new Uint8Array(await file.arrayBuffer()),
inputEncoding,
fatalDecode,
);
if (decoded.length > 2_000_000)
if (decoded.text.length > 2_000_000)
throw new Error(
"Decoded text exceeds the 2,000,000 UTF-16-unit pipeline limit.",
);
setSource(decoded);
setSource(decoded.text);
setSourceEvidence(decoded.evidence);
setSourceName(file.name);
setError("");
} catch (reason) {
setError(
@@ -253,7 +350,7 @@ export function Workbench() {
};
const exportRecipe = () => {
const value = JSON.stringify(
{ schemaVersion: 1, app: "text-tools", version: "0.1.0", steps },
{ schemaVersion: 1, app: "text-tools", version: "0.2.0", steps },
null,
2,
);
@@ -263,6 +360,41 @@ export function Workbench() {
"text-tools-recipe.json",
);
};
const exportArtifact = async () => {
try {
const bytes = encodeText(result.output, outputEncoding);
const evidence = await createTextArtifactEvidence({
sourceName,
sourceEvidence,
sourceText: source,
outputName: `transformed-${outputEncoding}.txt`,
outputEncoding,
outputBytes: bytes,
pipeline: result,
steps,
});
triggerBlobDownloads(
[
{
blob: new Blob([bytes as BlobPart], { type: "text/plain" }),
filename: evidence.output.name,
},
{
blob: new Blob([JSON.stringify(evidence, null, 2) + "\n"], {
type: "application/json",
}),
filename: "text-tools-artifact-evidence.json",
},
],
{ maximumFiles: 2, order: "input", revokeDelayMs: 1_000 },
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Artifact export failed.",
);
}
};
const importRecipe = () => {
try {
if (recipe.length > 1_000_000)
@@ -315,7 +447,8 @@ export function Workbench() {
<h1>Text Tools</h1>
<p>
Build an ordered, visible transformation pipeline for normalization,
lines, casing, escaping, wrapping, and columns.
lines, casing, strict escaping/decoding, wrapping, and quoted or
literal-delimited columns.
</p>
</div>
<span className="privacy-pill">Browser-local</span>
@@ -362,11 +495,54 @@ export function Workbench() {
</div>
<textarea
value={source}
onChange={(event) => setSource(event.target.value)}
onChange={(event) => {
setSource(event.target.value);
setSourceEvidence(undefined);
setSourceName("pasted-text.txt");
}}
spellCheck={false}
aria-label="Text source"
/>
<Inventory value={source} />
{sourceEvidence && (
<details>
<summary>Byte decoding evidence</summary>
<dl className="inventory">
<div>
<dt>Selected / BOM</dt>
<dd>
{sourceEvidence.selectedEncoding} /{" "}
{sourceEvidence.bom?.encoding ?? "none"}
</dd>
</div>
<div>
<dt>UTF-8 validation</dt>
<dd>
{sourceEvidence.utf8.valid
? "valid"
: `invalid at byte ${sourceEvidence.utf8.firstInvalidOffset}`}
</dd>
</div>
<div>
<dt>Replacement characters</dt>
<dd>{sourceEvidence.replacementCharacters}</dd>
</div>
<div>
<dt>Byte newlines CRLF / LF / CR</dt>
<dd>
{sourceEvidence.byteNewlines.crlf} /{" "}
{sourceEvidence.byteNewlines.bareLf} /{" "}
{sourceEvidence.byteNewlines.bareCr}
</dd>
</div>
</dl>
{sourceEvidence.warnings.map((warning) => (
<p className="warning" key={warning}>
{warning}
</p>
))}
</details>
)}
</section>
<section className="panel workspace" aria-labelledby="output-heading">
<div className="panel-heading">
@@ -386,6 +562,9 @@ export function Workbench() {
<button type="button" onClick={download}>
Download
</button>
<button type="button" onClick={() => void exportArtifact()}>
Export artifact + evidence
</button>
</div>
</div>
<textarea
@@ -533,6 +712,7 @@ export function Workbench() {
<summary>Versioned recipe import/export</summary>
<div className="recipe">
<textarea
aria-label="Text Tools recipe JSON"
value={recipe}
onChange={(event) => setRecipe(event.target.value)}
placeholder="Paste a Text Tools recipe JSON here."
@@ -551,7 +731,12 @@ export function Workbench() {
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.
exact source and output remain visible. Portable artifact evidence
includes exact hashes, selected encodings, newline facts and the
ordered recipe. SDK 0.3.0 provides the shared transfer contract, but
one-click Open With remains disabled until the Portal consumer rollout
is coordinated. This version provides explicit portable downloads in
the meantime.
</p>
</section>
</main>