Release Text Tools 0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Text Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
:root {
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #fff;
|
||||
--toolbox-surface-soft: #eff1f7;
|
||||
--toolbox-text: #202332;
|
||||
--toolbox-muted: #656b7d;
|
||||
--toolbox-border: #d9dce7;
|
||||
--toolbox-accent: #5b4ec4;
|
||||
--toolbox-accent-hover: #493caf;
|
||||
--toolbox-accent-soft: #ece9ff;
|
||||
--toolbox-accent-contrast: #fff;
|
||||
--toolbox-focus: #137d75;
|
||||
--toolbox-danger: #b42342;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
.button {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled),
|
||||
.button:hover {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
:where(button, input, select, textarea, a):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
textarea {
|
||||
min-height: 10rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
line-height: 1.48;
|
||||
}
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero,
|
||||
.panel {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
}
|
||||
.hero h1,
|
||||
.panel h2,
|
||||
.panel h3,
|
||||
.help-dialog h2,
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 52rem;
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.privacy-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.38rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.capability-grid article {
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.72rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.capability-grid p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.48;
|
||||
}
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.muted {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.result {
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(36rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.editor-grid textarea {
|
||||
min-height: 22rem;
|
||||
}
|
||||
.encoding-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 16rem) minmax(12rem, 1fr);
|
||||
gap: 0.7rem;
|
||||
align-items: end;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.primary {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.file-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-height: 2.55rem;
|
||||
}
|
||||
.check input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
.inventory {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
}
|
||||
.inventory div {
|
||||
padding: 0.55rem;
|
||||
border-radius: 0.55rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.inventory dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.inventory dd {
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
.steps {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.steps li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 0.8fr) minmax(12rem, 1.4fr) auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.step-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.step-actions button {
|
||||
min-width: 2.55rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.warning,
|
||||
.error,
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.warning {
|
||||
border-color: #d9a72e;
|
||||
background: #fff8df;
|
||||
color: #725000;
|
||||
}
|
||||
.error {
|
||||
border-color: var(--toolbox-danger);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.notice {
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
details {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
summary {
|
||||
padding: 0.65rem;
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
.recipe {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0 0.65rem 0.65rem;
|
||||
}
|
||||
@media (max-width: 62rem) {
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.steps li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 42rem) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
bytesToBase64,
|
||||
bytesToHex,
|
||||
convertLineEndings,
|
||||
encodeText,
|
||||
normalizeUnicode,
|
||||
transformCase,
|
||||
type CaseTransform,
|
||||
type LineEnding,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
|
||||
export type StepType =
|
||||
| "line-endings"
|
||||
| "trim-lines"
|
||||
| "trim-document"
|
||||
| "collapse-whitespace"
|
||||
| "sort-lines"
|
||||
| "dedupe-lines"
|
||||
| "case"
|
||||
| "normalize"
|
||||
| "transliterate"
|
||||
| "escape"
|
||||
| "wrap"
|
||||
| "columns";
|
||||
export interface TransformStep {
|
||||
id: string;
|
||||
type: StepType;
|
||||
enabled: boolean;
|
||||
option: string;
|
||||
}
|
||||
export interface StepReport {
|
||||
id: string;
|
||||
type: StepType;
|
||||
beforeUnits: number;
|
||||
afterUnits: number;
|
||||
changed: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
export interface PipelineResult {
|
||||
output: string;
|
||||
steps: StepReport[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const MAX_INPUT = 2_000_000;
|
||||
const MAX_OUTPUT = 32 * 1024 * 1024;
|
||||
let nextStepId = 0;
|
||||
|
||||
function lines(value: string): string[] {
|
||||
return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
||||
}
|
||||
function htmlEscape(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
function stableUnique(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
return values.filter((value) => !seen.has(value) && !!seen.add(value));
|
||||
}
|
||||
|
||||
function wrapText(value: string, width: number): string {
|
||||
if (!Number.isSafeInteger(width) || width < 1 || width > 10_000)
|
||||
throw new Error("Wrap width must be 1–10,000 code points.");
|
||||
return lines(value)
|
||||
.flatMap((line) => {
|
||||
const words = line.split(/\s+/u);
|
||||
const output: string[] = [];
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
if (!word) continue;
|
||||
if (!current) current = word;
|
||||
else if ([...current, " ", ...word].length <= width)
|
||||
current += ` ${word}`;
|
||||
else {
|
||||
output.push(current);
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
output.push(current);
|
||||
return output;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function transformColumns(value: string, option: string): string {
|
||||
const [delimiter = ",", order = "1"] = option.split("|");
|
||||
if (!delimiter || delimiter.length > 8)
|
||||
throw new Error("Column delimiter must contain 1–8 characters.");
|
||||
const indices = order.split(",").map((entry) => Number(entry.trim()) - 1);
|
||||
if (
|
||||
!indices.length ||
|
||||
indices.some(
|
||||
(index) => !Number.isSafeInteger(index) || index < 0 || index > 999,
|
||||
)
|
||||
)
|
||||
throw new Error("Column order uses 1-based indices such as 3,1,2.");
|
||||
return lines(value)
|
||||
.map((line) => {
|
||||
const cells = line.split(delimiter);
|
||||
return indices.map((index) => cells[index] ?? "").join(delimiter);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function transliterate(value: string): string {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replaceAll(/\p{Mark}+/gu, "")
|
||||
.replaceAll("ß", "ss")
|
||||
.replaceAll("Æ", "AE")
|
||||
.replaceAll("æ", "ae")
|
||||
.replaceAll("Ø", "O")
|
||||
.replaceAll("ø", "o")
|
||||
.replaceAll("Ł", "L")
|
||||
.replaceAll("ł", "l");
|
||||
}
|
||||
|
||||
function applyStep(
|
||||
value: string,
|
||||
step: TransformStep,
|
||||
): { value: string; warning?: string } {
|
||||
switch (step.type) {
|
||||
case "line-endings":
|
||||
return { value: convertLineEndings(value, step.option as LineEnding) };
|
||||
case "trim-lines":
|
||||
return {
|
||||
value: lines(value)
|
||||
.map((line) => line.trim())
|
||||
.join("\n"),
|
||||
};
|
||||
case "trim-document":
|
||||
return { value: value.trim() };
|
||||
case "collapse-whitespace":
|
||||
return {
|
||||
value:
|
||||
step.option === "all"
|
||||
? value.replaceAll(/\s+/gu, " ")
|
||||
: lines(value)
|
||||
.map((line) => line.replaceAll(/[\t ]+/gu, " "))
|
||||
.join("\n"),
|
||||
};
|
||||
case "sort-lines": {
|
||||
const locale = step.option || "en";
|
||||
return {
|
||||
value: lines(value)
|
||||
.map((line, index) => ({ line, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.line.localeCompare(right.line, locale, {
|
||||
numeric: true,
|
||||
sensitivity: "variant",
|
||||
}) || left.index - right.index,
|
||||
)
|
||||
.map(({ line }) => line)
|
||||
.join("\n"),
|
||||
warning: `Sort order uses the host Intl.Collator for locale ${locale}.`,
|
||||
};
|
||||
}
|
||||
case "dedupe-lines":
|
||||
return { value: stableUnique(lines(value)).join("\n") };
|
||||
case "case":
|
||||
return { value: transformCase(value, step.option as CaseTransform) };
|
||||
case "normalize":
|
||||
return {
|
||||
value: normalizeUnicode(
|
||||
value,
|
||||
step.option as "NFC" | "NFD" | "NFKC" | "NFKD",
|
||||
),
|
||||
};
|
||||
case "transliterate":
|
||||
return {
|
||||
value: transliterate(value),
|
||||
warning:
|
||||
"Best-effort Latin transliteration is lossy and incomplete; it is not language-aware.",
|
||||
};
|
||||
case "escape": {
|
||||
if (step.option === "json")
|
||||
return { value: JSON.stringify(value).slice(1, -1) };
|
||||
if (step.option === "html") return { value: htmlEscape(value) };
|
||||
if (step.option === "url") return { value: encodeURIComponent(value) };
|
||||
if (step.option === "base64")
|
||||
return { value: bytesToBase64(encodeText(value)) };
|
||||
if (step.option === "hex")
|
||||
return { value: bytesToHex(encodeText(value)) };
|
||||
throw new Error("Unsupported escape target.");
|
||||
}
|
||||
case "wrap":
|
||||
return { value: wrapText(value, Number(step.option)) };
|
||||
case "columns":
|
||||
return { value: transformColumns(value, step.option) };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPipeline(
|
||||
input: string,
|
||||
steps: readonly TransformStep[],
|
||||
): PipelineResult {
|
||||
if (input.length > MAX_INPUT)
|
||||
throw new Error(
|
||||
`Input exceeds ${MAX_INPUT.toLocaleString()} UTF-16 units.`,
|
||||
);
|
||||
let output = input;
|
||||
const reports: StepReport[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const step of steps) {
|
||||
if (!step.enabled) continue;
|
||||
const before = output;
|
||||
const result = applyStep(before, step);
|
||||
output = result.value;
|
||||
if (
|
||||
output.length > MAX_OUTPUT ||
|
||||
output.length > Math.max(1024, input.length * 8)
|
||||
)
|
||||
throw new Error(
|
||||
"Pipeline output exceeded its 8× / 32 MiB expansion limit.",
|
||||
);
|
||||
reports.push({
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
beforeUnits: before.length,
|
||||
afterUnits: output.length,
|
||||
changed: before !== output,
|
||||
warning: result.warning,
|
||||
});
|
||||
if (result.warning) warnings.push(result.warning);
|
||||
}
|
||||
return { output, steps: reports, warnings: [...new Set(warnings)] };
|
||||
}
|
||||
|
||||
export function createStep(type: StepType): TransformStep {
|
||||
const defaults: Record<StepType, string> = {
|
||||
"line-endings": "lf",
|
||||
"trim-lines": "",
|
||||
"trim-document": "",
|
||||
"collapse-whitespace": "line",
|
||||
"sort-lines": "en",
|
||||
"dedupe-lines": "",
|
||||
case: "lower",
|
||||
normalize: "NFC",
|
||||
transliterate: "",
|
||||
escape: "json",
|
||||
wrap: "80",
|
||||
columns: ",|1",
|
||||
};
|
||||
return {
|
||||
id: `step-${++nextStepId}`,
|
||||
type,
|
||||
enabled: true,
|
||||
option: defaults[type],
|
||||
};
|
||||
}
|
||||
|
||||
export function textInventory(value: string) {
|
||||
let codePoints = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (
|
||||
unit >= 0xd800 &&
|
||||
unit <= 0xdbff &&
|
||||
index + 1 < value.length &&
|
||||
value.charCodeAt(index + 1) >= 0xdc00 &&
|
||||
value.charCodeAt(index + 1) <= 0xdfff
|
||||
)
|
||||
index += 1;
|
||||
codePoints += 1;
|
||||
}
|
||||
return {
|
||||
utf16Units: value.length,
|
||||
codePoints,
|
||||
lines: value ? value.split(/\r\n|\r|\n/u).length : 0,
|
||||
crlf: (value.match(/\r\n/gu) ?? []).length,
|
||||
bareLf: (value.match(/(?<!\r)\n/gu) ?? []).length,
|
||||
bareCr: (value.match(/\r(?!\n)/gu) ?? []).length,
|
||||
finalNewline: /(?:\r\n|\r|\n)$/u.test(value),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.text-tools",
|
||||
"name": "Text Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Transform and inspect plain text locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["text", "developer", "productivity"],
|
||||
"tags": ["text", "unicode", "normalize", "sort", "escape", "encoding"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": false,
|
||||
"workers": false,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/text-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/text-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user