Release File Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 08:24:07 +02:00
parent 055f533cf1
commit 91dc9bc2f7
30 changed files with 979 additions and 96 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added bounded batch-rename planning with collision-safe copy exports, byte-exact split/join operations, split manifests, and sorted SHA-256/SHA-512 checksum manifests.
- Adopted shared byte formatting and deterministic multi-download helpers, and declared accurate Toolbox I/O and capability metadata.
## 0.1.0 - 2026-09-01
- Added the initial local-first File Tools workbench.
+7 -5
View File
@@ -1,17 +1,19 @@
# File Tools
Identify, inspect and inventory local files in the browser.
Inspect, hash, rename, split and join local files in the browser.
File Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
- Bounded head/tail signature inspection with claimed-versus-detected MIME reporting
- Bounded head/tail signature inspection for every selected file with three-worker concurrency, aggregate progress, cancellation, per-file errors and claimed-versus-detected MIME reporting
- Paged 512-byte hexadecimal/ASCII views plus bounded UTF-8 and extracted-string previews
- Streaming SHA-256 and SHA-512 hashing in a cancellable module worker
- Same-size and same-hash duplicate candidates plus deterministic JSON/CSV inventory manifests
- Same-size and same-hash duplicate candidates plus deterministic JSON/CSV inventory manifests that explicitly report inspection coverage, failures and cancellations
- Deterministic, bounded batch-rename planning with path-safe names and collision suffixes; the plan is visible before separate browser downloads begin
- Zero-copy Blob slicing into manifest-described parts, explicit ordered rejoin, and standard `SHA256SUMS` / `SHA512SUMS` exports
Detection and entropy results are heuristics, not format validation, malware analysis or proof of encryption. A selection is limited to 500 entries; file contents remain represented by browser `File` handles and are read only for an inspection window, the visible hex window, or an explicitly requested streaming hash. File mutation, split/join and batch rename are not part of version 0.1. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Detection and entropy results are heuristics, not format validation, malware analysis or proof of encryption. A selection is limited to 500 entries; file contents remain represented by browser `File` handles and are read only for an inspection window, visible hex window, explicit streaming hash, or explicit export operation. Rename exports create copies and never rename the source handle. Split parts are downloaded separately because no archive container is implied, while join uses the displayed input order and does not infer a part manifest. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -25,7 +27,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/file-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/file-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for File Tools 0.1.0 is available at:
The corresponding source for File Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/file-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/file-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ File Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@noble/hashes` | 2.4.0 | MIT |
| `file-type` | 22.0.2 | MIT |
| `react` | 19.2.8 | MIT |
+4 -2
View File
@@ -1,7 +1,9 @@
# Architecture
File Tools is a static React/Vite application wrapped in the shared Toolbox shell. Browser `File` handles and small metadata records remain on the main thread; one fresh module worker performs each inspection or streaming hash and is terminated on completion, cancellation, timeout, replacement or unmount.
File Tools is a static React/Vite application wrapped in the shared Toolbox shell. Browser `File` handles and small metadata records remain on the main thread. Selection starts an inspection queue with at most three fresh module workers; each worker is terminated on completion, cancellation, timeout, replacement or unmount. Explicit streaming-hash jobs use the same isolated-worker lifecycle.
The worker reads at most 1 MiB from each end for signature, entropy, text and string heuristics. SHA-256 and SHA-512 are updated together in 1 MiB chunks, so hashing does not buffer the complete file. The hex view independently reads one 512-byte window. `file/signatures.ts` contains project-owned evidence heuristics; `file-type` provides broader signature hints; `@noble/hashes` provides incremental hashes.
`file/manifest.ts` sorts by path and emits JSON or spreadsheet-neutralised CSV. Duplicate groups start as same-size candidates and become stronger same-hash candidates after explicit hashing; no deletion or mutation follows. Relative worker and asset URLs keep the build relocatable below a nested portal path.
`file/manifest.ts` sorts by path and emits JSON or spreadsheet-neutralised CSV. Each row carries its own inspection state/error, while JSON also records aggregate inspected, failed, cancelled and pending coverage so an interrupted batch cannot look complete. Duplicate groups start as same-size candidates and become stronger same-hash candidates after explicit hashing; no deletion or mutation follows. Relative worker and asset URLs keep the build relocatable below a nested portal path.
`file/operations.ts` owns the bounded export operations. Rename templates are expanded into a deterministic download plan and passed through the shared collision-safe filename planner before any download. Split uses immutable `Blob.slice` windows and emits a JSON byte-offset manifest; join verifies part count and aggregate size before constructing an ordered Blob. Checksum manifests sort paths and use the conventional escaped GNU sum-file representation. These operations deliberately do not guess which part order, rename semantics, or checksum scope a user intended.
+4 -2
View File
@@ -2,6 +2,8 @@
Selected files remain browser `File` objects and are never uploaded. There is no telemetry, analytics, account, persistence or runtime network path. A selection is limited to 500 entries. Inspection reads bounded samples, the hex view reads one window, and hashing streams the selected file only after an explicit action.
Worker jobs are isolated from the UI thread and are terminated on cancellation, replacement, error, timeout or component teardown. A completed result remains visible while a later operation runs or fails. Exported manifests contain file names/relative paths, sizes, modification times, declared types and any requested hashes; those metadata can themselves be sensitive.
Worker jobs are isolated from the UI thread, limited to three simultaneous inspections, and terminated on cancellation, replacement, error, timeout or component teardown. A completed result remains visible while a later operation runs or fails. Exported manifests expose incomplete/error/cancelled coverage instead of silently treating queued files as inspected. They also contain file names/relative paths, sizes, modification times, declared types and any requested hashes; those metadata can themselves be sensitive.
Signature detection, strings, entropy, embedded markers and trailing-byte findings are heuristics. They do not validate a format or establish encryption, malware, steganography or intent. Equal SHA-256 values are strong byte-identity evidence but the app does not delete, rename, split, join or otherwise mutate files.
Signature detection, strings, entropy, embedded markers and trailing-byte findings are heuristics. They do not validate a format or establish encryption, malware, steganography or intent. Equal SHA-256 values are strong byte-identity evidence.
Rename, split and join are copy-producing downloads: browser-selected sources are never overwritten. Plans and split/join operations are capped at 500 files or parts, and join at 2 GiB. The UI exposes collision-adjusted names before a rename export. Split manifests contain filenames, byte offsets and sizes but no claim that rejoining unrelated or reordered parts is safe. Checksum manifests include original paths and hashes, which may disclose sensitive names or provide stable cross-context identifiers.
+19 -19
View File
@@ -1,24 +1,24 @@
{
"name": "file-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "file-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@noble/hashes": "2.4.0",
"file-type": "22.0.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -44,24 +44,25 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -69,17 +70,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "file-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Identify, inspect and inventory local files in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,16 +39,16 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@noble/hashes": "2.4.0",
"file-type": "22.0.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added bounded batch-rename planning with collision-safe copy exports, byte-exact split/join operations, split manifests, and sorted SHA-256/SHA-512 checksum manifests.
- Adopted shared byte formatting and deterministic multi-download helpers, and declared accurate Toolbox I/O and capability metadata.
## 0.1.0 - 2026-09-01
- Added the initial local-first File Tools workbench.
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+7 -5
View File
@@ -1,17 +1,19 @@
# File Tools
Identify, inspect and inventory local files in the browser.
Inspect, hash, rename, split and join local files in the browser.
File Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
- Bounded head/tail signature inspection with claimed-versus-detected MIME reporting
- Bounded head/tail signature inspection for every selected file with three-worker concurrency, aggregate progress, cancellation, per-file errors and claimed-versus-detected MIME reporting
- Paged 512-byte hexadecimal/ASCII views plus bounded UTF-8 and extracted-string previews
- Streaming SHA-256 and SHA-512 hashing in a cancellable module worker
- Same-size and same-hash duplicate candidates plus deterministic JSON/CSV inventory manifests
- Same-size and same-hash duplicate candidates plus deterministic JSON/CSV inventory manifests that explicitly report inspection coverage, failures and cancellations
- Deterministic, bounded batch-rename planning with path-safe names and collision suffixes; the plan is visible before separate browser downloads begin
- Zero-copy Blob slicing into manifest-described parts, explicit ordered rejoin, and standard `SHA256SUMS` / `SHA512SUMS` exports
Detection and entropy results are heuristics, not format validation, malware analysis or proof of encryption. A selection is limited to 500 entries; file contents remain represented by browser `File` handles and are read only for an inspection window, the visible hex window, or an explicitly requested streaming hash. File mutation, split/join and batch rename are not part of version 0.1. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Detection and entropy results are heuristics, not format validation, malware analysis or proof of encryption. A selection is limited to 500 entries; file contents remain represented by browser `File` handles and are read only for an inspection window, visible hex window, explicit streaming hash, or explicit export operation. Rename exports create copies and never rename the source handle. Split parts are downloaded separately because no archive container is implied, while join uses the displayed input order and does not infer a part manifest. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -25,7 +27,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/file-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/file-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for File Tools 0.1.0 is available at:
The corresponding source for File Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/file-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/file-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ File Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@noble/hashes` | 2.4.0 | MIT |
| `file-type` | 22.0.2 | MIT |
| `react` | 19.2.8 | MIT |
+4 -2
View File
@@ -1,7 +1,9 @@
# Architecture
File Tools is a static React/Vite application wrapped in the shared Toolbox shell. Browser `File` handles and small metadata records remain on the main thread; one fresh module worker performs each inspection or streaming hash and is terminated on completion, cancellation, timeout, replacement or unmount.
File Tools is a static React/Vite application wrapped in the shared Toolbox shell. Browser `File` handles and small metadata records remain on the main thread. Selection starts an inspection queue with at most three fresh module workers; each worker is terminated on completion, cancellation, timeout, replacement or unmount. Explicit streaming-hash jobs use the same isolated-worker lifecycle.
The worker reads at most 1 MiB from each end for signature, entropy, text and string heuristics. SHA-256 and SHA-512 are updated together in 1 MiB chunks, so hashing does not buffer the complete file. The hex view independently reads one 512-byte window. `file/signatures.ts` contains project-owned evidence heuristics; `file-type` provides broader signature hints; `@noble/hashes` provides incremental hashes.
`file/manifest.ts` sorts by path and emits JSON or spreadsheet-neutralised CSV. Duplicate groups start as same-size candidates and become stronger same-hash candidates after explicit hashing; no deletion or mutation follows. Relative worker and asset URLs keep the build relocatable below a nested portal path.
`file/manifest.ts` sorts by path and emits JSON or spreadsheet-neutralised CSV. Each row carries its own inspection state/error, while JSON also records aggregate inspected, failed, cancelled and pending coverage so an interrupted batch cannot look complete. Duplicate groups start as same-size candidates and become stronger same-hash candidates after explicit hashing; no deletion or mutation follows. Relative worker and asset URLs keep the build relocatable below a nested portal path.
`file/operations.ts` owns the bounded export operations. Rename templates are expanded into a deterministic download plan and passed through the shared collision-safe filename planner before any download. Split uses immutable `Blob.slice` windows and emits a JSON byte-offset manifest; join verifies part count and aggregate size before constructing an ordered Blob. Checksum manifests sort paths and use the conventional escaped GNU sum-file representation. These operations deliberately do not guess which part order, rename semantics, or checksum scope a user intended.
+4 -2
View File
@@ -2,6 +2,8 @@
Selected files remain browser `File` objects and are never uploaded. There is no telemetry, analytics, account, persistence or runtime network path. A selection is limited to 500 entries. Inspection reads bounded samples, the hex view reads one window, and hashing streams the selected file only after an explicit action.
Worker jobs are isolated from the UI thread and are terminated on cancellation, replacement, error, timeout or component teardown. A completed result remains visible while a later operation runs or fails. Exported manifests contain file names/relative paths, sizes, modification times, declared types and any requested hashes; those metadata can themselves be sensitive.
Worker jobs are isolated from the UI thread, limited to three simultaneous inspections, and terminated on cancellation, replacement, error, timeout or component teardown. A completed result remains visible while a later operation runs or fails. Exported manifests expose incomplete/error/cancelled coverage instead of silently treating queued files as inspected. They also contain file names/relative paths, sizes, modification times, declared types and any requested hashes; those metadata can themselves be sensitive.
Signature detection, strings, entropy, embedded markers and trailing-byte findings are heuristics. They do not validate a format or establish encryption, malware, steganography or intent. Equal SHA-256 values are strong byte-identity evidence but the app does not delete, rename, split, join or otherwise mutate files.
Signature detection, strings, entropy, embedded markers and trailing-byte findings are heuristics. They do not validate a format or establish encryption, malware, steganography or intent. Equal SHA-256 values are strong byte-identity evidence.
Rename, split and join are copy-producing downloads: browser-selected sources are never overwritten. Plans and split/join operations are capped at 500 files or parts, and join at 2 GiB. The UI exposes collision-adjusted names before a rename export. Split manifests contain filenames, byte offsets and sizes but no claim that rejoining unrelated or reordered parts is safe. Checksum manifests include original paths and hashes, which may disclose sensitive names or provide stable cross-context identifiers.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "file-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+36 -3
View File
@@ -3,12 +3,21 @@
"schemaVersion": 1,
"id": "de.add-ideas.file-tools",
"name": "File Tools",
"version": "0.1.0",
"description": "Identify, inspect and inventory local files in the browser.",
"version": "0.2.0",
"description": "Inspect, hash, rename, split and join local files in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["files", "developer", "productivity"],
"tags": ["file", "hex", "checksum", "manifest", "duplicate"],
"tags": [
"file",
"hex",
"checksum",
"manifest",
"rename",
"split",
"join",
"duplicate"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +30,30 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "*/*", "extensions": [], "label": "Any local file" }
],
"produces": [
{
"mediaType": "*/*",
"extensions": [],
"label": "Renamed or joined local file"
},
{
"mediaType": "application/json",
"extensions": [".json"],
"label": "Inventory and split manifests"
},
{ "mediaType": "text/csv", "extensions": [".csv"], "label": "Inventory" },
{
"mediaType": "text/plain",
"extensions": [".sha256", ".sha512"],
"label": "Checksum manifests"
}
]
},
"capabilities": { "required": ["workers", "web-crypto"], "optional": [] },
"privacy": {
"processing": "local",
"fileUploads": true,
+332 -28
View File
@@ -1,6 +1,17 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
formatBytes,
triggerBlobDownload,
triggerBlobDownloads,
} from "@add-ideas/toolbox-helpers";
import { manifestCsv, manifestJson, type FileRecord } from "../file/manifest";
import {
checksumManifest,
joinBlobs,
planBatchRename,
splitBlob,
type RenamePlanItem,
} from "../file/operations";
import type {
FileInspection,
WorkerRequest,
@@ -11,6 +22,10 @@ interface LocalRecord extends FileRecord {
id: string;
file: File;
}
interface InspectionBatch {
cancelled: boolean;
jobs: Set<WorkerJob>;
}
type JobResult =
{ inspection: FileInspection } | { sha256: string; sha512: string };
interface WorkerJob {
@@ -88,13 +103,6 @@ function runWorker(
};
}
function formatSize(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MiB`;
return `${(value / 1024 ** 3).toFixed(2)} GiB`;
}
function HexView({ file }: { file: File }) {
const [offset, setOffset] = useState(0);
const [bytes, setBytes] = useState<Uint8Array>(new Uint8Array());
@@ -192,12 +200,103 @@ export function Workbench() {
const [busy, setBusy] = useState("");
const [progress, setProgress] = useState(0);
const [error, setError] = useState("");
const [renameTemplate, setRenameTemplate] = useState(
"document-{index:3}.{ext}",
);
const [renamePlan, setRenamePlan] = useState<readonly RenamePlanItem[]>([]);
const [chunkMiB, setChunkMiB] = useState(8);
const [joinedName, setJoinedName] = useState("joined.bin");
const activeJob = useRef<WorkerJob | undefined>(undefined);
const inspectionBatch = useRef<InspectionBatch | undefined>(undefined);
const selected = records.find((record) => record.id === selectedId);
useEffect(() => () => activeJob.current?.cancel(), []);
useEffect(
() => () => {
activeJob.current?.cancel();
const batch = inspectionBatch.current;
if (batch) {
batch.cancelled = true;
for (const job of batch.jobs) job.cancel();
}
},
[],
);
const update = (id: string, changes: Partial<LocalRecord>) =>
setRecords((current) =>
current.map((record) =>
record.id === id ? { ...record, ...changes } : record,
),
);
const inspectBatch = async (added: LocalRecord[]) => {
const previous = inspectionBatch.current;
if (previous) {
previous.cancelled = true;
for (const job of previous.jobs) job.cancel();
}
const batch: InspectionBatch = { cancelled: false, jobs: new Set() };
inspectionBatch.current = batch;
let cursor = 0;
let completed = 0;
let failed = 0;
setBusy(`Inspecting 0 of ${added.length} files`);
setProgress(0);
const runNext = async () => {
while (!batch.cancelled) {
const record = added[cursor++];
if (!record) return;
update(record.id, {
inspectionStatus: "inspecting",
inspectionError: undefined,
});
const job = runWorker(record.file, "inspect", undefined, 15_000);
batch.jobs.add(job);
try {
const result = await job.promise;
if ("inspection" in result && !batch.cancelled)
update(record.id, {
inspection: result.inspection,
inspectionStatus: "inspected",
inspectionError: undefined,
});
} catch (reason) {
if (!batch.cancelled) {
failed += 1;
update(record.id, {
inspectionStatus: "error",
inspectionError:
reason instanceof Error ? reason.message : "Inspection failed.",
});
}
} finally {
batch.jobs.delete(job);
if (!batch.cancelled) {
completed += 1;
setBusy(`Inspecting ${completed} of ${added.length} files`);
setProgress(added.length ? completed / added.length : 1);
}
}
}
};
await Promise.all(
Array.from({ length: Math.min(3, added.length) }, () => runNext()),
);
if (inspectionBatch.current !== batch) return;
inspectionBatch.current = undefined;
setBusy("");
setProgress(0);
if (failed)
setError(
`${added.length - failed} of ${added.length} files were inspected; ${failed} failed. Select a failed file for details or retry it.`,
);
};
const addFiles = (files: FileList | null) => {
if (!files) return;
activeJob.current?.cancel();
activeJob.current = undefined;
const added = [...files].slice(0, 500).map((file): LocalRecord => ({
id: `file-${++nextRecordId}`,
file,
@@ -206,40 +305,47 @@ export function Workbench() {
size: file.size,
type: file.type,
lastModified: file.lastModified,
inspectionStatus: "queued",
}));
setRecords(added);
setSelectedId(added[0]?.id ?? "");
setError(
files.length > 500 ? "Only the first 500 selected files were added." : "",
);
if (added[0]) void inspectRecord(added[0], added);
if (added.length) void inspectBatch(added);
};
const update = (
id: string,
changes: Partial<LocalRecord>,
base?: LocalRecord[],
) =>
setRecords((current) =>
(base ?? current).map((record) =>
record.id === id ? { ...record, ...changes } : record,
),
);
const inspectRecord = async (record: LocalRecord, base?: LocalRecord[]) => {
const inspectRecord = async (record: LocalRecord) => {
activeJob.current?.cancel();
setBusy(`Inspecting ${record.name}`);
setError("");
const job = runWorker(record.file, "inspect", undefined, 15_000);
activeJob.current = job;
update(record.id, {
inspectionStatus: "inspecting",
inspectionError: undefined,
});
try {
const result = await job.promise;
if ("inspection" in result)
update(record.id, { inspection: result.inspection }, base);
update(record.id, {
inspection: result.inspection,
inspectionStatus: "inspected",
inspectionError: undefined,
});
} catch (reason) {
if (!(reason instanceof DOMException && reason.name === "AbortError"))
if (reason instanceof DOMException && reason.name === "AbortError")
update(record.id, { inspectionStatus: "cancelled" });
else {
update(record.id, {
inspectionStatus: "error",
inspectionError:
reason instanceof Error ? reason.message : "Inspection failed.",
});
setError(
reason instanceof Error ? reason.message : "Inspection failed.",
);
}
} finally {
if (activeJob.current === job) {
activeJob.current = undefined;
@@ -289,6 +395,20 @@ export function Workbench() {
const cancel = () => {
activeJob.current?.cancel();
activeJob.current = undefined;
const batch = inspectionBatch.current;
if (batch) {
batch.cancelled = true;
for (const job of batch.jobs) job.cancel();
inspectionBatch.current = undefined;
setRecords((current) =>
current.map((record) =>
record.inspectionStatus === "queued" ||
record.inspectionStatus === "inspecting"
? { ...record, inspectionStatus: "cancelled" }
: record,
),
);
}
setBusy("");
setError("Operation cancelled; the last completed result was kept.");
};
@@ -314,6 +434,81 @@ export function Workbench() {
`file-manifest.${format}`,
);
};
const planRename = () => {
try {
setRenamePlan(
planBatchRename(
records.map((record) => ({ name: record.name, blob: record.file })),
renameTemplate,
),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Rename planning failed.",
);
}
};
const exportRename = () => {
if (!renamePlan.length) return;
triggerBlobDownloads(renamePlan, {
maximumFiles: 500,
order: "input",
revokeDelayMs: 1_000,
});
};
const splitSelected = () => {
if (!selected) return;
try {
const split = splitBlob(
selected.file,
selected.name,
Math.round(chunkMiB * 1024 * 1024),
);
triggerBlobDownloads(
[
...split.parts.map((part) => ({
blob: part.blob,
filename: part.name,
})),
{
blob: new Blob([JSON.stringify(split.manifest, null, 2) + "\n"], {
type: "application/json",
}),
filename: `${split.manifest.originalName}.split-manifest.json`,
},
],
{ maximumFiles: 501, order: "input", revokeDelayMs: 1_000 },
);
setError("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Split failed.");
}
};
const joinSelection = () => {
try {
const blob = joinBlobs(records.map((record) => record.file));
triggerBlobDownload(blob, joinedName);
setError("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Join failed.");
}
};
const exportChecksums = (algorithm: "sha256" | "sha512") => {
try {
triggerBlobDownload(
new Blob([checksumManifest(records, algorithm)], {
type: "text/plain",
}),
algorithm === "sha256" ? "SHA256SUMS" : "SHA512SUMS",
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Checksum export failed.",
);
}
};
return (
<main className="workbench">
@@ -397,9 +592,10 @@ export function Workbench() {
<small>
{(record.inspection?.detected?.mime ?? record.type) ||
"Unknown type"}
{` · ${record.inspectionStatus ?? "queued"}`}
</small>
</span>
<span>{formatSize(record.size)}</span>
<span>{formatBytes(record.size)}</span>
</button>
))}
</section>
@@ -417,17 +613,24 @@ export function Workbench() {
<button
type="button"
onClick={() => void inspectRecord(selected)}
disabled={Boolean(busy)}
>
Inspect
</button>
<button
type="button"
onClick={() => void hashRecord(selected)}
disabled={Boolean(busy)}
>
Hash
</button>
</div>
</div>
{selected.inspectionError && (
<p className="error" role="alert">
{selected.inspectionError}
</p>
)}
<dl className="facts">
<div>
<dt>Size</dt>
@@ -449,7 +652,7 @@ export function Workbench() {
<dt>Sample entropy</dt>
<dd>
{selected.inspection
? `${selected.inspection.entropy.toFixed(3)} bits/byte over ${formatSize(selected.inspection.sampleBytes)}`
? `${selected.inspection.entropy.toFixed(3)} bits/byte over ${formatBytes(selected.inspection.sampleBytes)}`
: "—"}
</dd>
</div>
@@ -527,7 +730,7 @@ export function Workbench() {
<strong>
{key.startsWith("sha256:")
? "Same SHA-256"
: `Same size (${formatSize(group[0]!.size)})`}
: `Same size (${formatBytes(group[0]!.size)})`}
</strong>
<span>{group.map((record) => record.path).join(" · ")}</span>
</div>
@@ -561,12 +764,113 @@ export function Workbench() {
</section>
</div>
)}
{records.length > 0 && (
<div className="grid operations-grid">
<section className="panel workspace" aria-labelledby="rename-heading">
<div>
<p className="eyebrow">Batch rename</p>
<h2 id="rename-heading">Preview before exporting copies</h2>
</div>
<label className="field">
<span>Template</span>
<input
value={renameTemplate}
onChange={(event) => {
setRenameTemplate(event.target.value);
setRenamePlan([]);
}}
/>
</label>
<p className="muted">
Tokens: {"{name}"}, {"{ext}"}, {"{index}"}, {"{index:3}"}. Unsafe
filename characters are neutralized and case-insensitive
collisions receive deterministic suffixes.
</p>
<div className="actions">
<button type="button" onClick={planRename}>
Plan rename
</button>
<button
type="button"
disabled={!renamePlan.length}
onClick={exportRename}
>
Export renamed copies
</button>
</div>
{renamePlan.length > 0 && (
<ol className="rename-plan" aria-label="Rename plan">
{renamePlan.map((item) => (
<li key={item.sourceIndex}>
<code>{item.originalName}</code>
<span aria-hidden="true"></span>
<code>{item.filename}</code>
</li>
))}
</ol>
)}
</section>
<section className="panel workspace" aria-labelledby="split-heading">
<div>
<p className="eyebrow">Split / join</p>
<h2 id="split-heading">Byte-exact Blob parts</h2>
</div>
<label className="field">
<span>Split selected file into MiB parts</span>
<input
type="number"
min="0.0625"
max="2048"
step="0.0625"
value={chunkMiB}
onChange={(event) => setChunkMiB(event.target.valueAsNumber)}
/>
</label>
<button type="button" disabled={!selected} onClick={splitSelected}>
Download parts + manifest
</button>
<label className="field">
<span>Join all selected files in their displayed order as</span>
<input
value={joinedName}
onChange={(event) => setJoinedName(event.target.value)}
/>
</label>
<button type="button" onClick={joinSelection}>
Download joined bytes
</button>
<p className="muted">
Join does not infer ordering from names or prove that parts belong
together. Review the displayed selection order and split manifest.
</p>
</section>
<section className="panel workspace" aria-labelledby="sums-heading">
<div>
<p className="eyebrow">Hash manifests</p>
<h2 id="sums-heading">Portable checksum lists</h2>
</div>
<p className="muted">
Hash every file first. Paths are sorted and newline/backslash
escapes follow common checksum-file syntax.
</p>
<div className="actions">
<button type="button" onClick={() => exportChecksums("sha256")}>
Download SHA256SUMS
</button>
<button type="button" onClick={() => exportChecksums("sha512")}>
Download SHA512SUMS
</button>
</div>
</section>
</div>
)}
<section className="panel workspace">
<p className="notice">
Detection is a signature hint, not format validation. Entropy,
embedded signatures, and trailing bytes are heuristics and do not
establish encryption, malware, steganography, or intent. File
mutation, split/join, and batch rename are deferred.
establish encryption, malware, steganography, or intent. Rename, split
and join create new download Blobs; browsers do not rename or
overwrite source files, and multi-download permission may be required.
</p>
</section>
</main>
+33 -2
View File
@@ -7,15 +7,40 @@ export interface FileRecord {
type: string;
lastModified: number;
inspection?: FileInspection;
inspectionStatus?:
"queued" | "inspecting" | "inspected" | "error" | "cancelled";
inspectionError?: string;
sha256?: string;
sha512?: string;
}
function inspectionStatus(
record: FileRecord,
): NonNullable<FileRecord["inspectionStatus"]> {
return (
record.inspectionStatus ?? (record.inspection ? "inspected" : "queued")
);
}
export function manifestJson(records: FileRecord[]): string {
return JSON.stringify(
{
schemaVersion: 1,
generatedBy: "add-ideas File Tools 0.1.0",
schemaVersion: 2,
generatedBy: "add-ideas File Tools 0.2.0",
inspectionCoverage: {
total: records.length,
inspected: records.filter(
(record) => inspectionStatus(record) === "inspected",
).length,
failed: records.filter((record) => inspectionStatus(record) === "error")
.length,
cancelled: records.filter(
(record) => inspectionStatus(record) === "cancelled",
).length,
pending: records.filter((record) =>
["queued", "inspecting"].includes(inspectionStatus(record)),
).length,
},
files: [...records]
.sort((left, right) => left.path.localeCompare(right.path))
.map((record) => ({
@@ -24,6 +49,8 @@ export function manifestJson(records: FileRecord[]): string {
lastModified: new Date(record.lastModified).toISOString(),
claimedMime: record.type || null,
detectedMime: record.inspection?.detected?.mime ?? null,
inspectionStatus: inspectionStatus(record),
inspectionError: record.inspectionError ?? null,
sha256: record.sha256 ?? null,
sha512: record.sha512 ?? null,
findings: record.inspection?.findings ?? [],
@@ -48,6 +75,8 @@ export function manifestCsv(records: FileRecord[]): string {
"lastModified",
"claimedMime",
"detectedMime",
"inspectionStatus",
"inspectionError",
"sha256",
"sha512",
]
@@ -64,6 +93,8 @@ export function manifestCsv(records: FileRecord[]): string {
new Date(record.lastModified).toISOString(),
record.type,
record.inspection?.detected?.mime,
inspectionStatus(record),
record.inspectionError,
record.sha256,
record.sha512,
]
+206
View File
@@ -0,0 +1,206 @@
import {
planBlobDownloads,
sanitizeDownloadFilename,
type PlannedBlobDownload,
} from "@add-ideas/toolbox-helpers";
import type { FileRecord } from "./manifest";
export const MAX_OPERATION_FILES = 500;
export const MAX_JOIN_BYTES = 2 * 1024 * 1024 * 1024;
export const MIN_CHUNK_BYTES = 64 * 1024;
export const MAX_CHUNKS = 500;
export interface RenameSource {
readonly name: string;
readonly blob: Blob;
}
export interface RenamePlanItem extends PlannedBlobDownload {
readonly originalName: string;
}
export interface SplitPart {
readonly index: number;
readonly offset: number;
readonly size: number;
readonly name: string;
readonly blob: Blob;
}
export interface SplitManifest {
readonly schemaVersion: 1;
readonly operation: "split";
readonly originalName: string;
readonly originalSize: number;
readonly mediaType: string;
readonly chunkBytes: number;
readonly parts: ReadonlyArray<{
index: number;
offset: number;
size: number;
name: string;
}>;
}
function stemAndExtension(name: string): { stem: string; extension: string } {
const leaf = name.replaceAll("\\", "/").split("/").at(-1) ?? name;
const dot = leaf.lastIndexOf(".");
return dot > 0
? { stem: leaf.slice(0, dot), extension: leaf.slice(dot + 1) }
: { stem: leaf, extension: "" };
}
function renderRenameTemplate(
source: RenameSource,
template: string,
index: number,
): string {
if (template.length === 0 || template.length > 1_024)
throw new RangeError("Rename template must contain 11,024 characters.");
const { stem, extension } = stemAndExtension(source.name);
const output = template.replace(
/\{(name|ext|index(?::([1-9]\d?))?)\}/gu,
(_token, key: string, widthText?: string) => {
if (key === "name") return stem;
if (key === "ext") return extension;
const width = widthText ? Number(widthText) : 1;
return index.toString().padStart(width, "0");
},
);
if (/\{[^{}]+\}/u.test(output))
throw new SyntaxError(
"Unknown rename token. Use {name}, {ext}, {index}, or {index:3}.",
);
return output;
}
export function planBatchRename(
sources: readonly RenameSource[],
template: string,
startIndex = 1,
): readonly RenamePlanItem[] {
if (sources.length === 0 || sources.length > MAX_OPERATION_FILES)
throw new RangeError(
`Rename planning requires 1${MAX_OPERATION_FILES} files.`,
);
if (!Number.isSafeInteger(startIndex) || startIndex < 0)
throw new RangeError("Rename start index must be a non-negative integer.");
const planned = planBlobDownloads(
sources.map((source, sourceIndex) => ({
blob: source.blob,
filename: renderRenameTemplate(
source,
template,
startIndex + sourceIndex,
),
})),
{ maximumFiles: MAX_OPERATION_FILES, order: "input" },
);
return Object.freeze(
planned.map((item) =>
Object.freeze({ ...item, originalName: sources[item.sourceIndex]!.name }),
),
);
}
export function splitBlob(
blob: Blob,
originalName: string,
chunkBytes: number,
): { parts: readonly SplitPart[]; manifest: SplitManifest } {
if (
!Number.isSafeInteger(chunkBytes) ||
chunkBytes < MIN_CHUNK_BYTES ||
chunkBytes > MAX_JOIN_BYTES
)
throw new RangeError(
`Chunk size must be from ${MIN_CHUNK_BYTES} to ${MAX_JOIN_BYTES} bytes.`,
);
const count = Math.max(1, Math.ceil(blob.size / chunkBytes));
if (count > MAX_CHUNKS)
throw new RangeError(`Split would exceed the ${MAX_CHUNKS} part limit.`);
const safe = sanitizeDownloadFilename(originalName, "file.bin");
const width = Math.max(4, count.toString().length);
const parts = Object.freeze(
Array.from({ length: count }, (_, offsetIndex): SplitPart => {
const offset = offsetIndex * chunkBytes;
const end = Math.min(blob.size, offset + chunkBytes);
const index = offsetIndex + 1;
return Object.freeze({
index,
offset,
size: end - offset,
name: `${safe}.part${index.toString().padStart(width, "0")}-of-${count.toString().padStart(width, "0")}`,
blob: blob.slice(offset, end, "application/octet-stream"),
});
}),
);
return {
parts,
manifest: Object.freeze({
schemaVersion: 1,
operation: "split",
originalName: safe,
originalSize: blob.size,
mediaType: blob.type || "application/octet-stream",
chunkBytes,
parts: Object.freeze(
parts.map(({ index, offset, size, name }) =>
Object.freeze({ index, offset, size, name }),
),
),
}),
};
}
export function joinBlobs(
parts: readonly Blob[],
mediaType = "application/octet-stream",
maximumBytes = MAX_JOIN_BYTES,
): Blob {
if (parts.length === 0 || parts.length > MAX_CHUNKS)
throw new RangeError(`Join requires 1${MAX_CHUNKS} parts.`);
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
throw new RangeError("Join limit must be a positive safe integer.");
let total = 0;
for (const part of parts) {
total += part.size;
if (!Number.isSafeInteger(total) || total > maximumBytes)
throw new RangeError(
`Joined output exceeds the ${maximumBytes.toLocaleString()}-byte limit.`,
);
}
return new Blob([...parts], { type: mediaType });
}
function sumLine(digest: string, path: string): string {
if (!/^[a-f\d]+$/u.test(digest))
throw new TypeError("Checksum manifest contains an invalid digest.");
const escaped = path.replaceAll("\\", "\\\\").replaceAll("\n", "\\n");
return `${escaped === path ? "" : "\\"}${digest} *${escaped}`;
}
export function checksumManifest(
records: readonly FileRecord[],
algorithm: "sha256" | "sha512",
): string {
if (records.length === 0 || records.length > MAX_OPERATION_FILES)
throw new RangeError(
`Checksum manifest requires 1${MAX_OPERATION_FILES} files.`,
);
const length = algorithm === "sha256" ? 64 : 128;
return (
[...records]
.sort((left, right) => left.path.localeCompare(right.path))
.map((record) => {
const digest = record[algorithm];
if (!digest || digest.length !== length)
throw new Error(
`${record.path} has no complete ${algorithm.toUpperCase()} digest.`,
);
return sumLine(digest, record.path);
})
.join("\n") + "\n"
);
}
+24
View File
@@ -378,6 +378,30 @@ pre {
border-radius: 0.65rem;
background: var(--toolbox-surface-soft);
}
.operations-grid {
align-items: start;
}
.rename-plan {
display: grid;
gap: 0.35rem;
max-height: 18rem;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.rename-plan li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 0.4rem;
padding: 0.45rem;
border-radius: 0.5rem;
background: var(--toolbox-surface-soft);
}
.rename-plan code {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 56rem) {
.file-layout {
grid-template-columns: 1fr;
+39 -3
View File
@@ -3,12 +3,21 @@
"schemaVersion": 1,
"id": "de.add-ideas.file-tools",
"name": "File Tools",
"version": "0.1.0",
"description": "Identify, inspect and inventory local files in the browser.",
"version": "0.2.0",
"description": "Inspect, hash, rename, split and join local files in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["files", "developer", "productivity"],
"tags": ["file", "hex", "checksum", "manifest", "duplicate"],
"tags": [
"file",
"hex",
"checksum",
"manifest",
"rename",
"split",
"join",
"duplicate"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +30,33 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "*/*", "extensions": [], "label": "Any local file" }
],
"produces": [
{
"mediaType": "*/*",
"extensions": [],
"label": "Renamed or joined local file"
},
{
"mediaType": "application/json",
"extensions": [".json"],
"label": "Inventory and split manifests"
},
{ "mediaType": "text/csv", "extensions": [".csv"], "label": "Inventory" },
{
"mediaType": "text/plain",
"extensions": [".sha256", ".sha512"],
"label": "Checksum manifests"
}
]
},
"capabilities": {
"required": ["workers", "web-crypto"],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+46 -1
View File
@@ -57,6 +57,51 @@ test("inspects and hashes a local file in an immutable module worker", async ({
expect(external).toEqual([]);
});
test("automatically inspects every file in a bounded batch", async ({
page,
}) => {
await page.goto("/deep/nested/file/");
await page
.locator('input[type="file"]')
.first()
.setInputFiles([
"tests/fixtures/hello.txt",
"tests/fixtures/second.txt",
"tests/fixtures/third.csv",
]);
await expect(
page.locator(".file-row small", { hasText: "inspected" }),
).toHaveCount(3, {
timeout: 30_000,
});
await expect(page.getByText("Inspecting 3 of 3 files")).toBeHidden();
await page.getByRole("button", { name: "third.csv" }).click();
await expect(
page.getByText("text/plain (.txt)", { exact: true }),
).toBeVisible();
});
test("previews collision-safe batch renames and exposes split/hash workflows", async ({
page,
}) => {
await page.goto("/deep/nested/file/");
await page
.locator('input[type="file"]')
.first()
.setInputFiles(["tests/fixtures/hello.txt", "tests/fixtures/second.txt"]);
await page.getByLabel("Template").fill("same.{ext}");
await page.getByRole("button", { name: "Plan rename" }).click();
await expect(page.getByRole("list", { name: "Rename plan" })).toContainText(
"same (2).txt",
);
await expect(
page.getByRole("button", { name: "Download parts + manifest" }),
).toBeEnabled();
await expect(
page.getByRole("button", { name: "Download SHA256SUMS" }),
).toBeVisible();
});
test("serves the release identity and hardened headers", async ({
request,
}) => {
@@ -69,7 +114,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/file/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.file-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/file/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+104
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { manifestCsv, manifestJson } from "../../src/file/manifest";
import {
checksumManifest,
joinBlobs,
planBatchRename,
splitBlob,
} from "../../src/file/operations";
import {
detectKnownSignature,
extensionMismatch,
@@ -52,4 +58,102 @@ describe("manifests", () => {
expect(manifestJson(records)).toBe(manifestJson(records)));
it("neutralises spreadsheet-leading formulas", () =>
expect(manifestCsv(records)).toContain("'=formula"));
it("reports truthful inspection coverage and per-file failures", () => {
const text = manifestJson([
{
...records[0]!,
inspectionStatus: "inspected",
inspection: {
evidence: [],
entropy: 0,
sampleBytes: 0,
strings: [],
findings: [],
},
},
{
...records[0]!,
path: "broken.bin",
inspectionStatus: "error",
inspectionError: "Timed out",
},
]);
const manifest = JSON.parse(text) as {
inspectionCoverage: { inspected: number; failed: number };
files: Array<{
inspectionStatus: string;
inspectionError: string | null;
}>;
};
expect(manifest.inspectionCoverage).toMatchObject({
inspected: 1,
failed: 1,
});
expect(
manifest.files.find((file) => file.inspectionStatus === "error"),
).toMatchObject({ inspectionError: "Timed out" });
expect(
manifestCsv([{ ...records[0]!, inspectionStatus: "cancelled" }]),
).toContain('"cancelled"');
});
});
describe("bounded file operations", () => {
it("plans sanitized collision-safe rename downloads deterministically", () => {
const files = [
{ name: "first.txt", blob: new Blob(["one"]) },
{ name: "second.txt", blob: new Blob(["two"]) },
];
const plan = planBatchRename(files, "same.{ext}");
expect(plan.map((item) => item.filename)).toEqual([
"same.txt",
"same (2).txt",
]);
expect(plan.map((item) => item.originalName)).toEqual([
"first.txt",
"second.txt",
]);
});
it("splits and rejoins byte-exact Blob parts with an offset manifest", async () => {
const source = new Blob([new Uint8Array(150_000).map((_, index) => index)]);
const split = splitBlob(source, "payload.bin", 65_536);
expect(split.parts.map((part) => part.size)).toEqual([
65_536, 65_536, 18_928,
]);
expect(split.manifest.parts[1]).toMatchObject({ offset: 65_536, index: 2 });
const joined = joinBlobs(split.parts.map((part) => part.blob));
expect(new Uint8Array(await joined.arrayBuffer())).toEqual(
new Uint8Array(await source.arrayBuffer()),
);
});
it("refuses unbounded split/join and emits sorted checksum files", () => {
expect(() => splitBlob(new Blob(["x"]), "x", 1)).toThrow(/Chunk size/u);
expect(() =>
joinBlobs([new Blob(["abc"]), new Blob(["def"])], "", 5),
).toThrow(/exceeds/u);
const output = checksumManifest(
[
{
name: "b",
path: "b",
size: 0,
type: "",
lastModified: 0,
sha256: "b".repeat(64),
},
{
name: "a",
path: "a",
size: 0,
type: "",
lastModified: 0,
sha256: "a".repeat(64),
},
],
"sha256",
);
expect(output.split("\n")[0]).toBe(`${"a".repeat(64)} *a`);
});
});
+1
View File
@@ -0,0 +1 @@
Second batch fixture.
+2
View File
@@ -0,0 +1,2 @@
name,value
batch,3
1 name value
2 batch 3