diff --git a/assets/storage_worker.js b/assets/storage_worker.js new file mode 100644 index 0000000..b9d51f1 --- /dev/null +++ b/assets/storage_worker.js @@ -0,0 +1,27 @@ +/** + * @typedef {object} Message + * @prop {number} id + * @prop {FileSystemFileHandle} file + * @prop {string} content */ + +self.addEventListener( + "message", + /** @param {MessageEvent} event */ + async (event) => { + try { + const { id, file, content } = event.data; + const encoder = new TextEncoder(); + const content_buf = encoder.encode(content); + + const syncHandle = await file.createSyncAccessHandle(); + syncHandle.truncate(0); + syncHandle.write(content_buf); + syncHandle.flush(); + syncHandle.close(); + + self.postMessage({ id }); + } catch (error) { + self.postMessage({ id, error }); + } + }, +); diff --git a/gleam.toml b/gleam.toml index 20db8ca..195cc46 100644 --- a/gleam.toml +++ b/gleam.toml @@ -17,7 +17,6 @@ target = "javascript" gleam_stdlib = ">= 1.0.0 and < 2.0.0" lustre = ">= 5.7.1 and < 6.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" -plinth = ">= 0.10.2 and < 1.0.0" gleam_javascript = ">= 1.0.1 and < 2.0.0" [dev_dependencies] diff --git a/manifest.toml b/manifest.toml index b1eb7c1..aa8f19c 100644 --- a/manifest.toml +++ b/manifest.toml @@ -39,7 +39,6 @@ packages = [ { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, - { name = "plinth", version = "0.10.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "3FE77CED3F19D70918EE32CE8BFB12BE1C28CA004D997F874C2D8DAD2DB73D87" }, { name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" }, { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, @@ -54,4 +53,3 @@ gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } lustre = { version = ">= 5.7.1 and < 6.0.0" } lustre_dev_tools = { version = ">= 2.3.6 and < 3.0.0" } -plinth = { version = ">= 0.10.2 and < 1.0.0" } diff --git a/src/dnd_inventory_tracker.gleam b/src/dnd_inventory_tracker.gleam index 22dc85a..19fd373 100644 --- a/src/dnd_inventory_tracker.gleam +++ b/src/dnd_inventory_tracker.gleam @@ -6,7 +6,6 @@ import gleam/int import gleam/list import gleam/result import gleam/set.{type Set} -import gleam/string import lustre import lustre/attribute import lustre/effect.{type Effect} @@ -374,13 +373,6 @@ fn failed_to_load_view(error: storage.Error) -> Element(Message) { storage.CouldNotGetRootDirectory(error) -> "Could not get root directory: " <> error storage.FileSystemError(error) -> "File system error: " <> error - storage.CouldNotReadInventory(storage.CouldNotGetFile(error)) -> - "Could not read inventory: Could not get file: " <> error - storage.CouldNotReadInventory(storage.CouldNotDecode(decode_error)) -> - // FIXME: this shouldn't use string.inspect - "Could not read inventory: " <> string.inspect(decode_error) - storage.UncaughtInPromise(error) -> - "Uncaught (in promise): " <> string.inspect(error) } html.section([], [ diff --git a/src/dnd_inventory_tracker/storage.gleam b/src/dnd_inventory_tracker/storage.gleam index 2f12735..d64729a 100644 --- a/src/dnd_inventory_tracker/storage.gleam +++ b/src/dnd_inventory_tracker/storage.gleam @@ -1,17 +1,11 @@ import dnd_inventory_tracker/inventory.{type Inventory, Inventory} -import gleam/bit_array import gleam/dict.{type Dict} -import gleam/dynamic import gleam/int -import gleam/javascript/array import gleam/javascript/promise.{type Promise} import gleam/json import gleam/list import gleam/result import lustre/effect.{type Effect} -import plinth/browser/file -import plinth/browser/file_system -import plinth/browser/storage pub type Storage { Storage(inventories: Dict(String, Inventory)) @@ -21,12 +15,10 @@ pub type Error { CouldNotGetStorageManager CouldNotGetRootDirectory(String) FileSystemError(String) - CouldNotReadInventory(InventoryError) - UncaughtInPromise(dynamic.Dynamic) } pub type InventoryError { - CouldNotGetFile(String) + CouldNotGetFile(Error) CouldNotDecode(json.DecodeError) } @@ -35,31 +27,27 @@ pub fn load( error_to_message: fn(Error) -> message, ) -> Effect(message) { effect.from(fn(dispatch) { - let storage_promise = - { - use inventories <- promise.try_await(get_inventory_files()) - use inventories <- promise.map( - promise.await_array(array.map(inventories, load_inventory)), - ) - use inventories <- result.map( - inventories - |> array.to_list - |> list.try_fold(from: dict.new(), with: fn(inventories, inventory) { - case inventory { - Ok(inventory) -> - Ok(dict.insert( - inventory, - into: inventories, - for: inventory.name, - )) - Error(error) -> Error(CouldNotReadInventory(error)) + let storage_promise = { + use inventories <- promise.try_await(get_inventory_files()) + use inventories <- promise.map( + promise.await_list(list.map(inventories, load_inventory)), + ) + + let inventories = + inventories + |> list.fold(from: dict.new(), with: fn(inventories, inventory) { + case inventory { + Ok(inventory) -> + dict.insert(inventory, into: inventories, for: inventory.name) + Error(_error) -> { + // TODO: handle error? + inventories } - }), - ) + } + }) - Storage(inventories:) - } - |> promise_err + Storage(inventories:) |> Ok + } promise.tap(storage_promise, fn(storage) { case storage { Ok(storage) -> dispatch(storage |> storage_to_message) @@ -70,25 +58,22 @@ pub fn load( }) } -fn get_inventory_files() -> Promise( - Result(array.Array(file_system.FileHandle), Error), -) { - use root_dir <- promise.try_await(root_dir()) - use inventories_dir <- promise.try_await(open_dir(root_dir, "inventories")) - use #(_, inventories) <- promise.map_try( - file_system.all_entries(inventories_dir) - |> fs_err, - ) +fn get_inventory_files() -> FsResult(List(FileHandle)) { + use root_dir <- promise.try_await(get_root_directory()) + use inventories_dir <- promise.try_await(get_directory_handle( + in: root_dir, + named: "inventories", + create_if_missing: True, + )) + use #(_, inventories) <- promise.map_try(all_entries(inventories_dir)) Ok(inventories) } fn load_inventory( - from file: file_system.FileHandle, + from file: FileHandle, ) -> Promise(Result(Inventory, InventoryError)) { - use file <- promise.try_await( - file_system.get_file(file) |> map_err(CouldNotGetFile), - ) - use text <- promise.map(file.text(file)) + use file <- promise.try_await(get_file(file) |> map_err(CouldNotGetFile)) + use text <- promise.map_try(file_text(file) |> map_err(CouldNotGetFile)) json.parse(text, using: inventory.decoder()) |> result.map_error(CouldNotDecode) } @@ -127,13 +112,11 @@ pub fn update_inventory( } False -> { effect.from(fn(dispatch) { - let update_promise = - { - // Rename the file, via deleting and creating - use Nil <- promise.try_await(delete_inventory_fs(name)) - write_inventory_fs(inventory) - } - |> promise_err + let update_promise = { + // Rename the file, via deleting and creating + use Nil <- promise.try_await(delete_inventory_fs(name)) + write_inventory_fs(inventory) + } promise.tap(update_promise, fn(res) { case res { Ok(Nil) -> dispatch(updated_inventory_message(name, update)) @@ -152,9 +135,7 @@ fn write_inventory( error_message: fn(Error) -> message, ) -> Effect(message) { effect.from(fn(dispatch) { - let new_inventory_promise = - write_inventory_fs(inventory) - |> promise_err + let new_inventory_promise = write_inventory_fs(inventory) promise.tap(new_inventory_promise, fn(res) { case res { Ok(Nil) -> dispatch(inventory |> new_inventory_message) @@ -165,26 +146,24 @@ fn write_inventory( }) } -fn write_inventory_fs(inventory: Inventory) -> Promise(Result(Nil, Error)) { - use root_dir <- promise.try_await(root_dir()) - use inventories_dir <- promise.try_await(open_dir(root_dir, "inventories")) - use file <- promise.try_await(open_file( - inside: inventories_dir, +fn write_inventory_fs(inventory: Inventory) -> FsResult(Nil) { + use root_dir <- promise.try_await(get_root_directory()) + use inventories_dir <- promise.try_await(get_directory_handle( + in: root_dir, + named: "inventories", + create_if_missing: True, + )) + use file <- promise.try_await(get_file_handle( + in: inventories_dir, named: inventory_filename(inventory.name), + create_if_missing: True, )) - use file <- promise.try_await(file_system.create_writable(file) |> fs_err) - use Nil <- promise.try_await( - file_system.write( - file, - inventory - |> inventory.to_json - |> json.to_string - |> bit_array.from_string, - ) - |> fs_err, + write( + inventory + |> inventory.to_json + |> json.to_string, + to: file, ) - file_system.close(file) - |> fs_err } pub fn delete_inventory( @@ -193,7 +172,7 @@ pub fn delete_inventory( error_message: fn(Error) -> message, ) -> Effect(message) { effect.from(fn(dispatch) { - let delete_inventory_promise = delete_inventory_fs(inventory) |> promise_err + let delete_inventory_promise = delete_inventory_fs(inventory) promise.tap(delete_inventory_promise, fn(res) { case res { Ok(Nil) -> dispatch(inventory |> inventory_deleted_message) @@ -204,15 +183,18 @@ pub fn delete_inventory( }) } -fn delete_inventory_fs(inventory: String) -> Promise(Result(Nil, Error)) { - use root_dir <- promise.try_await(root_dir()) - use inventories_dir <- promise.try_await(open_dir(root_dir, "inventories")) - file_system.remove_entry( - inventories_dir, - inventory_filename(inventory), - False, +fn delete_inventory_fs(inventory: String) -> FsResult(Nil) { + use root_dir <- promise.try_await(get_root_directory()) + use inventories_dir <- promise.try_await(get_directory_handle( + in: root_dir, + named: "inventories", + create_if_missing: False, + )) + remove_entry( + in: inventories_dir, + named: inventory_filename(inventory), + recursive: False, ) - |> fs_err } fn inventory_filename(inventory_name: String) -> String { @@ -229,36 +211,6 @@ fn find_unique_inventory_name(storage: Storage, starting_at i: Int) -> String { } } -fn storage_manager() -> Result(storage.StorageManager, Error) { - storage.get() - |> result.replace_error(CouldNotGetStorageManager) -} - -fn root_dir() -> Promise(Result(file_system.DirectoryHandle, Error)) { - case storage_manager() { - Ok(storage_manager) -> - storage.get_directory(storage_manager) - |> map_err(CouldNotGetRootDirectory) - Error(error) -> promise.resolve(Error(error)) - } -} - -fn open_dir( - inside dir: file_system.DirectoryHandle, - named name: String, -) -> Promise(Result(file_system.DirectoryHandle, Error)) { - file_system.get_directory_handle(dir, name, True) - |> fs_err -} - -fn open_file( - inside dir: file_system.DirectoryHandle, - named name: String, -) -> Promise(Result(file_system.FileHandle, Error)) { - file_system.get_file_handle(dir, name, True) - |> fs_err -} - fn map_err( promise: Promise(Result(value, a)), map: fn(a) -> b, @@ -266,12 +218,76 @@ fn map_err( promise |> promise.map(result.map_error(_, map)) } -fn fs_err( - promise: Promise(Result(value, String)), -) -> Promise(Result(value, Error)) { - promise |> map_err(FileSystemError) +// ################# +// ### EXTERNALS ### +// ################# + +// For external use to prevent cyclical imports +pub fn new_could_not_get_storage_manager() -> Error { + CouldNotGetStorageManager +} + +pub fn new_could_not_get_root_directory(desc: String) -> Error { + CouldNotGetRootDirectory(desc) } -fn promise_err(p: Promise(Result(a, Error))) -> Promise(Result(a, Error)) { - promise.rescue(p, fn(error) { Error(UncaughtInPromise(error)) }) +pub fn new_file_system_error(desc: String) -> Error { + FileSystemError(desc) } + +type FsResult(a) = + Promise(Result(a, Error)) + +type Handle(directory_or_file) + +type DirectoryHandle = + Handle(DirectoryT) + +type FileHandle = + Handle(FileT) + +/// Phantom type marker for Handle, indicating a directory handle. +type DirectoryT + +/// Phantom type marker for Handle, indicating a file handle. +type FileT + +type File + +@external(javascript, "./storage_ffi.js", "get_root_directory") +fn get_root_directory() -> FsResult(DirectoryHandle) + +@external(javascript, "./storage_ffi.js", "all_entries") +fn all_entries( + in directory: DirectoryHandle, +) -> FsResult(#(List(DirectoryHandle), List(FileHandle))) + +@external(javascript, "./storage_ffi.js", "get_directory_handle") +fn get_directory_handle( + in directory: DirectoryHandle, + named name: String, + create_if_missing create_if_missing: Bool, +) -> FsResult(DirectoryHandle) + +@external(javascript, "./storage_ffi.js", "get_file_handle") +fn get_file_handle( + in directory: DirectoryHandle, + named name: String, + create_if_missing create_if_missing: Bool, +) -> FsResult(FileHandle) + +@external(javascript, "./storage_ffi.js", "remove_entry") +fn remove_entry( + in directory: DirectoryHandle, + named name: String, + recursive recursive: Bool, +) -> FsResult(Nil) + +@external(javascript, "./storage_ffi.js", "get_file") +fn get_file(handle: FileHandle) -> FsResult(File) + +@external(javascript, "./storage_ffi.js", "file_text") +fn file_text(file: File) -> FsResult(String) + +@external(javascript, "./storage_ffi.js", "write_file") +fn write(to file: FileHandle, write content: String) -> FsResult(Nil) diff --git a/src/dnd_inventory_tracker/storage_ffi.js b/src/dnd_inventory_tracker/storage_ffi.js new file mode 100644 index 0000000..cdcdcd0 --- /dev/null +++ b/src/dnd_inventory_tracker/storage_ffi.js @@ -0,0 +1,110 @@ +import { Result$Ok, Result$Error } from "../../prelude.mjs"; +import { to_list as array_to_list } from "../../gleam_javascript/gleam/javascript/array.mjs"; +import { + new_could_not_get_storage_manager, + new_could_not_get_root_directory, + new_file_system_error, +} from "./storage.mjs"; + +/** @param {(any) => any} ErrorConstructor @param {(...args) => Promise} fn */ +const catchErrors = + (fn, ErrorConstructor = new_file_system_error) => + (...args) => + fn(...args).catch((error) => { + const message = error?.toString?.(); + if (message == undefined) + console.warn("Could not get message for error:", error); + return Result$Error(ErrorConstructor(message ?? "Unknown error")); + }); + +export const get_root_directory = catchErrors(async () => { + if (!navigator.storage) + return Result$Error(new_could_not_get_storage_manager()); + + const root_dir = await navigator.storage.getDirectory(); + return Result$Ok(root_dir); +}, new_could_not_get_root_directory); + +export const all_entries = catchErrors( + /** @param {FileSystemDirectoryHandle} dir */ async (dir) => { + const dirs = []; + const files = []; + for await (const entry of dir.values()) { + if (entry instanceof FileSystemDirectoryHandle) dirs.push(entry); + else if (entry instanceof FileSystemFileHandle) files.push(entry); + else throw new Error(`Unknown file system entry type: ${typeof entry}`); + } + return Result$Ok([array_to_list(dirs), array_to_list(files)]); + }, +); + +export const get_directory_handle = catchErrors( + /** @param {FileSystemDirectoryHandle} dir @param {string} name @param {boolean} create */ + async (dir, name, create) => + Result$Ok(await dir.getDirectoryHandle(name, { create })), +); + +export const get_file_handle = catchErrors( + /** @param {FileSystemDirectoryHandle} dir @param {string} name @param {boolean} create */ + async (dir, name, create) => + Result$Ok(await dir.getFileHandle(name, { create })), +); + +export const remove_entry = catchErrors( + /** @param {FileSystemDirectoryHandle} dir @param {string} name @param {boolean} recursive */ + async (dir, name, recursive) => + Result$Ok(await dir.removeEntry(name, { recursive })), +); + +export const get_file = catchErrors( + /** @param {FileSystemFileHandle} handle */ + async (handle) => Result$Ok(await handle.getFile()), +); + +export const file_text = catchErrors( + /** @param {File} file */ async (file) => Result$Ok(await file.text()), +); + +// FIXME: This is relative to the current HTML page. The fix might be hosting on +// a subdomain so an absolute path can be used. +const storageWorker = new Worker("./storage_worker.js", { type: "module" }); + +/** + * @typedef {object} Message + * @prop {number} id + * @prop {FileSystemFileHandle} file + * @prop {string} content */ + +/** @type {{ id: number, resolve: () => void, reject: (error: any) => void }[]} */ +const listeners = []; +let nextMessageId = 0; +storageWorker.addEventListener( + "message", + /** @param {MessageEvent<{ id: number, error?: any }>} event */ (event) => { + for (const listener of listeners) { + if (listener.id === event.data.id) { + if (event.error) { + listener.reject(event.data.error); + } else { + listener.resolve(); + } + } + } + }, +); + +export const write_file = catchErrors( + /** @param {FileSystemFileHandle} file @param {string} contents */ + (file, content) => { + /** @type {Message} */ + const data = { id: nextMessageId++, file, content }; + storageWorker.postMessage(data); + return new Promise((resolve, reject) => { + listeners.push({ + id: data.id, + resolve: () => resolve(Result$Ok(undefined)), + reject, + }); + }); + }, +);