Release Text Tools 0.2.0
This commit is contained in:
+204
-19
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user