From 832ef0382e8898aabbd357362d75b83e1aa52b04 Mon Sep 17 00:00:00 2001 From: Dependency Mode Test Date: Mon, 7 Sep 2026 16:01:40 +0900 Subject: [PATCH] feat(js): add browser file capabilities --- BROWSER_FILE_CAPABILITIES.md | 111 ++++++++ README.ja.md | 14 + README.ko.md | 14 + README.md | 21 ++ gleam.toml | 1 + glendix_guide.ja.md | 11 + glendix_guide.ko.md | 10 + glendix_guide.md | 11 + manifest.toml | 4 + src/glendix/js/file.gleam | 436 ++++++++++++++++++++++++++++++ src/glendix/js/file_ffi.mjs | 6 + test/glendix/js/file_test.gleam | 327 ++++++++++++++++++++++ test/glendix/js/file_test_ffi.mjs | 126 +++++++++ 13 files changed, 1092 insertions(+) create mode 100644 BROWSER_FILE_CAPABILITIES.md create mode 100644 src/glendix/js/file.gleam create mode 100644 src/glendix/js/file_ffi.mjs create mode 100644 test/glendix/js/file_test.gleam create mode 100644 test/glendix/js/file_test_ffi.mjs diff --git a/BROWSER_FILE_CAPABILITIES.md b/BROWSER_FILE_CAPABILITIES.md new file mode 100644 index 0000000..fbf525b --- /dev/null +++ b/BROWSER_FILE_CAPABILITIES.md @@ -0,0 +1,111 @@ +# Browser file capability contract + +Assessment date: 2026-09-07 + +Glendix exposes generic browser file operations through `glendix/js/file`. +Document parsing, persistence, filename normalization, and application-specific +extension rules remain application responsibilities. + +## Capability matrix + +| Responsibility | Implementation | Residual Glendix FFI | +| --- | --- | --- | +| Build a MIME-typed Blob from `BitArray` | `gossamer/blob.from_bytes` | None | +| Create and revoke an object URL | `gossamer/blob.to_object_url` and `revoke_object_url` | None | +| Render a download link | Lustre or Redraw `href` and `download` attributes | None; no hidden anchor or programmatic click | +| Detect the modern picker | `glendix/js/file.picker_capability` | One predicate in `file_ffi.mjs`, because Plinth 0.11.0 has no capability query | +| Open the modern picker | `plinth/browser/file_system.show_open_file_picker` | None | +| Open the selected handle | `plinth/browser/file_system.get_file` | None | +| Read name, MIME type, size, and bytes | `plinth/browser/file` | None | +| Convert a rejected byte read to a domain error | `gleam/javascript/promise.rescue` plus typed dynamic decoding | None | +| Legacy fallback picker | Visible Lustre/Redraw `` owned by the application | No imperative hidden-input adapter | + +The capability predicate is the only production JavaScript added for this +feature. It does not perform selection or file access. Its retention rationale +and contract belong in the retained-FFI inventory tracked by issue #21. + +## Download contract + +`file.download`: + +- rejects a filename that is empty after trimming; +- rejects an empty or malformed concrete MIME type; +- preserves every non-empty filename exactly, without sanitizing it; +- creates the Blob and object URL through Gossamer; +- exposes URL, filename, and MIME accessors for a declarative anchor. + +Call `file.release` when the anchor is replaced or its component is disposed. +Repeated release is safe because Gossamer follows `URL.revokeObjectURL` +semantics. + +```gleam +import gleam/bit_array +import gleam/result +import glendix/js/file +import lustre/attribute +import lustre/element +import lustre/element/html + +pub fn download_link() -> Result(element.Element(message), file.DownloadError) { + use resource <- result.try(file.download( + from: bit_array.from_string("workbook bytes"), + named: "workbook.ic", + with_mime_type: "application/octet-stream", + )) + + Ok(html.a( + [ + attribute.href(file.download_url(resource)), + attribute.download(file.download_filename(resource)), + ], + [html.text("Download")], + )) +} +``` + +The component that stores `resource` must call `file.release(resource)` in its +disposal path. Redraw uses its equivalent `href` and `download` attributes. + +## Picker contract + +`file.picker` requires a positive maximum byte size. Accepted values can be: + +- exact MIME types such as `application/json`; +- MIME wildcards such as `image/*`; +- dot-prefixed extensions such as `.ic`; +- an empty list, meaning any type. + +Duplicates are removed in first-seen order. The stable list is available +through `file.accepted_types` for a visible fallback input's `accept` +attribute. + +`file.pick` uses one file. Plinth 0.11.0 does not expose picker options, and the +browser call is single-select by default; if a browser returns several handles, +Glendix uses the first handle in browser order. + +Validation occurs before reading bytes: + +1. a zero-byte file returns `SelectedFileWasEmpty`; +2. a file larger than the maximum returns `SelectedFileWasTooLarge`; +3. a non-matching MIME type/extension returns + `SelectedFileTypeWasNotAccepted`; +4. only a file that passes metadata checks is read. + +A file exactly equal to the maximum is accepted. Picker cancellation and an +empty handle list return `SelectionCancelled`. Unsupported browsers return +`PickerUnsupported` without attempting the Plinth picker call. Other picker, +handle-open, and byte-read errors preserve their operation and reason. + +## Fallback policy + +Glendix does not claim a package-only cross-browser imperative picker. +Applications that support browsers without `showOpenFilePicker` must choose +one of these explicit policies: + +1. require the modern picker and display an unsupported-capability message; or +2. render a visible Lustre/Redraw file input and process its event in the + application UI layer. + +Glendix does not create, click, or remove a hidden input, because Plinth 0.11.0 +does not fully type `input.files`, programmatic click, one-shot listeners, and +cancellation as one portable operation. diff --git a/README.ja.md b/README.ja.md index e719278..1590758 100644 --- a/README.ja.md +++ b/README.ja.md @@ -216,6 +216,20 @@ unsafe/dynamic な境界です。`__proto__` を通常のデータとして保 JavaScript setter の意味を維持するため、信頼できないプロパティ名を `reflect.set` に渡してはいけません。 +## ブラウザーファイルのダウンロードと選択 + +`glendix/js/file` は `gossamer/blob` で宣言的なダウンロードリソースを +作成し、`plinth/browser/file_system` と `plinth/browser/file` でモダンな +ブラウザーファイル選択を読み取ります。通常の Lustre/Redraw アンカーとして +描画し、置換または破棄時に `file.release` を呼び出します。 + +`showOpenFilePicker` がない場合は `PickerUnsupported` を返します。より広い +ブラウザー対応が必要なアプリケーションは、安定した重複除去済みの +`file.accepted_types` を使って可視のファイル入力を描画できます。Glendix は +隠し入力や隠しアンカーを生成してクリックする FFI を追加しません。API、 +エラー、フォールバック方針、ecosystem 対応表は +[browser file capability contract](BROWSER_FILE_CAPABILITIES.md) を参照してください。 + ## WebAssembly 依存関係 Glendix は、ブラウザ toolchain が使用する次の標準的な静的 URL 形式の diff --git a/README.ko.md b/README.ko.md index aef2775..adbd9f1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -208,6 +208,20 @@ pub fn themed_component( `object.from_entries`에만 적용된다. Reflection 대입은 일반 JavaScript setter 의미를 유지하므로 신뢰할 수 없는 속성 이름을 `reflect.set`에 전달하면 안 된다. +## 브라우저 파일 다운로드와 선택 + +`glendix/js/file`은 `gossamer/blob`으로 선언형 다운로드 리소스를 만들고 +`plinth/browser/file_system` 및 `plinth/browser/file`로 최신 브라우저 파일 +선택을 읽는다. 다운로드는 일반 Lustre/Redraw 앵커로 렌더링하고 컴포넌트가 +교체되거나 해제될 때 `file.release`를 호출한다. + +`showOpenFilePicker`가 없으면 `PickerUnsupported`를 반환한다. 더 넓은 브라우저 +지원이 필요하면 안정적으로 중복 제거된 `file.accepted_types`를 사용해 보이는 +파일 입력을 렌더링할 수 있다. Glendix는 숨겨진 입력이나 앵커를 생성해서 +클릭하는 FFI를 추가하지 않는다. 전체 API, 오류, 폴백 정책 및 생태계 사용 +현황은 [브라우저 파일 capability 계약](BROWSER_FILE_CAPABILITIES.md)을 +참고한다. + ## WebAssembly 의존성 Glendix는 브라우저 도구가 사용하는 다음 표준 정적 URL 형식의 WebAssembly diff --git a/README.md b/README.md index 8855dbf..f4c83ff 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,27 @@ property or method exists. The `__proto__`-as-data guarantee applies to `object.from_entries`; reflective assignment retains ordinary JavaScript setter semantics, so never pass untrusted property names to `reflect.set`. +## Browser file downloads and selection + +`glendix/js/file` creates declarative download resources through +`gossamer/blob` and reads a modern browser file selection through +`plinth/browser/file_system` and `plinth/browser/file`. + +Render downloads as normal Lustre or Redraw anchors with the resource's URL and +filename, then call `file.release` when the anchor is replaced or disposed. +Glendix does not create or click a hidden download anchor. + +The picker reports `PickerUnsupported` when `showOpenFilePicker` is unavailable. +Applications that need broader browser support can render a visible file input +using the stable, de-duplicated `file.accepted_types` list; Glendix does not add +an imperative hidden-input fallback. Empty files, maximum-size overflow, type +mismatch, cancellation, handle failures, and read failures have distinct typed +errors. Application parsing and filename policy remain outside Glendix. + +See [the browser file capability contract](BROWSER_FILE_CAPABILITIES.md) for +the API examples, validation order, fallback policy, and ecosystem/residual-FFI +matrix. + ## WebAssembly dependencies Glendix automatically packages browser WebAssembly modules referenced with the diff --git a/gleam.toml b/gleam.toml index b1deb1f..e9493a9 100644 --- a/gleam.toml +++ b/gleam.toml @@ -23,6 +23,7 @@ plinth = ">= 0.11.0 and < 1.0.0" simplifile = ">= 2.6.0 and < 3.0.0" xmlm = ">= 1.0.1 and < 2.0.0" tom = ">= 2.1.0 and < 3.0.0" +gossamer = ">= 10.0.0 and < 11.0.0" [dev_dependencies] gleeunit = ">= 1.11.0 and < 2.0.0" diff --git a/glendix_guide.ja.md b/glendix_guide.ja.md index 19edc45..b16d54f 100644 --- a/glendix_guide.ja.md +++ b/glendix_guide.ja.md @@ -123,6 +123,17 @@ pub fn themed_component( `object.from_entries` は通常の文字列キーの順序を保持し、重複キーには最後の値を 採用し、特殊なキーもデータとして安全に格納します。 +## ブラウザーファイル capability + +`glendix/js/file` は Gossamer ベースの宣言的ダウンロードリソースと、 +Plinth ベースのモダンなファイル選択を提供します。対応状況、キャンセル、 +メタデータ検証、ハンドルを開く処理、バイト読み取りの失敗をそれぞれ型で +返します。隠し入力のフォールバック、アプリケーション固有の解析やファイル名 +方針は意図的に含みません。 + +API、検証順序、フォールバック方針、ecosystem/残存 FFI の対応表は +[browser file capability contract](BROWSER_FILE_CAPABILITIES.md) を参照してください。 + ## Marketplace ウィジェットとの組み合わせ ```toml diff --git a/glendix_guide.ko.md b/glendix_guide.ko.md index acecff3..cce0535 100644 --- a/glendix_guide.ko.md +++ b/glendix_guide.ko.md @@ -122,6 +122,16 @@ pub fn themed_component( `object.from_entries`는 일반 문자열 키 순서를 보존하고, 중복 키에는 마지막 값을 적용하며, 특수 키도 데이터로 안전하게 저장한다. +## 브라우저 파일 capability + +`glendix/js/file`은 Gossamer 기반 선언형 다운로드 리소스와 Plinth 기반 최신 +파일 선택을 제공한다. 지원 여부, 취소, 메타데이터 검증, 핸들 열기, 바이트 읽기 +실패를 각각 타입으로 반환한다. 숨겨진 입력 폴백과 애플리케이션별 파싱 및 파일명 +정책은 의도적으로 포함하지 않는다. + +API, 검증 순서, 폴백 정책 및 생태계/잔여 FFI 현황은 +[브라우저 파일 capability 계약](BROWSER_FILE_CAPABILITIES.md)을 참고한다. + ## Marketplace 위젯과 조합 ```toml diff --git a/glendix_guide.md b/glendix_guide.md index bd72f72..da4ccba 100644 --- a/glendix_guide.md +++ b/glendix_guide.md @@ -176,6 +176,17 @@ pub fn themed_component( `object.from_entries` preserves ordinary string-key order, keeps the last value for duplicate keys, and safely stores special keys as data. +## Browser file capabilities + +Use `glendix/js/file` for Gossamer-backed declarative download resources and +Plinth-backed modern file selection. It returns typed capability, cancellation, +metadata-validation, handle-open, and byte-read errors. Glendix deliberately +does not add a hidden-input fallback or application-specific parsing and +filename policy. + +See [the browser file capability contract](BROWSER_FILE_CAPABILITIES.md) for +the API, validation order, fallback policy, and ecosystem/residual-FFI matrix. + ## Installed Marketplace widgets Package acquisition is a separate step owned by mxpak: diff --git a/manifest.toml b/manifest.toml index da6f768..7ca770e 100644 --- a/manifest.toml +++ b/manifest.toml @@ -16,9 +16,12 @@ packages = [ { name = "gleam_javascript", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "D542C4B4F40E942F5D3372D524419FA521A7BB92D621AF696CC286E89D882D55" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, { name = "gleam_time", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "gossamer", version = "10.0.0", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_regexp", "gleam_stdlib", "gleam_time", "gleam_yielder"], otp_app = "gossamer", source = "hex", outer_checksum = "BFE6A981CA1BF31AC2925C926241BD9918D809C2CBCA07909024BC765A25013B" }, { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, { name = "lustre", version = "5.7.1", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "663A2D1A3458914CA537A42AC07E601DD3FD85415EBDAF4717F2DFF6E86F90F9" }, { name = "mendraw", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "redraw", "redraw_dom"], otp_app = "mendraw", source = "hex", outer_checksum = "BB913054A5FEDC872AAB114FFADDFB791DACFE605B3DCEEAD3620F11BB59D090" }, @@ -36,6 +39,7 @@ gleam_javascript = { version = ">= 1.0.1 and < 2.0.0" } gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.3 and < 2.0.0" } gleeunit = { version = ">= 1.11.0 and < 2.0.0" } +gossamer = { version = ">= 10.0.0 and < 11.0.0" } lustre = { version = ">= 5.7.1 and < 6.0.0" } mendraw = { version = ">= 2.0.0 and < 3.0.0" } plinth = { version = ">= 0.11.0 and < 1.0.0" } diff --git a/src/glendix/js/file.gleam b/src/glendix/js/file.gleam new file mode 100644 index 0000000..07f5024 --- /dev/null +++ b/src/glendix/js/file.gleam @@ -0,0 +1,436 @@ +//// Provides typed browser download resources and modern file selection. +//// +//// Download resources use `gossamer/blob` for Blob construction and object-URL +//// lifetime. Render the returned URL and filename on a normal Lustre or Redraw +//// anchor; Glendix intentionally does not synthesize or programmatically click a +//// hidden anchor. +//// +//// File selection uses Plinth's File System Access API bindings. Browsers +//// without `showOpenFilePicker` return `PickerUnsupported`; Glendix does not +//// emulate the capability with a hidden input. Applications that need a +//// fallback can render a visible file input with [`accepted_types`](#accepted_types) +//// and handle that input in their UI layer. +//// +//// Application document parsing, persistence, extension policy, and filename +//// normalization remain outside this module. +//// + +import gleam/dynamic +import gleam/dynamic/decode +import gleam/javascript/array +import gleam/javascript/promise +import gleam/list +import gleam/string +import gossamer/blob +import plinth/browser/file as browser_file +import plinth/browser/file_system + +/// Represents a declarative browser download and its object-URL resource. +pub opaque type Download { + Download(filename: String, mime_type: String, url: String) +} + +/// Represents invalid metadata supplied while creating a download. +pub type DownloadError { + /// The suggested filename is empty after surrounding whitespace is removed. + DownloadFilenameWasEmpty + /// The MIME type is empty or does not contain a concrete `type/subtype`. + DownloadMimeTypeWasInvalid(mime_type: String) +} + +/// Represents availability of the modern browser file picker. +pub type PickerCapability { + /// `showOpenFilePicker` is callable in the current browser. + ModernPickerAvailable + /// The current runtime does not expose `showOpenFilePicker`. + ModernPickerUnavailable +} + +/// Configures metadata validation for a selected browser file. +pub opaque type Picker { + Picker(accepted_types: List(String), maximum_size_bytes: Int) +} + +/// Represents invalid picker configuration. +pub type PickerConfigurationError { + /// The maximum permitted file size must be greater than zero. + MaximumSizeWasNotPositive(maximum_size_bytes: Int) + /// An accepted type was neither a MIME type/range nor a file extension. + AcceptedTypeWasInvalid(accepted_type: String) +} + +/// Represents a browser-selected file whose bytes passed configured checks. +pub opaque type SelectedFile { + SelectedFile( + name: String, + mime_type: String, + bytes: BitArray, + size_bytes: Int, + ) +} + +/// Represents a modern file-picker or selected-file failure. +pub type PickerError { + /// The current runtime does not support the modern file picker. + PickerUnsupported + /// The user cancelled selection or the browser returned no selected handle. + SelectionCancelled + /// The picker failed for a reason other than user cancellation. + SelectionFailed(reason: String) + /// The selected handle could not be opened as a browser file. + SelectedFileCouldNotBeOpened(name: String, reason: String) + /// The selected file contains no bytes. + SelectedFileWasEmpty(name: String) + /// The selected file is larger than the configured maximum. + SelectedFileWasTooLarge( + name: String, + size_bytes: Int, + maximum_size_bytes: Int, + ) + /// The selected file does not match any configured MIME type or extension. + SelectedFileTypeWasNotAccepted( + name: String, + mime_type: String, + accepted_types: List(String), + ) + /// The browser rejected reading the selected file's bytes. + SelectedFileCouldNotBeRead(name: String, reason: String) +} + +/// Creates a MIME-typed Blob and object URL for a declarative download link. +/// +/// The filename is preserved exactly. Glendix only rejects an empty filename; +/// application-specific normalization and extension policy remain the caller's +/// responsibility. Release the resource with [`release`](#release) when its +/// anchor is removed or replaced. +pub fn download( + from bytes: BitArray, + named filename: String, + with_mime_type mime_type: String, +) -> Result(Download, DownloadError) { + case string.trim(filename), valid_download_mime_type(mime_type) { + "", _ -> Error(DownloadFilenameWasEmpty) + _, False -> Error(DownloadMimeTypeWasInvalid(mime_type:)) + _, True -> { + let url = + blob.from_bytes(bytes, content_type: mime_type) + |> blob.to_object_url + Ok(Download(filename:, mime_type:, url:)) + } + } +} + +/// Returns the filename to use on a declarative anchor's `download` attribute. +pub fn download_filename(resource resource: Download) -> String { + resource.filename +} + +/// Returns the validated MIME type associated with the download. +pub fn download_mime_type(resource resource: Download) -> String { + resource.mime_type +} + +/// Returns the object URL to use on a declarative anchor's `href` attribute. +pub fn download_url(resource resource: Download) -> String { + resource.url +} + +/// Revokes a download's object URL. +/// +/// The underlying Gossamer operation follows `URL.revokeObjectURL`: releasing +/// the same resource more than once is a safe no-op at the browser boundary. +pub fn release(resource resource: Download) -> Nil { + blob.revoke_object_url(resource.url) +} + +/// Creates picker validation configuration. +/// +/// Accepted values may be exact MIME types (`application/json`), MIME wildcards +/// (`image/*`), or dot-prefixed extensions (`.ic`). An empty list accepts any +/// type. Duplicate values are removed while preserving first-seen order. +pub fn picker( + accepting accepted_types: List(String), + maximum_size_bytes maximum_size_bytes: Int, +) -> Result(Picker, PickerConfigurationError) { + case maximum_size_bytes > 0 { + False -> Error(MaximumSizeWasNotPositive(maximum_size_bytes:)) + True -> + case first_invalid_accepted_type(accepted_types) { + Ok(Nil) -> + Ok(Picker( + accepted_types: deduplicate_accepted_types(accepted_types), + maximum_size_bytes:, + )) + Error(accepted_type) -> Error(AcceptedTypeWasInvalid(accepted_type:)) + } + } +} + +/// Returns accepted MIME types/extensions in stable first-seen order. +/// +/// The result can be passed to Lustre or Redraw's `accept` attribute for a +/// visible declarative fallback input. +pub fn accepted_types(configuration configuration: Picker) -> List(String) { + configuration.accepted_types +} + +/// Reports whether the modern picker is available in the current runtime. +pub fn picker_capability() -> PickerCapability { + case modern_picker_is_available_raw() { + True -> ModernPickerAvailable + False -> ModernPickerUnavailable + } +} + +/// Opens the modern picker and reads one validated file. +/// +/// Plinth's current API does not expose picker options and the browser call is +/// single-select by default. If a browser nevertheless returns multiple +/// handles, the first handle in browser order is used. +pub fn pick( + using configuration: Picker, +) -> promise.Promise(Result(SelectedFile, PickerError)) { + case picker_capability() { + ModernPickerUnavailable -> promise.resolve(Error(PickerUnsupported)) + ModernPickerAvailable -> + file_system.show_open_file_picker() + |> promise.await(fn(selection) { + case selection { + Error(reason) -> + promise.resolve(Error(classify_selection_error(reason))) + Ok(handles) -> + case array.get(handles, 0) { + Error(Nil) -> promise.resolve(Error(SelectionCancelled)) + Ok(handle) -> open_selected_file(configuration, handle) + } + } + }) + } +} + +/// Returns the selected browser filename. +pub fn selected_name(file file: SelectedFile) -> String { + file.name +} + +/// Returns the selected browser MIME type, which may be empty. +pub fn selected_mime_type(file file: SelectedFile) -> String { + file.mime_type +} + +/// Returns the selected file bytes. +pub fn selected_bytes(file file: SelectedFile) -> BitArray { + file.bytes +} + +/// Returns the selected file size in bytes. +pub fn selected_size_bytes(file file: SelectedFile) -> Int { + file.size_bytes +} + +fn open_selected_file( + configuration: Picker, + handle: file_system.FileHandle, +) -> promise.Promise(Result(SelectedFile, PickerError)) { + let name = file_system.name(handle) + file_system.get_file(handle) + |> promise.await(fn(opened) { + case opened { + Error(reason) -> + promise.resolve(Error(SelectedFileCouldNotBeOpened(name:, reason:))) + Ok(file) -> validate_and_read(configuration, file) + } + }) +} + +fn validate_and_read( + configuration: Picker, + file: browser_file.File, +) -> promise.Promise(Result(SelectedFile, PickerError)) { + let name = browser_file.name(file) + let mime_type = browser_file.mime(file) + let size_bytes = browser_file.size(file) + case size_bytes { + 0 -> promise.resolve(Error(SelectedFileWasEmpty(name:))) + size if size > configuration.maximum_size_bytes -> + promise.resolve( + Error(SelectedFileWasTooLarge( + name:, + size_bytes: size, + maximum_size_bytes: configuration.maximum_size_bytes, + )), + ) + _ -> + case + selected_type_is_accepted(name, mime_type, configuration.accepted_types) + { + False -> + promise.resolve( + Error(SelectedFileTypeWasNotAccepted( + name:, + mime_type:, + accepted_types: configuration.accepted_types, + )), + ) + True -> read_selected_file(file, name, mime_type, size_bytes) + } + } +} + +fn read_selected_file( + file: browser_file.File, + name: String, + mime_type: String, + size_bytes: Int, +) -> promise.Promise(Result(SelectedFile, PickerError)) { + file + |> browser_file.bytes + |> promise.map(fn(bytes) { + Ok(SelectedFile(name:, mime_type:, bytes:, size_bytes:)) + }) + |> promise.rescue(fn(rejection) { + Error(SelectedFileCouldNotBeRead(name:, reason: rejection_reason(rejection))) + }) +} + +fn classify_selection_error(reason: String) -> PickerError { + let normalized = string.lowercase(reason) + case + string.contains(normalized, "aborterror") + || string.contains(normalized, "cancelled") + || string.contains(normalized, "canceled") + || string.contains(normalized, "aborted") + { + True -> SelectionCancelled + False -> SelectionFailed(reason:) + } +} + +fn rejection_reason(rejection: dynamic.Dynamic) -> String { + case decode.run(rejection, decode.at(["message"], decode.string)) { + Ok(message) -> + case string.trim(message) { + "" -> "The browser rejected reading the selected file" + _ -> message + } + Error(_) -> + case decode.run(rejection, decode.string) { + Ok(message) -> + case string.trim(message) { + "" -> "The browser rejected reading the selected file" + _ -> message + } + Error(_) -> "The browser rejected reading the selected file" + } + } +} + +fn first_invalid_accepted_type( + accepted_types: List(String), +) -> Result(Nil, String) { + case accepted_types { + [] -> Ok(Nil) + [accepted_type, ..rest] -> + case valid_accepted_type(accepted_type) { + True -> first_invalid_accepted_type(rest) + False -> Error(accepted_type) + } + } +} + +fn deduplicate_accepted_types(accepted_types: List(String)) -> List(String) { + deduplicate_accepted_types_loop(accepted_types, [], []) +} + +fn deduplicate_accepted_types_loop( + accepted_types: List(String), + normalized_seen: List(String), + accumulated: List(String), +) -> List(String) { + case accepted_types { + [] -> list.reverse(accumulated) + [accepted_type, ..rest] -> { + let normalized = string.lowercase(accepted_type) + case list.contains(normalized_seen, normalized) { + True -> + deduplicate_accepted_types_loop(rest, normalized_seen, accumulated) + False -> + deduplicate_accepted_types_loop( + rest, + [normalized, ..normalized_seen], + [accepted_type, ..accumulated], + ) + } + } + } +} + +fn valid_accepted_type(accepted_type: String) -> Bool { + case string.trim(accepted_type) == accepted_type { + False -> False + True -> + case string.starts_with(accepted_type, ".") { + True -> + string.length(accepted_type) > 1 + && !string.contains(accepted_type, " ") + && !string.contains(accepted_type, "/") + && !string.contains(accepted_type, "\\") + False -> valid_media_range(accepted_type) + } + } +} + +fn valid_download_mime_type(mime_type: String) -> Bool { + let base_type = case string.split_once(mime_type, on: ";") { + Ok(#(base_type, _parameters)) -> string.trim(base_type) + Error(Nil) -> string.trim(mime_type) + } + valid_media_range(base_type) && !string.ends_with(base_type, "/*") +} + +fn valid_media_range(media_range: String) -> Bool { + case string.split_once(media_range, on: "/") { + Error(Nil) -> False + Ok(#(type_, subtype)) -> + type_ != "" + && type_ != "*" + && subtype != "" + && !string.contains(type_, " ") + && !string.contains(subtype, " ") + && !string.contains(subtype, "/") + } +} + +fn selected_type_is_accepted( + name: String, + mime_type: String, + accepted_types: List(String), +) -> Bool { + case accepted_types { + [] -> True + _ -> { + let normalized_name = string.lowercase(name) + let normalized_mime_type = string.lowercase(mime_type) + list.any(accepted_types, fn(accepted_type) { + let normalized = string.lowercase(accepted_type) + case string.starts_with(normalized, ".") { + True -> string.ends_with(normalized_name, normalized) + False -> + case string.ends_with(normalized, "/*") { + True -> + case string.split_once(normalized, on: "/") { + Ok(#(type_, "*")) -> + string.starts_with(normalized_mime_type, type_ <> "/") + _ -> False + } + False -> normalized_mime_type == normalized + } + } + }) + } + } +} + +// -- FFI -- +@external(javascript, "./file_ffi.mjs", "modern_picker_is_available") +fn modern_picker_is_available_raw() -> Bool diff --git a/src/glendix/js/file_ffi.mjs b/src/glendix/js/file_ffi.mjs new file mode 100644 index 0000000..4a62dc4 --- /dev/null +++ b/src/glendix/js/file_ffi.mjs @@ -0,0 +1,6 @@ +// Plinth 0.11.0 binds the picker operation itself but does not expose +// capability detection. Keep this adapter to the single missing predicate so +// unsupported runtimes are reported before Plinth attempts the browser call. +export function modern_picker_is_available() { + return typeof globalThis.showOpenFilePicker === "function"; +} diff --git a/test/glendix/js/file_test.gleam b/test/glendix/js/file_test.gleam new file mode 100644 index 0000000..860b4ba --- /dev/null +++ b/test/glendix/js/file_test.gleam @@ -0,0 +1,327 @@ +//// Exercises browser file capability validation and ecosystem boundaries. +//// + +import gleam/bit_array +import gleam/javascript/promise +import gleam/string +import gleeunit/should +import glendix/js/file + +/// Verifies download metadata validation rejects empty filenames and MIME types. +pub fn download_invalid_metadata_test() -> Nil { + file.download( + from: bit_array.from_string("data"), + named: " ", + with_mime_type: "application/octet-stream", + ) + |> should.equal(Error(file.DownloadFilenameWasEmpty)) + + file.download( + from: bit_array.from_string("data"), + named: "data.bin", + with_mime_type: "", + ) + |> should.equal(Error(file.DownloadMimeTypeWasInvalid(mime_type: ""))) + + file.download( + from: bit_array.from_string("data"), + named: "data.bin", + with_mime_type: "application", + ) + |> should.equal( + Error(file.DownloadMimeTypeWasInvalid(mime_type: "application")), + ) +} + +/// Verifies Gossamer owns object-URL creation and repeated cleanup is safe. +pub fn download_resource_lifetime_test() -> Nil { + observe_object_url_lifetime(fn() { + case + file.download( + from: bit_array.from_string("data"), + named: "report.ic", + with_mime_type: "application/octet-stream; charset=binary", + ) + { + Error(_) -> { + should.fail() + "" + } + Ok(resource) -> { + file.download_filename(resource) + |> should.equal("report.ic") + file.download_mime_type(resource) + |> should.equal("application/octet-stream; charset=binary") + let url = file.download_url(resource) + file.release(resource) + file.release(resource) + url + } + } + }) + |> should.equal(#("blob:glendix-test-1", 1, 2)) +} + +/// Verifies picker validation and stable first-seen de-duplication. +pub fn picker_configuration_validation_test() -> Nil { + file.picker(accepting: [], maximum_size_bytes: 0) + |> should.equal(Error(file.MaximumSizeWasNotPositive(maximum_size_bytes: 0))) + file.picker(accepting: ["json"], maximum_size_bytes: 10) + |> should.equal(Error(file.AcceptedTypeWasInvalid(accepted_type: "json"))) + case + file.picker( + accepting: [ + "application/json", + ".ic", + "application/json", + "image/*", + ".IC", + ], + maximum_size_bytes: 10, + ) + { + Error(_) -> should.fail() + Ok(configuration) -> + file.accepted_types(configuration) + |> should.equal(["application/json", ".ic", "image/*"]) + } +} + +/// Verifies unsupported runtimes fail before Plinth attempts picker selection. +pub fn picker_unsupported_test() -> promise.Promise(Nil) { + install_picker_scenario("unsupported") + file.picker_capability() + |> should.equal(file.ModernPickerUnavailable) + case file.picker(accepting: [], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + result + |> should.equal(Error(file.PickerUnsupported)) + picker_invocation_count() + |> should.equal(0) + }) + } +} + +/// Verifies AbortError and an empty handle list both report cancellation. +pub fn picker_cancellation_test() -> promise.Promise(Nil) { + case file.picker(accepting: [], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> { + install_picker_scenario("cancel") + file.pick(using: configuration) + |> promise.await(fn(cancelled) { + cancelled + |> should.equal(Error(file.SelectionCancelled)) + install_picker_scenario("no_handles") + file.pick(using: configuration) + }) + |> promise.map(fn(no_handles) { + no_handles + |> should.equal(Error(file.SelectionCancelled)) + }) + } + } +} + +/// Verifies non-cancellation picker failures preserve their browser reason. +pub fn picker_selection_failure_test() -> promise.Promise(Nil) { + install_picker_scenario("selection_failure") + case file.picker(accepting: [], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + case result { + Error(file.SelectionFailed(reason)) -> + reason + |> string.contains("permission denied") + |> should.be_true + _ -> should.fail() + } + }) + } +} + +/// Verifies a handle-open failure preserves the handle name and reason. +pub fn picker_open_failure_test() -> promise.Promise(Nil) { + install_picker_scenario("open_failure") + case file.picker(accepting: [], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + case result { + Error(file.SelectedFileCouldNotBeOpened(name, reason)) -> { + name + |> should.equal("broken.ic") + reason + |> string.contains("open failed") + |> should.be_true + } + _ -> should.fail() + } + }) + } +} + +/// Verifies empty files fail before a byte read is attempted. +pub fn picker_empty_file_test() -> promise.Promise(Nil) { + install_picker_scenario("empty") + case file.picker(accepting: [], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + result + |> should.equal(Error(file.SelectedFileWasEmpty(name: "empty.ic"))) + picker_read_count() + |> should.equal(0) + }) + } +} + +/// Verifies a file exactly at the maximum is accepted and read once. +pub fn picker_exact_maximum_success_test() -> promise.Promise(Nil) { + install_picker_scenario("exact") + case file.picker(accepting: [".IC"], maximum_size_bytes: 4) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + case result { + Error(_) -> should.fail() + Ok(selected) -> { + file.selected_name(selected) + |> should.equal("workbook.ic") + file.selected_mime_type(selected) + |> should.equal("application/octet-stream") + file.selected_size_bytes(selected) + |> should.equal(4) + file.selected_bytes(selected) + |> bit_array.to_string + |> should.equal(Ok("data")) + picker_read_count() + |> should.equal(1) + } + } + }) + } +} + +/// Verifies overflow fails from metadata without reading file contents. +pub fn picker_maximum_overflow_test() -> promise.Promise(Nil) { + install_picker_scenario("overflow") + case file.picker(accepting: [], maximum_size_bytes: 4) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + result + |> should.equal( + Error(file.SelectedFileWasTooLarge( + name: "large.ic", + size_bytes: 5, + maximum_size_bytes: 4, + )), + ) + picker_read_count() + |> should.equal(0) + }) + } +} + +/// Verifies MIME mismatch fails before reading and wildcard matching succeeds. +pub fn picker_type_validation_test() -> promise.Promise(Nil) { + install_picker_scenario("image") + case file.picker(accepting: ["application/json"], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(rejecting_configuration) -> + file.pick(using: rejecting_configuration) + |> promise.await(fn(rejected) { + rejected + |> should.equal( + Error( + file.SelectedFileTypeWasNotAccepted( + name: "pixel.png", + mime_type: "image/png", + accepted_types: ["application/json"], + ), + ), + ) + picker_read_count() + |> should.equal(0) + install_picker_scenario("image") + case file.picker(accepting: ["image/*"], maximum_size_bytes: 10) { + Error(_) -> { + should.fail() + promise.resolve(rejected) + } + Ok(accepting_configuration) -> + file.pick(using: accepting_configuration) + } + }) + |> promise.map(fn(accepted) { + case accepted { + Ok(selected) -> + file.selected_name(selected) + |> should.equal("pixel.png") + Error(_) -> should.fail() + } + }) + } +} + +/// Verifies byte-read rejection becomes a descriptive typed error. +pub fn picker_read_failure_test() -> promise.Promise(Nil) { + install_picker_scenario("read_failure") + case file.picker(accepting: [], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + result + |> should.equal( + Error(file.SelectedFileCouldNotBeRead( + name: "unreadable.ic", + reason: "read failed", + )), + ) + picker_read_count() + |> should.equal(1) + }) + } +} + +/// Verifies the first handle is selected when a browser returns several. +pub fn picker_first_handle_order_test() -> promise.Promise(Nil) { + install_picker_scenario("multiple") + case file.picker(accepting: [], maximum_size_bytes: 10) { + Error(_) -> promise.resolve(should.fail()) + Ok(configuration) -> + file.pick(using: configuration) + |> promise.map(fn(result) { + case result { + Ok(selected) -> + file.selected_name(selected) + |> should.equal("first.ic") + Error(_) -> should.fail() + } + }) + } +} + +// -- FFI -- +@external(javascript, "./file_test_ffi.mjs", "observe_object_url_lifetime") +fn observe_object_url_lifetime(callback: fn() -> String) -> #(String, Int, Int) + +@external(javascript, "./file_test_ffi.mjs", "install_picker_scenario") +fn install_picker_scenario(scenario: String) -> Nil + +@external(javascript, "./file_test_ffi.mjs", "picker_invocation_count") +fn picker_invocation_count() -> Int + +@external(javascript, "./file_test_ffi.mjs", "picker_read_count") +fn picker_read_count() -> Int diff --git a/test/glendix/js/file_test_ffi.mjs b/test/glendix/js/file_test_ffi.mjs new file mode 100644 index 0000000..52ebc77 --- /dev/null +++ b/test/glendix/js/file_test_ffi.mjs @@ -0,0 +1,126 @@ +let pickerInvocationCount = 0; +let pickerReadCount = 0; + +export function observe_object_url_lifetime(callback) { + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + let createCount = 0; + let revokeCount = 0; + + URL.createObjectURL = () => { + createCount += 1; + return `blob:glendix-test-${createCount}`; + }; + URL.revokeObjectURL = () => { + revokeCount += 1; + }; + + try { + const result = callback(); + return [result, createCount, revokeCount]; + } finally { + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + } +} + +function bytesFile(name, type, contents) { + return { + name, + type, + size: contents.length, + async arrayBuffer() { + pickerReadCount += 1; + return Uint8Array.from(contents).buffer; + }, + }; +} + +function handle(name, fileOrError) { + return { + name, + async getFile() { + if (fileOrError instanceof Error) throw fileOrError; + return fileOrError; + }, + }; +} + +export function install_picker_scenario(scenario) { + pickerInvocationCount = 0; + pickerReadCount = 0; + globalThis.window = globalThis; + + if (scenario === "unsupported") { + delete globalThis.showOpenFilePicker; + return; + } + + globalThis.showOpenFilePicker = async () => { + pickerInvocationCount += 1; + switch (scenario) { + case "cancel": + throw new DOMException("The user aborted a request", "AbortError"); + case "no_handles": + return []; + case "selection_failure": + throw new Error("permission denied"); + case "open_failure": + return [handle("broken.ic", new Error("open failed"))]; + case "empty": + return [handle("empty.ic", bytesFile("empty.ic", "", []))]; + case "exact": + return [ + handle( + "workbook.ic", + bytesFile( + "workbook.ic", + "application/octet-stream", + [100, 97, 116, 97], + ), + ), + ]; + case "overflow": + return [ + handle( + "large.ic", + bytesFile("large.ic", "application/octet-stream", [1, 2, 3, 4, 5]), + ), + ]; + case "image": + return [ + handle( + "pixel.png", + bytesFile("pixel.png", "image/png", [137, 80, 78, 71]), + ), + ]; + case "read_failure": + return [ + handle("unreadable.ic", { + name: "unreadable.ic", + type: "application/octet-stream", + size: 4, + async arrayBuffer() { + pickerReadCount += 1; + throw new Error("read failed"); + }, + }), + ]; + case "multiple": + return [ + handle("first.ic", bytesFile("first.ic", "", [1])), + handle("second.ic", bytesFile("second.ic", "", [2])), + ]; + default: + throw new Error(`unknown picker scenario: ${scenario}`); + } + }; +} + +export function picker_invocation_count() { + return pickerInvocationCount; +} + +export function picker_read_count() { + return pickerReadCount; +}