diff --git a/src/bonbon.gleam b/src/bonbon.gleam index 0931a48..05ef8b6 100644 --- a/src/bonbon.gleam +++ b/src/bonbon.gleam @@ -4,8 +4,10 @@ import bonbon/list as blist import bonbon/table import gleam/dynamic/decode import gleam/int +import gleam/json import gleam/list import gleam/option.{type Option, None, Some} +import gleam/uri import lustre import lustre/effect.{type Effect} import lustre/element.{type Element} @@ -16,9 +18,9 @@ import lustre/event pub fn main() -> Nil { let app = lustre.application(init, update, view) - let assert Ok(_) = lustre.start(app, "#app", Nil) let assert Ok(_) = table.register() let assert Ok(_) = column_table.register() + let assert Ok(_) = lustre.start(app, "#app", Nil) Nil } @@ -28,12 +30,19 @@ type Model { tables: List(#(String, Table)), table_id: Int, context_menu: Option(#(Int, Int)), + project: Option(Project), ) } +type TableData(table, template) { + NoData + Template(template) + Custom(table) +} + type Table { - GridTable(Option(table.Template)) - ColumnTable(Option(column_table.Template)) + GridTable(TableData(table.PositionedTable, table.Template)) + ColumnTable(TableData(column_table.PositionedTable, column_table.Template)) } type Msg { @@ -42,10 +51,18 @@ type Msg { UserClosedContextMenu CreateGridTable(Option(table.Template)) CreateColumnTable(Option(column_table.Template)) + SaveToFile + TableSerialised(table.PositionedTable) + LoadFromFile + ProjectLoaded(Project) + ColumnTableSerialised(column_table.PositionedTable) } fn init(_flags: a) -> #(Model, Effect(Msg)) { - #(Model(tables: [], context_menu: None, table_id: 0), effect.none()) + #( + Model(tables: [], context_menu: None, table_id: 0, project: None), + effect.none(), + ) } fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { @@ -66,7 +83,7 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model( ..model, tables: [ - #(int.to_string(model.table_id), GridTable(template)), + #(int.to_string(model.table_id), GridTable(option_to_data(template))), ..model.tables ], table_id: model.table_id + 1, @@ -77,35 +94,159 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model( ..model, tables: [ - #(int.to_string(model.table_id), ColumnTable(template)), + #( + int.to_string(model.table_id), + ColumnTable(option_to_data(template)), + ), ..model.tables ], table_id: model.table_id + 1, ), effect.none(), ) + SaveToFile -> #( + Model(..model, project: Some(Project(column_tables: [], tables: []))), + effect.none(), + ) + TableSerialised(table) -> + case model.project { + Some(project) -> { + let project = Project(..project, tables: [table, ..project.tables]) + case is_finished(model.tables, project) { + True -> #(Model(..model, project: None), save_project(project)) + False -> #(Model(..model, project: Some(project)), effect.none()) + } + } + None -> #(model, effect.none()) + } + ColumnTableSerialised(table) -> + case model.project { + Some(project) -> { + let project = + Project(..project, column_tables: [table, ..project.column_tables]) + case is_finished(model.tables, project) { + True -> #(Model(..model, project: None), save_project(project)) + False -> #(Model(..model, project: Some(project)), effect.none()) + } + } + None -> #(model, effect.none()) + } + LoadFromFile -> #( + model, + effect.from(fn(dispatch) { + open_file(fn(contents) { + case json.parse(contents, project_decoder()) { + Ok(project) -> dispatch(ProjectLoaded(project)) + Error(_) -> Nil + } + }) + }), + ) + ProjectLoaded(project) -> { + let #(table_id, tables) = + list.map_fold(project.tables, 0, fn(id, table) { + #(id + 1, #(int.to_string(id), GridTable(Custom(table)))) + }) + + let #(table_id, column_tables) = + list.map_fold(project.column_tables, table_id, fn(id, table) { + #(id + 1, #(int.to_string(id), ColumnTable(Custom(table)))) + }) + + #( + Model( + tables: list.append(tables, column_tables), + table_id:, + context_menu: None, + project: None, + ), + effect.none(), + ) + } + } +} + +fn option_to_data(template: Option(template)) -> TableData(a, template) { + case template { + None -> NoData + Some(template) -> Template(template) } } +@external(javascript, "./bonbon_ffi.mjs", "openFile") +fn open_file(msg: fn(String) -> Nil) -> Nil + +fn save_project(project: Project) -> Effect(Msg) { + use _ <- effect.from + do_save_project( + project |> project_to_json |> json.to_string |> uri.percent_encode, + ) +} + +@external(javascript, "./bonbon_ffi.mjs", "saveProject") +fn do_save_project(project: String) -> Nil + +fn is_finished(tables: List(#(String, Table)), project: Project) -> Bool { + let #(table_count, column_table_count) = + list.fold(tables, #(0, 0), fn(acc, pair) { + let #(t, ct) = acc + case pair.1 { + GridTable(_) -> #(t + 1, ct) + ColumnTable(_) -> #(t, ct + 1) + } + }) + + list.length(project.tables) == table_count + && list.length(project.column_tables) == column_table_count +} + fn view(model: Model) -> Element(Msg) { + let saving = option.is_some(model.project) + html.main([event.prevent_default(event.on("contextmenu", on_contextmenu()))], [ keyed.fragment( list.index_map(model.tables, fn(pair, index) { let #(id, table) = pair let element = case table { - GridTable(None) -> - table.element([table.on_delete(DeleteTable(index))]) - ColumnTable(None) -> - column_table.element([column_table.on_delete(DeleteTable(index))]) - GridTable(Some(template)) -> + GridTable(NoData) -> + table.element([ + table.on_delete(DeleteTable(index)), + table.serialise(saving), + table.on_serialise(TableSerialised), + ]) + ColumnTable(NoData) -> + column_table.element([ + column_table.on_delete(DeleteTable(index)), + column_table.serialise(saving), + column_table.on_serialise(ColumnTableSerialised), + ]) + GridTable(Template(template)) -> table.element([ table.template(template), table.on_delete(DeleteTable(index)), + table.serialise(saving), + table.on_serialise(TableSerialised), ]) - ColumnTable(Some(template)) -> + ColumnTable(Template(template)) -> column_table.element([ column_table.template(template), column_table.on_delete(DeleteTable(index)), + column_table.serialise(saving), + column_table.on_serialise(ColumnTableSerialised), + ]) + GridTable(Custom(data)) -> + table.element([ + table.data(data), + table.on_delete(DeleteTable(index)), + table.serialise(saving), + table.on_serialise(TableSerialised), + ]) + ColumnTable(Custom(data)) -> + column_table.element([ + column_table.data(data), + column_table.serialise(saving), + column_table.on_delete(DeleteTable(index)), + column_table.on_serialise(ColumnTableSerialised), ]) } #(id, element) @@ -144,6 +285,8 @@ fn view(model: Model) -> Element(Msg) { ), ], ), + context_menu.Button("Save to file", SaveToFile), + context_menu.Button("Load from file", LoadFromFile), ], x, y, @@ -161,3 +304,27 @@ fn on_contextmenu() -> decode.Decoder(Msg) { _ -> decode.failure(UserOpenedContextMenu(x:, y:), "") } } + +pub type Project { + Project( + column_tables: List(column_table.PositionedTable), + tables: List(table.PositionedTable), + ) +} + +fn project_decoder() -> decode.Decoder(Project) { + use column_tables <- decode.field( + "column_tables", + decode.list(column_table.decoder()), + ) + use tables <- decode.field("tables", decode.list(table.decoder())) + decode.success(Project(column_tables:, tables:)) +} + +fn project_to_json(project: Project) -> json.Json { + let Project(column_tables:, tables:) = project + json.object([ + #("column_tables", json.array(column_tables, column_table.to_json)), + #("tables", json.array(tables, table.to_json)), + ]) +} diff --git a/src/bonbon/column_table.gleam b/src/bonbon/column_table.gleam index 90a4965..5f64925 100644 --- a/src/bonbon/column_table.gleam +++ b/src/bonbon/column_table.gleam @@ -23,6 +23,17 @@ pub fn register() -> Result(Nil, lustre.Error) { component.on_attribute_change("template", fn(string) { string |> template_from_string |> result.map(SetTemplate) }), + component.on_property_change("serialise", { + use bool <- decode.then(decode.bool) + case bool { + True -> decode.success(Serialise) + False -> decode.failure(Serialise, "") + } + }), + component.on_property_change("data", { + use table <- decode.then(decoder()) + decode.success(LoadFromData(table)) + }), ]) lustre.register(app, tag) @@ -61,6 +72,14 @@ fn emit_delete() -> Effect(Msg) { event.emit("bonbon:delete", json.null()) } +pub fn serialise(bool: Bool) -> attribute.Attribute(a) { + attribute.property("serialise", json.bool(bool)) +} + +pub fn data(data: PositionedTable) -> attribute.Attribute(a) { + attribute.property("data", to_json(data)) +} + type Model { Model( table: Table, @@ -95,9 +114,11 @@ type Msg { UserClickedAwayFromContextMenu RemoveSubheading(heading: Int) DeleteTable + Serialise + LoadFromData(PositionedTable) } -fn word_list() { +pub fn word_list() { let headings = [ Heading("Word", ["Proto", "Modern"]), Heading("Meaning", []), @@ -331,6 +352,16 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) DeleteTable -> #(model, emit_delete()) + + Serialise -> #( + model, + emit_serialise(model.position.0, model.position.1, model.table), + ) + + LoadFromData(data) -> #( + Model(..model, position: #(data.x, data.y), table: data.table), + effect.none(), + ) } } @@ -662,7 +693,11 @@ fn on_contextmenu(field: Option(TableField)) -> decode.Decoder(Msg) { decode.success(UserOpenedContextMenu(x:, y:, field:)) } -type Table { +pub type PositionedTable { + PositionedTable(x: Int, y: Int, table: Table) +} + +pub opaque type Table { Table(headings: List(Heading), rows: List(List(String))) } @@ -682,6 +717,61 @@ type Heading { Heading(main: String, subheadings: List(String)) } +pub fn decoder() -> decode.Decoder(PositionedTable) { + use x <- decode.field("x", decode.int) + use y <- decode.field("y", decode.int) + use table <- decode.field("table", table_decoder()) + decode.success(PositionedTable(x:, y:, table:)) +} + +pub fn to_json(table: PositionedTable) -> json.Json { + let PositionedTable(x:, y:, table:) = table + json.object([ + #("x", json.int(x)), + #("y", json.int(y)), + #("table", table_to_json(table)), + ]) +} + +pub fn on_serialise(msg: fn(PositionedTable) -> msg) -> Attribute(msg) { + event.on( + "bonbon:serialise", + decode.map(decode.at(["detail"], decoder()), msg), + ) +} + +fn emit_serialise(x: Int, y: Int, table: Table) -> Effect(Msg) { + event.emit("bonbon:serialise", to_json(PositionedTable(x, y, table))) +} + +fn table_decoder() -> decode.Decoder(Table) { + use headings <- decode.field("headings", decode.list(heading_decoder())) + use rows <- decode.field("rows", decode.list(decode.list(decode.string))) + decode.success(Table(headings:, rows:)) +} + +fn table_to_json(table: Table) -> json.Json { + let Table(headings:, rows:) = table + json.object([ + #("headings", json.array(headings, heading_to_json)), + #("rows", json.array(rows, json.array(_, json.string))), + ]) +} + +fn heading_to_json(heading: Heading) -> json.Json { + let Heading(main:, subheadings:) = heading + json.object([ + #("main", json.string(main)), + #("subheadings", json.array(subheadings, json.string)), + ]) +} + +fn heading_decoder() -> decode.Decoder(Heading) { + use main <- decode.field("main", decode.string) + use subheadings <- decode.field("subheadings", decode.list(decode.string)) + decode.success(Heading(main:, subheadings:)) +} + fn td( width colspan: Int, height rowspan: Int, diff --git a/src/bonbon/table.gleam b/src/bonbon/table.gleam index 3a705f6..b21db77 100644 --- a/src/bonbon/table.gleam +++ b/src/bonbon/table.gleam @@ -23,6 +23,17 @@ pub fn register() -> Result(Nil, lustre.Error) { component.on_attribute_change("template", fn(string) { string |> template_from_string |> result.map(SetTemplate) }), + component.on_property_change("serialise", { + use bool <- decode.then(decode.bool) + case bool { + True -> decode.success(Serialise) + False -> decode.failure(Serialise, "") + } + }), + component.on_property_change("data", { + use table <- decode.then(decoder()) + decode.success(LoadFromData(table)) + }), ]) lustre.register(app, tag) @@ -67,6 +78,121 @@ fn emit_delete() -> Effect(Msg) { event.emit("bonbon:delete", json.null()) } +pub fn serialise(bool: Bool) -> attribute.Attribute(a) { + attribute.property("serialise", json.bool(bool)) +} + +pub fn data(data: PositionedTable) -> attribute.Attribute(a) { + attribute.property("data", to_json(data)) +} + +pub type PositionedTable { + PositionedTable(x: Int, y: Int, table: Table) +} + +pub fn decoder() -> decode.Decoder(PositionedTable) { + use x <- decode.field("x", decode.int) + use y <- decode.field("y", decode.int) + use table <- decode.field("table", table_decoder()) + decode.success(PositionedTable(x:, y:, table:)) +} + +pub fn to_json(table: PositionedTable) -> json.Json { + let PositionedTable(x:, y:, table:) = table + json.object([ + #("x", json.int(x)), + #("y", json.int(y)), + #("table", table_to_json(table)), + ]) +} + +pub fn on_serialise(msg: fn(PositionedTable) -> msg) -> Attribute(msg) { + event.on( + "bonbon:serialise", + decode.map(decode.at(["detail"], decoder()), msg), + ) +} + +fn emit_serialise(x: Int, y: Int, table: Table) -> Effect(Msg) { + event.emit("bonbon:serialise", to_json(PositionedTable(x, y, table))) +} + +fn table_decoder() -> decode.Decoder(Table) { + use column_headings <- decode.field( + "column_headings", + decode.list(heading_decoder()), + ) + use row_headings <- decode.field( + "row_headings", + decode.list(heading_decoder()), + ) + use items <- decode.field( + "items", + decode.list(item_decoder()) |> decode.map(dict.from_list), + ) + decode.success(Table(column_headings:, row_headings:, items:)) +} + +fn table_to_json(table: Table) -> json.Json { + let Table(column_headings:, row_headings:, items:) = table + json.object([ + #("column_headings", json.array(column_headings, heading_to_json)), + #("row_headings", json.array(row_headings, heading_to_json)), + #("items", json.array(dict.to_list(items), item_to_json)), + ]) +} + +fn item_to_json(item: #(Path, String)) -> json.Json { + let #(path, value) = item + + json.object([ + #("column_heading", heading_path_to_json(path.column_heading)), + #("row_heading", heading_path_to_json(path.row_heading)), + #("value", json.string(value)), + ]) +} + +fn item_decoder() -> decode.Decoder(#(Path, String)) { + use column_heading <- decode.field("column_heading", heading_path_decoder()) + use row_heading <- decode.field("row_heading", heading_path_decoder()) + use value <- decode.field("value", decode.string) + decode.success(#(Path(column_heading:, row_heading:), value)) +} + +fn heading_path_to_json(column_heading: HeadingPath) -> json.Json { + case column_heading { + TopLevel(heading:) -> json.object([#("heading", json.int(heading))]) + Subheading(heading:, sub:) -> + json.object([ + #("heading", json.int(heading)), + #("sub", json.int(sub)), + ]) + } +} + +fn heading_path_decoder() -> decode.Decoder(HeadingPath) { + use heading <- decode.field("heading", decode.int) + use sub <- decode.optional_field("sub", -1, decode.int) + decode.success(case sub { + -1 -> TopLevel(heading:) + _ -> Subheading(heading:, sub:) + }) +} + +fn heading_to_json(heading: Heading) -> json.Json { + let Heading(main:, subheadings:) = heading + json.object([ + #("main", json.string(main)), + #("subheadings", json.array(subheadings, json.string)), + ]) +} + +fn heading_decoder() -> decode.Decoder(Heading) { + use main <- decode.field("main", decode.string) + use subheadings <- decode.field("subheadings", decode.list(decode.string)) + decode.success(Heading(main:, subheadings:)) +} + type Model { Model( table: Table, @@ -103,6 +229,8 @@ type Msg { RemoveColumnSubheading(heading: Int) DeleteTable PrintTable + Serialise + LoadFromData(PositionedTable) } fn init(_flags: a) -> #(Model, Effect(Msg)) { @@ -616,6 +744,16 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Nil }), ) + + Serialise -> #( + model, + emit_serialise(model.position.0, model.position.1, model.table), + ) + + LoadFromData(data) -> #( + Model(..model, position: #(data.x, data.y), table: data.table), + effect.none(), + ) } } @@ -973,7 +1111,7 @@ fn right_click_event( } } -type Table { +pub opaque type Table { Table( column_headings: List(Heading), row_headings: List(Heading), @@ -1101,7 +1239,7 @@ fn get_or_empty(dict: dict.Dict(a, String), key: a) -> String { // -------------- TEMPLATES -fn consonants() { +pub fn consonants() { Table( column_headings: [ Heading("Bilabial", []), diff --git a/src/bonbon_ffi.mjs b/src/bonbon_ffi.mjs index 0f8ffb8..ebad403 100644 --- a/src/bonbon_ffi.mjs +++ b/src/bonbon_ffi.mjs @@ -17,3 +17,33 @@ export function setCursor(root, position) { export function addWindowEventListener(event, once, callback) { window.addEventListener(event, callback, { once }); } + +export function saveProject(project) { + const url = `data:application/json;charset=UTF-8,${project}`; + + const a = document.createElement("a"); + a.setAttribute("href", url); + a.setAttribute("download", "project.json"); + + console.log(a); + + a.click(); +} + +export function openFile(next) { + const input = document.createElement("input"); + input.type = "file"; + + input.onchange = e => { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.readAsText(file, 'UTF-8'); + + reader.onload = readerEvent => { + const content = readerEvent.target.result; + next(content); + } + } + + input.click(); +} diff --git a/test/bonbon_test.gleam b/test/bonbon_test.gleam index fba3c88..3ead5f2 100644 --- a/test/bonbon_test.gleam +++ b/test/bonbon_test.gleam @@ -1,13 +1,22 @@ +import bonbon/column_table +import bonbon/table +import gleam/json import gleeunit pub fn main() -> Nil { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - let name = "Joe" - let greeting = "Hello, " <> name <> "!" +pub fn table_roundtrip_test() { + let table = table.PositionedTable(42, 917, table.consonants()) + let json = json.to_string(table.to_json(table)) + let assert Ok(parsed) = json.parse(json, table.decoder()) + assert parsed == table +} - assert greeting == "Hello, Joe!" +pub fn column_roundtrip_test() { + let table = column_table.PositionedTable(42, 917, column_table.word_list()) + let json = json.to_string(column_table.to_json(table)) + let assert Ok(parsed) = json.parse(json, column_table.decoder()) + assert parsed == table }