commit b305f83808171e9013f596c11a8205c8cc57079a Author: Lily Rose Date: Wed Jul 16 00:15:34 2025 +1000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc88207 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.beam +*.ez +/build +erl_crash.dump +/priv \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..eaad237 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# pinpoint + +```sh +cd client +gleam run -m lustre/dev build --outdir=../server/priv/static # Build the client +cd .. +cd server +gleam run # Run the server +``` diff --git a/client/.github/workflows/test.yml b/client/.github/workflows/test.yml new file mode 100644 index 0000000..7c92c48 --- /dev/null +++ b/client/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27.1.2" + gleam-version: "1.11.1" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..f7865c6 --- /dev/null +++ b/client/README.md @@ -0,0 +1,5 @@ +# client + +```sh +gleam run -m lustre/dev build --outdir=../server/priv/static +``` diff --git a/client/gleam.toml b/client/gleam.toml new file mode 100644 index 0000000..0fd1535 --- /dev/null +++ b/client/gleam.toml @@ -0,0 +1,19 @@ +name = "client" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +lustre = ">= 5.2.1 and < 6.0.0" +gleam_json = ">= 3.0.2 and < 4.0.0" +gleam_http = ">= 4.1.0 and < 5.0.0" +gleam_httpc = "4.1.1" +plinth = ">= 0.7.1 and < 1.0.0" +gleam_javascript = ">= 1.0.0 and < 2.0.0" +gleam_fetch = ">= 1.3.0 and < 2.0.0" +shared = { path = "../shared" } +rsvp = ">= 1.1.2 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +lustre_dev_tools = ">= 1.9.0 and < 2.0.0" diff --git a/client/manifest.toml b/client/manifest.toml new file mode 100644 index 0000000..d84d348 --- /dev/null +++ b/client/manifest.toml @@ -0,0 +1,62 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "fs", version = "11.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "DD00A61D89EAC01D16D3FC51D5B0EB5F0722EF8E3C1A3A547CD086957F3260A9" }, + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, + { name = "gleam_deque", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_deque", source = "hex", outer_checksum = "64D77068931338CF0D0CB5D37522C3E3CCA7CB7D6C5BACB41648B519CC0133C7" }, + { name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, + { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, + { name = "gleam_http", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DB25DFC8530B64B77105405B80686541A0D96F7E2D83D807D6B2155FB9A8B1B8" }, + { name = "gleam_httpc", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C670EBD46FC1472AD5F1F74F1D3938D1D0AC1C7531895ED1D4DDCB6F07279F43" }, + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, + { name = "gleam_package_interface", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "8F2D19DE9876D9401BB0626260958A6B1580BB233489C32831FE74CE0ACAE8B4" }, + { 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 = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" }, + { 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.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "63022D81C12C17B7F1A60E029964E830A4CBD846BBC6740004FC1F1031AE0326" }, + { name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" }, + { name = "glisten", version = "8.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "17B3CC2E5093662404DDCF7C837D1CA093E5C436CE5F8A532F8EA0D12B5B2172" }, + { name = "gramps", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D213EEE41B467853E1FB9AAC204D2CB1AB301C84E8F7C1DF3307128221AB53BF" }, + { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, + { name = "lustre", version = "5.2.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "DCD121F8E6B7E179B27D9A8AEB6C828D8380E26DF2E16D078511EDAD1CA9F2A7" }, + { name = "lustre_dev_tools", version = "1.9.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_crypto", "gleam_deque", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_regexp", "gleam_stdlib", "glint", "glisten", "mist", "repeatedly", "simplifile", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "2132E6B2B7E89ED87C138FFE1F2CD70D859258D67222F26B5793CDACE9B07D75" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "5.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "0716CE491EA13E1AA1EFEC4B427593F8EB2B953B6EBDEBE41F15BE3D06A22918" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "plinth", version = "0.7.1", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "63BB36AACCCCB82FBE46A862CF85CB88EBE4EF280ECDBAC4B6CB042340B9E1D8" }, + { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, + { name = "rsvp", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "0C0732577712E7CB0E55F057637E62CD36F35306A5E830DC4874B83DA8CE4638" }, + { name = "shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, + { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "tom", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0910EE688A713994515ACAF1F486A4F05752E585B9E3209D8F35A85B234C2719" }, + { name = "wisp", version = "1.8.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "0FE9049AFFB7C8D5FC0B154EEE2704806F4D51B97F44925D69349B3F4F192957" }, +] + +[requirements] +gleam_fetch = { version = ">= 1.3.0 and < 2.0.0" } +gleam_http = { version = ">= 4.1.0 and < 5.0.0" } +gleam_httpc = { version = "4.1.1" } +gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } +gleam_json = { version = ">= 3.0.2 and < 4.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 5.2.1 and < 6.0.0" } +lustre_dev_tools = { version = ">= 1.9.0 and < 2.0.0" } +plinth = { version = ">= 0.7.1 and < 1.0.0" } +rsvp = { version = ">= 1.1.2 and < 2.0.0" } +shared = { path = "../shared" } diff --git a/client/src/client.gleam b/client/src/client.gleam new file mode 100644 index 0000000..6744d36 --- /dev/null +++ b/client/src/client.gleam @@ -0,0 +1,524 @@ +import client/form_field.{type FormField, FormField} +import client/geofeed_form.{type GeofeedForm} +import client/geofeed_form_item.{type GeofeedFormItem, GeofeedFormItem} +import gleam/dict +import gleam/http/response.{type Response} +import gleam/int +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import lustre +import lustre/attribute +import lustre/effect.{type Effect} +import lustre/element.{type Element} +import lustre/element/html +import lustre/element/svg +import lustre/event +import plinth/browser/document +import plinth/browser/element as plinth_element +import rsvp +import shared +import shared/cidr +import shared/city +import shared/country_code +import shared/geofeed +import shared/postcode +import shared/region_code + +pub fn main() { + let geofeed = + document.query_selector("#" <> shared.model_element_id) + |> result.map(plinth_element.inner_text) + |> result.try(fn(json) { + json.parse(json, geofeed.decoder()) + |> result.replace_error(Nil) + }) + |> result.unwrap([]) + let app = lustre.application(init, update, view) + let assert Ok(_) = + lustre.start( + app, + "#" <> shared.app_element_id, + geofeed_form.from_geofeed(geofeed), + ) + Nil +} + +type Model { + Model(geofeed_form: GeofeedForm, saving: Bool, errors: List(String)) +} + +fn init(geofeed_form: GeofeedForm) -> #(Model, Effect(Msg)) { + let model = Model(geofeed_form:, saving: False, errors: []) + #(model, effect.none()) +} + +type Msg { + ServerSavedList(Result(Response(String), rsvp.Error)) + UserSavedList + UserAddedItem(index: Option(Int)) + UserMovedItemUp(index: Int) + UserMovedItemDown(index: Int) + UserDeletedItem(index: Int) + UserUpdatedItem(index: Int, item: GeofeedFormItem) +} + +fn prepend_form_field_errors( + errors errors: List(String), + form_field form_field: FormField(a), + index index: Int, + field_name field_name: String, +) -> List(String) { + case form_field.parsed_value { + Ok(_) -> errors + Error(_) -> [ + "Invalid " <> field_name <> " at position " <> int.to_string(index + 1), + ..errors + ] + } +} + +fn get_errors(model: Model) -> List(String) { + let errors = + list.index_fold(model.geofeed_form, [], fn(acc, item, index) { + acc + |> prepend_form_field_errors( + form_field: item.cidr, + index:, + field_name: "CIDR", + ) + |> prepend_form_field_errors( + form_field: item.country, + index:, + field_name: "country code", + ) + |> prepend_form_field_errors( + form_field: item.region, + index:, + field_name: "region code", + ) + |> prepend_form_field_errors( + form_field: item.city, + index:, + field_name: "city", + ) + |> prepend_form_field_errors( + form_field: item.postcode, + index:, + field_name: "postcode", + ) + }) + let errors = + list.index_fold(model.geofeed_form, #(errors, dict.new()), fn(acc, item, i) { + case item.cidr.parsed_value { + Ok(cidr) -> #( + case acc.1 |> dict.get(cidr) { + Ok(j) -> [ + "Duplicate CIDR at position " + <> int.to_string(i + 1) + <> " (same as position " + <> int.to_string(j + 1) + <> ")", + ..acc.0 + ] + Error(Nil) -> acc.0 + }, + acc.1 |> dict.insert(cidr, i), + ) + Error(Nil) -> acc + } + }).0 + list.reverse(errors) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + ServerSavedList(Ok(_)) -> #( + Model(..model, saving: False, errors: []), + effect.none(), + ) + ServerSavedList(Error(_)) -> #( + Model(..model, saving: False, errors: ["Failed to save list"]), + effect.none(), + ) + UserSavedList -> { + case get_errors(model) { + [] -> #(Model(..model, saving: True), { + let url = "/api/geofeed" + let body = + model.geofeed_form |> geofeed_form.to_geofeed |> geofeed.encode + rsvp.post(url, body, rsvp.expect_ok_response(ServerSavedList)) + }) + errors -> #(Model(..model, errors:), effect.none()) + } + } + UserAddedItem(index:) -> { + let new_item = + GeofeedFormItem( + cidr: FormField(raw_value: "", parsed_value: Error(Nil)), + country: FormField(raw_value: "", parsed_value: Ok(None)), + region: FormField(raw_value: "", parsed_value: Ok(None)), + city: FormField(raw_value: "", parsed_value: Ok(None)), + postcode: FormField(raw_value: "", parsed_value: Ok(None)), + ) + let geofeed_form = case index { + Some(index) -> + list.index_fold(model.geofeed_form, [], fn(acc, item, i) { + case i == index { + True -> [item, new_item, ..acc] + False -> [item, ..acc] + } + }) + |> list.reverse + None -> list.append(model.geofeed_form, [new_item]) + } + #(Model(..model, geofeed_form:), effect.none()) + } + UserMovedItemUp(index:) -> { + let geofeed_form = + list.index_fold(model.geofeed_form, [], fn(acc, cur, i) { + case i == index, acc { + True, [] -> [cur, ..acc] + True, [prev, ..rest] -> [prev, cur, ..rest] + False, _ -> [cur, ..acc] + } + }) + |> list.reverse + #(Model(..model, geofeed_form:), effect.none()) + } + UserMovedItemDown(index:) -> { + let index = list.length(model.geofeed_form) - index - 1 + let geofeed_form = + list.index_fold(list.reverse(model.geofeed_form), [], fn(acc, cur, i) { + case i == index, acc { + True, [] -> [cur, ..acc] + True, [prev, ..rest] -> [prev, cur, ..rest] + False, _ -> [cur, ..acc] + } + }) + #(Model(..model, geofeed_form:), effect.none()) + } + UserDeletedItem(index:) -> { + let geofeed_form = + list.index_fold(model.geofeed_form, [], fn(acc, cur, i) { + case i != index { + True -> [cur, ..acc] + False -> acc + } + }) + |> list.reverse + #(Model(..model, geofeed_form:), effect.none()) + } + UserUpdatedItem(index:, item:) -> { + let geofeed_form = + list.index_map(model.geofeed_form, fn(cur_item, item_index) { + case item_index == index { + True -> item + False -> cur_item + } + }) + #(Model(..model, geofeed_form:), effect.none()) + } + } +} + +const default_font_size_style = #("font-size", "0.9em") + +const text_align_center_style = #("text-align", "center") + +const cursor_pointer_style = #("cursor", "pointer") + +fn form_button( + on_click_msg on_click_msg: Msg, + text text: String, +) -> Element(Msg) { + html.button( + [ + event.on_click(on_click_msg), + attribute.styles([ + #("padding", "0.4em 1em"), + shared.font_family_style, + default_font_size_style, + cursor_pointer_style, + ]), + ], + [html.text(text)], + ) +} + +fn view(model: Model) -> Element(Msg) { + element.fragment([ + html.h1( + [ + attribute.styles([ + #("margin", "0.5em 0 0 0"), + text_align_center_style, + shared.font_family_style, + ]), + ], + [html.text("Pinpoint 📌")], + ), + html.h2( + [ + attribute.styles([ + #("margin", "0.3em 0 0 0"), + text_align_center_style, + shared.font_family_style, + ]), + ], + [html.text("Geofeed Editor")], + ), + html.div( + [ + attribute.styles([ + #("margin", "0.5em 0 0 0"), + text_align_center_style, + shared.font_family_style, + #("font-size", "1em"), + ]), + ], + [ + html.a( + [attribute.href("https://www.iso.org/obp/ui/#iso:pub:PUB500001:en")], + [html.text("View ISO Country/Region Codes")], + ), + ], + ), + html.div( + [attribute.styles([#("display", "flex"), #("justify-content", "center")])], + [view_geofeed_form(model.geofeed_form)], + ), + element.fragment( + model.errors + |> list.map(fn(error) { + html.div( + [ + attribute.styles([ + shared.error_font_color_style, + shared.font_family_style, + default_font_size_style, + text_align_center_style, + #("margin-bottom", "1em"), + ]), + ], + [html.text(error)], + ) + }), + ), + html.div( + [ + attribute.styles([ + #("margin-bottom", "1em"), + #("display", "flex"), + #("justify-content", "center"), + #("gap", "0.6em"), + ]), + ], + [ + form_button(on_click_msg: UserAddedItem(None), text: "Add"), + form_button(on_click_msg: UserSavedList, text: case model.saving { + True -> "Saving..." + False -> "Save" + }), + ], + ), + ]) +} + +fn view_geofeed_form(geofeed_form: GeofeedForm) -> Element(Msg) { + case geofeed_form { + [] -> html.p([], [html.text("No items in your geofeed yet.")]) + _ -> { + let total = list.length(geofeed_form) + html.ol( + [attribute.styles([#("margin", "0"), #("padding", "0")])], + list.index_map(geofeed_form, fn(item, index) { + html.li([], [view_geofeed_item(item, index, total)]) + }), + ) + } + } +} + +fn form_field_input( + form_field form_field: FormField(a), + chars chars: Int, + set_max_length set_max_length: Bool, + placeholder placeholder: String, + on_input_handler on_input_handler: fn(String) -> Msg, +) { + html.input([ + attribute.styles([ + #("padding", "0.4em 0.6em"), + #("width", int.to_string(chars) <> "ch"), + shared.font_family_style, + default_font_size_style, + ..case form_field.parsed_value { + Ok(_) -> [] + Error(_) -> [shared.error_font_color_style] + } + ]), + attribute.placeholder(placeholder), + attribute.value(form_field.raw_value), + event.on_input(on_input_handler), + ..case set_max_length { + True -> [attribute.maxlength(chars)] + False -> [] + } + ]) +} + +fn item_button( + on_click_msg on_click_msg: Msg, + disabled disabled: Bool, + icon_path icon_path: String, +) -> Element(Msg) { + html.button( + [ + event.on_click(on_click_msg), + attribute.disabled(disabled), + attribute.styles([ + #("padding", "0 0.3em"), + #("display", "inline-flex"), + #("align-items", "center"), + shared.font_family_style, + #("font-size", "1.2em"), + ..case disabled { + True -> [] + False -> [cursor_pointer_style] + } + ]), + ], + [ + html.svg( + [ + attribute.attribute("viewBox", "0 0 512 512"), + attribute.attribute("fill", "white"), + attribute.styles([#("width", "1em")]), + ], + [svg.path([attribute.attribute("d", icon_path)])], + ), + ], + ) +} + +fn view_geofeed_item( + item: GeofeedFormItem, + index: Int, + total: Int, +) -> Element(Msg) { + html.div( + [ + attribute.styles([ + #("margin", "1em 0"), + #("display", "flex"), + #("gap", "0.6em"), + ]), + ], + [ + form_field_input( + form_field: item.cidr, + chars: 43, + set_max_length: True, + placeholder: "CIDR", + on_input_handler: fn(value) { + UserUpdatedItem( + index, + GeofeedFormItem( + ..item, + cidr: form_field.parse(string.lowercase(value), cidr.parse), + ), + ) + }, + ), + form_field_input( + form_field: item.country, + chars: 2, + set_max_length: True, + placeholder: "CC", + on_input_handler: fn(value) { + UserUpdatedItem( + index, + GeofeedFormItem( + ..item, + country: form_field.parse_optional( + string.uppercase(value), + country_code.parse, + ), + ), + ) + }, + ), + form_field_input( + form_field: item.region, + chars: 6, + set_max_length: True, + placeholder: "Region", + on_input_handler: fn(value) { + UserUpdatedItem( + index, + GeofeedFormItem( + ..item, + region: form_field.parse_optional( + string.uppercase(value), + region_code.parse, + ), + ), + ) + }, + ), + form_field_input( + form_field: item.city, + chars: 24, + set_max_length: False, + placeholder: "City", + on_input_handler: fn(value) { + UserUpdatedItem( + index, + GeofeedFormItem( + ..item, + city: form_field.parse_optional(value, city.parse), + ), + ) + }, + ), + form_field_input( + form_field: item.postcode, + chars: 10, + set_max_length: False, + placeholder: "Postcode", + on_input_handler: fn(value) { + UserUpdatedItem( + index, + GeofeedFormItem( + ..item, + postcode: form_field.parse_optional(value, postcode.parse), + ), + ) + }, + ), + item_button( + on_click_msg: UserMovedItemUp(index:), + disabled: index == 0, + icon_path: "m233.4 137.4c12.5-12.5 32.8-12.5 45.3 0l160 160c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-137.4-137.4-137.4 137.3c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l160-160z", + ), + item_button( + on_click_msg: UserMovedItemDown(index:), + disabled: index == total - 1, + icon_path: "m233.4 374.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-137.4 137.4-137.4-137.3c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z", + ), + item_button( + on_click_msg: UserAddedItem(index: Some(index)), + disabled: False, + icon_path: "m288 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144-144 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z", + ), + item_button( + on_click_msg: UserDeletedItem(index:), + disabled: False, + icon_path: "m406.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-105.3 105.4-105.4-105.3c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l105.4 105.3-105.3 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l105.3-105.4 105.4 105.3c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-105.4-105.3 105.3-105.4z", + ), + ], + ) +} diff --git a/client/src/client/form_field.gleam b/client/src/client/form_field.gleam new file mode 100644 index 0000000..dd39764 --- /dev/null +++ b/client/src/client/form_field.gleam @@ -0,0 +1,23 @@ +import gleam/option.{type Option, None, Some} +import gleam/result + +pub type FormField(a) { + FormField(raw_value: String, parsed_value: Result(a, Nil)) +} + +pub fn parse( + raw_value: String, + value_parser: fn(String) -> Result(a, Nil), +) -> FormField(a) { + FormField(raw_value:, parsed_value: value_parser(raw_value)) +} + +pub fn parse_optional( + raw_value: String, + value_parser: fn(String) -> Result(a, Nil), +) -> FormField(Option(a)) { + FormField(raw_value:, parsed_value: case raw_value { + "" -> Ok(None) + _ -> value_parser(raw_value) |> result.map(Some) + }) +} diff --git a/client/src/client/geofeed_form.gleam b/client/src/client/geofeed_form.gleam new file mode 100644 index 0000000..249d2b2 --- /dev/null +++ b/client/src/client/geofeed_form.gleam @@ -0,0 +1,15 @@ +import client/geofeed_form_item.{type GeofeedFormItem} +import gleam/list +import gleam/result +import shared/geofeed.{type Geofeed} + +pub type GeofeedForm = + List(GeofeedFormItem) + +pub fn from_geofeed(geofeed: Geofeed) -> GeofeedForm { + geofeed |> list.map(geofeed_form_item.from_geofeed_item) +} + +pub fn to_geofeed(geofeed_form: GeofeedForm) -> Geofeed { + geofeed_form |> list.map(geofeed_form_item.to_geofeed_item) |> result.values +} diff --git a/client/src/client/geofeed_form_item.gleam b/client/src/client/geofeed_form_item.gleam new file mode 100644 index 0000000..c484e53 --- /dev/null +++ b/client/src/client/geofeed_form_item.gleam @@ -0,0 +1,65 @@ +import client/form_field.{type FormField, FormField} +import gleam/option.{type Option, None, Some} +import gleam/result +import shared/cidr.{type Cidr} +import shared/city.{type City} +import shared/country_code.{type CountryCode} +import shared/geofeed_item.{type GeofeedItem, GeofeedItem} +import shared/postcode.{type Postcode} +import shared/region_code.{type RegionCode} + +pub type GeofeedFormItem { + GeofeedFormItem( + cidr: FormField(Cidr), + country: FormField(Option(CountryCode)), + region: FormField(Option(RegionCode)), + city: FormField(Option(City)), + postcode: FormField(Option(Postcode)), + ) +} + +pub fn from_geofeed_item(item: GeofeedItem) -> GeofeedFormItem { + GeofeedFormItem( + cidr: FormField( + raw_value: cidr.to_string(item.cidr), + parsed_value: Ok(item.cidr), + ), + country: FormField( + raw_value: case item.country { + Some(country) -> country_code.to_string(country) + None -> "" + }, + parsed_value: Ok(item.country), + ), + region: FormField( + raw_value: case item.region { + Some(region) -> region_code.to_string(region) + None -> "" + }, + parsed_value: Ok(item.region), + ), + city: FormField( + raw_value: case item.city { + Some(city) -> city.to_string(city) + None -> "" + }, + parsed_value: Ok(item.city), + ), + postcode: FormField( + raw_value: case item.postcode { + Some(postcode) -> postcode.to_string(postcode) + None -> "" + }, + parsed_value: Ok(item.postcode), + ), + ) +} + +pub fn to_geofeed_item(item: GeofeedFormItem) -> Result(GeofeedItem, Nil) { + use cidr <- result.try(item.cidr.parsed_value) + use country <- result.try(item.country.parsed_value) + use region <- result.try(item.region.parsed_value) + use city <- result.try(item.city.parsed_value) + use postcode <- result.try(item.postcode.parsed_value) + Ok(GeofeedItem(cidr:, country:, region:, city:, postcode:)) +} diff --git a/client/test/client_test.gleam b/client/test/client_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/client/test/client_test.gleam @@ -0,0 +1,13 @@ +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 <> "!" + + assert greeting == "Hello, Joe!" +} diff --git a/server/.github/workflows/test.yml b/server/.github/workflows/test.yml new file mode 100644 index 0000000..7c92c48 --- /dev/null +++ b/server/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27.1.2" + gleam-version: "1.11.1" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..4c6bc0a --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,5 @@ +*.beam +*.ez +/build +erl_crash.dump +priv/static/client.mjs \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..09c6f67 --- /dev/null +++ b/server/README.md @@ -0,0 +1,5 @@ +# server + +```sh +gleam run +``` diff --git a/server/gleam.toml b/server/gleam.toml new file mode 100644 index 0000000..8f46a97 --- /dev/null +++ b/server/gleam.toml @@ -0,0 +1,18 @@ +name = "server" +version = "1.0.0" +target = "erlang" + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +shared = { path = "../shared" } +gleam_erlang = ">= 1.2.0 and < 2.0.0" +gleam_http = ">= 4.1.0 and < 5.0.0" +gleam_json = ">= 3.0.2 and < 4.0.0" +wisp = ">= 1.8.0 and < 2.0.0" +mist = ">= 5.0.2 and < 6.0.0" +lustre = ">= 5.2.1 and < 6.0.0" +simplifile = ">= 2.3.0 and < 3.0.0" +argv = ">= 1.0.2 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/server/manifest.toml b/server/manifest.toml new file mode 100644 index 0000000..9848cc2 --- /dev/null +++ b/server/manifest.toml @@ -0,0 +1,44 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, + { name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, + { name = "gleam_http", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DB25DFC8530B64B77105405B80686541A0D96F7E2D83D807D6B2155FB9A8B1B8" }, + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, + { name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" }, + { 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.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "63022D81C12C17B7F1A60E029964E830A4CBD846BBC6740004FC1F1031AE0326" }, + { name = "glisten", version = "8.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "17B3CC2E5093662404DDCF7C837D1CA093E5C436CE5F8A532F8EA0D12B5B2172" }, + { name = "gramps", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D213EEE41B467853E1FB9AAC204D2CB1AB301C84E8F7C1DF3307128221AB53BF" }, + { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, + { name = "lustre", version = "5.2.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "DCD121F8E6B7E179B27D9A8AEB6C828D8380E26DF2E16D078511EDAD1CA9F2A7" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "5.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "0716CE491EA13E1AA1EFEC4B427593F8EB2B953B6EBDEBE41F15BE3D06A22918" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "wisp", version = "1.8.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "0FE9049AFFB7C8D5FC0B154EEE2704806F4D51B97F44925D69349B3F4F192957" }, +] + +[requirements] +argv = { version = ">= 1.0.2 and < 2.0.0" } +gleam_erlang = { version = ">= 1.2.0 and < 2.0.0" } +gleam_http = { version = ">= 4.1.0 and < 5.0.0" } +gleam_json = { version = ">= 3.0.2 and < 4.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 5.2.1 and < 6.0.0" } +mist = { version = ">= 5.0.2 and < 6.0.0" } +shared = { path = "../shared" } +simplifile = { version = ">= 2.3.0 and < 3.0.0" } +wisp = { version = ">= 1.8.0 and < 2.0.0" } diff --git a/server/src/server.gleam b/server/src/server.gleam new file mode 100644 index 0000000..8ad3def --- /dev/null +++ b/server/src/server.gleam @@ -0,0 +1,104 @@ +import argv +import gleam/dynamic/decode +import gleam/erlang/process +import gleam/http.{Get, Post} +import gleam/json +import lustre/attribute +import lustre/element +import lustre/element/html +import mist +import shared +import shared/geofeed +import simplifile +import wisp.{type Request, type Response} +import wisp/wisp_mist + +pub fn main() { + wisp.configure_logger() + let secret_key_base = wisp.random_string(64) + let assert Ok(priv_directory) = wisp.priv_directory("server") + let static_directory = priv_directory <> "/static" + let assert [geofeed_path, ..] = argv.load().arguments + let assert Ok(_) = + handle_request(static_directory, geofeed_path, _) + |> wisp_mist.handler(secret_key_base) + |> mist.new + |> mist.port(3000) + |> mist.start + process.sleep_forever() +} + +fn app_middleware( + req: Request, + static_directory: String, + next: fn(Request) -> Response, +) -> Response { + let req = wisp.method_override(req) + use <- wisp.log_request(req) + use <- wisp.rescue_crashes + use req <- wisp.handle_head(req) + use <- wisp.serve_static(req, under: "/static", from: static_directory) + next(req) +} + +fn handle_request( + static_directory: String, + geofeed_path: String, + req: Request, +) -> Response { + use req <- app_middleware(req, static_directory) + case req.method, wisp.path_segments(req) { + Post, ["api", "geofeed"] -> handle_save_geofeed(req, geofeed_path) + Get, _ -> serve_index(geofeed_path) + _, _ -> wisp.not_found() + } +} + +fn serve_index(geofeed_path: String) -> Response { + html.html( + [ + attribute.styles([ + #("color-scheme", "dark light"), + shared.font_family_style, + ]), + ], + [ + html.head([], [ + html.title([], "Pinpoint - Geofeed Editor"), + html.script( + [attribute.type_("module"), attribute.src("/static/client.mjs")], + "", + ), + ]), + html.script( + [ + attribute.type_("application/json"), + attribute.id(shared.model_element_id), + ], + json.to_string( + geofeed.encode(case simplifile.read(geofeed_path) { + Ok(csv) -> geofeed.from_csv(csv) + Error(_) -> [] + }), + ), + ), + html.body([attribute.styles([#("margin", "0"), #("padding", "0")])], [ + html.div([attribute.id(shared.app_element_id)], []), + ]), + ], + ) + |> element.to_document_string_tree + |> wisp.html_response(200) +} + +fn handle_save_geofeed(req: Request, geofeed_path: String) -> Response { + use json <- wisp.require_json(req) + case decode.run(json, geofeed.decoder()) { + Ok(geofeed) -> + case simplifile.write(geofeed_path, geofeed.to_csv(geofeed)) { + Ok(_) -> wisp.ok() + Error(_) -> wisp.internal_server_error() + } + Error(_) -> wisp.bad_request() + } +} diff --git a/server/test/server_test.gleam b/server/test/server_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/server/test/server_test.gleam @@ -0,0 +1,13 @@ +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 <> "!" + + assert greeting == "Hello, Joe!" +} diff --git a/shared/.github/workflows/test.yml b/shared/.github/workflows/test.yml new file mode 100644 index 0000000..7c92c48 --- /dev/null +++ b/shared/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27.1.2" + gleam-version: "1.11.1" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 0000000..7786e06 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,5 @@ +# shared + +```sh +gleam test +``` diff --git a/shared/gleam.toml b/shared/gleam.toml new file mode 100644 index 0000000..ad24b76 --- /dev/null +++ b/shared/gleam.toml @@ -0,0 +1,9 @@ +name = "shared" +version = "1.0.0" + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +gleam_json = ">= 3.0.2 and < 4.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/shared/manifest.toml b/shared/manifest.toml new file mode 100644 index 0000000..d568168 --- /dev/null +++ b/shared/manifest.toml @@ -0,0 +1,13 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" }, + { name = "gleeunit", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "63022D81C12C17B7F1A60E029964E830A4CBD846BBC6740004FC1F1031AE0326" }, +] + +[requirements] +gleam_json = { version = ">= 3.0.2 and < 4.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/shared/src/shared.gleam b/shared/src/shared.gleam new file mode 100644 index 0000000..0d4e0b3 --- /dev/null +++ b/shared/src/shared.gleam @@ -0,0 +1,9 @@ +pub const app_element_id = "app" + +pub const model_element_id = "model" + +pub const font_family_style = #("font-family", "Maple Mono, monospace") + +pub const error_color = "#ff7083" + +pub const error_font_color_style = #("color", error_color) diff --git a/shared/src/shared/cidr.gleam b/shared/src/shared/cidr.gleam new file mode 100644 index 0000000..ab11401 --- /dev/null +++ b/shared/src/shared/cidr.gleam @@ -0,0 +1,58 @@ +import gleam/bool +import gleam/dynamic/decode.{type Decoder} +import gleam/int +import gleam/json.{type Json} +import gleam/result +import gleam/string +import shared/ip_address.{type IpAddress, V4, V6} + +pub opaque type Cidr { + Cidr(ip_address: IpAddress, subnet: Int) +} + +fn is_valid_subnet(ip_address: IpAddress, subnet: Int) { + subnet >= 0 + && subnet + <= case ip_address { + V4(_) -> 32 + V6(_) -> 128 + } +} + +pub fn new(ip_address: IpAddress, subnet: Int) -> Result(Cidr, Nil) { + use <- bool.guard(!is_valid_subnet(ip_address, subnet), Error(Nil)) + Ok(Cidr(ip_address:, subnet:)) +} + +pub fn get_ip_address(cidr: Cidr) -> IpAddress { + cidr.ip_address +} + +pub fn get_subnet(cidr: Cidr) -> Int { + cidr.subnet +} + +pub fn parse(value: String) -> Result(Cidr, Nil) { + use #(ip_address_str, subnet_str) <- result.try(string.split_once(value, "/")) + use ip_address <- result.try(ip_address.parse(ip_address_str)) + use subnet <- result.try(int.parse(subnet_str)) + use <- bool.guard(!is_valid_subnet(ip_address, subnet), Error(Nil)) + Ok(Cidr(ip_address:, subnet:)) +} + +pub fn decoder() -> Decoder(Cidr) { + use value <- decode.then(decode.string) + case parse(value) { + Ok(cidr) -> decode.success(cidr) + Error(Nil) -> + decode.failure(Cidr(ip_address.V4(ip_address.v4_localhost), 32), "Cidr") + } +} + +pub fn to_string(cidr: Cidr) { + ip_address.to_string(cidr.ip_address) <> "/" <> int.to_string(cidr.subnet) +} + +pub fn encode(cidr: Cidr) -> Json { + json.string(to_string(cidr)) +} diff --git a/shared/src/shared/city.gleam b/shared/src/shared/city.gleam new file mode 100644 index 0000000..364f74d --- /dev/null +++ b/shared/src/shared/city.gleam @@ -0,0 +1,30 @@ +import gleam/bool +import gleam/dynamic/decode.{type Decoder} +import gleam/json.{type Json} +import gleam/string + +pub opaque type City { + City(value: String) +} + +pub fn parse(value: String) -> Result(City, Nil) { + use <- bool.guard(value == "", Error(Nil)) + use <- bool.guard(string.contains(value, ","), Error(Nil)) + Ok(City(value:)) +} + +pub fn decoder() -> Decoder(City) { + use value <- decode.then(decode.string) + case parse(value) { + Ok(city) -> decode.success(city) + Error(Nil) -> decode.failure(City("invalid"), "City") + } +} + +pub fn to_string(city: City) -> String { + city.value +} + +pub fn encode(city: City) -> Json { + json.string(to_string(city)) +} diff --git a/shared/src/shared/country_code.gleam b/shared/src/shared/country_code.gleam new file mode 100644 index 0000000..de5bdb3 --- /dev/null +++ b/shared/src/shared/country_code.gleam @@ -0,0 +1,65 @@ +import gleam/bool +import gleam/dynamic/decode.{type Decoder} +import gleam/json.{type Json} +import gleam/result +import gleam/string + +pub opaque type CountryCode { + CountryCode(value: String) +} + +fn parse_alphabetic(value: String) -> Result(String, Nil) { + case value { + "A" <> rest -> Ok(rest) + "B" <> rest -> Ok(rest) + "C" <> rest -> Ok(rest) + "D" <> rest -> Ok(rest) + "E" <> rest -> Ok(rest) + "F" <> rest -> Ok(rest) + "G" <> rest -> Ok(rest) + "H" <> rest -> Ok(rest) + "I" <> rest -> Ok(rest) + "J" <> rest -> Ok(rest) + "K" <> rest -> Ok(rest) + "L" <> rest -> Ok(rest) + "M" <> rest -> Ok(rest) + "N" <> rest -> Ok(rest) + "O" <> rest -> Ok(rest) + "P" <> rest -> Ok(rest) + "Q" <> rest -> Ok(rest) + "R" <> rest -> Ok(rest) + "S" <> rest -> Ok(rest) + "T" <> rest -> Ok(rest) + "U" <> rest -> Ok(rest) + "V" <> rest -> Ok(rest) + "W" <> rest -> Ok(rest) + "X" <> rest -> Ok(rest) + "Y" <> rest -> Ok(rest) + "Z" <> rest -> Ok(rest) + _ -> Error(Nil) + } +} + +pub fn parse(value: String) -> Result(CountryCode, Nil) { + let value = string.uppercase(value) + use rest <- result.try(parse_alphabetic(value)) + use rest <- result.try(parse_alphabetic(rest)) + use <- bool.guard(rest != "", Error(Nil)) + Ok(CountryCode(value:)) +} + +pub fn decoder() -> Decoder(CountryCode) { + use value <- decode.then(decode.string) + case parse(value) { + Ok(country_code) -> decode.success(country_code) + Error(Nil) -> decode.failure(CountryCode("invalid"), "CountryCode") + } +} + +pub fn to_string(country_code: CountryCode) -> String { + country_code.value +} + +pub fn encode(country_code: CountryCode) -> Json { + json.string(to_string(country_code)) +} diff --git a/shared/src/shared/geofeed.gleam b/shared/src/shared/geofeed.gleam new file mode 100644 index 0000000..ddb215e --- /dev/null +++ b/shared/src/shared/geofeed.gleam @@ -0,0 +1,31 @@ +import gleam/dynamic/decode.{type Decoder} +import gleam/json.{type Json} +import gleam/list +import gleam/result +import gleam/string +import shared/geofeed_item.{type GeofeedItem} + +pub type Geofeed = + List(GeofeedItem) + +pub fn decoder() -> Decoder(Geofeed) { + decode.list(geofeed_item.decoder()) +} + +pub fn encode(geofeed: Geofeed) -> Json { + json.array(geofeed, geofeed_item.encode) +} + +pub fn from_csv(csv: String) -> Geofeed { + csv + |> string.trim + |> string.split("\n") + |> list.map(geofeed_item.from_csv_line) + |> result.values +} + +pub fn to_csv(geofeed: Geofeed) -> String { + geofeed + |> list.map(geofeed_item.to_csv_line) + |> string.join("\n") +} diff --git a/shared/src/shared/geofeed_item.gleam b/shared/src/shared/geofeed_item.gleam new file mode 100644 index 0000000..0e1c33a --- /dev/null +++ b/shared/src/shared/geofeed_item.gleam @@ -0,0 +1,125 @@ +import gleam/bool +import gleam/dynamic/decode.{type Decoder} +import gleam/json.{type Json} +import gleam/option.{type Option, None, Some} +import gleam/pair +import gleam/result +import gleam/string +import shared/cidr.{type Cidr} +import shared/city.{type City} +import shared/country_code.{type CountryCode} +import shared/postcode.{type Postcode} +import shared/region_code.{type RegionCode} + +pub type GeofeedItem { + GeofeedItem( + cidr: Cidr, + country: Option(CountryCode), + region: Option(RegionCode), + city: Option(City), + postcode: Option(Postcode), + ) +} + +pub fn decoder() -> Decoder(GeofeedItem) { + use cidr <- decode.field("cidr", cidr.decoder()) + use country <- decode.optional_field( + "country", + None, + decode.optional(country_code.decoder()), + ) + use region <- decode.optional_field( + "region", + None, + decode.optional(region_code.decoder()), + ) + use city <- decode.optional_field( + "city", + None, + decode.optional(city.decoder()), + ) + use postcode <- decode.optional_field( + "postcode", + None, + decode.optional(postcode.decoder()), + ) + decode.success(GeofeedItem(cidr:, country:, region:, city:, postcode:)) +} + +pub fn encode(geofeed_item: GeofeedItem) -> Json { + json.object([ + #("cidr", cidr.encode(geofeed_item.cidr)), + #("country", json.nullable(geofeed_item.country, country_code.encode)), + #("region", json.nullable(geofeed_item.region, region_code.encode)), + #("city", json.nullable(geofeed_item.city, city.encode)), + #("postcode", json.nullable(geofeed_item.postcode, postcode.encode)), + ]) +} + +pub fn from_csv_line(line: String) -> Result(GeofeedItem, Nil) { + let rest = + line + |> string.trim + |> string.split(",") + use #(cidr, rest) <- result.try(case rest { + [] -> Error(Nil) + [cidr, ..rest] -> cidr.parse(cidr) |> result.map(pair.new(_, rest)) + }) + use #(country, rest) <- result.try(case rest { + [] -> Ok(#(None, [])) + ["", ..rest] -> Ok(#(None, rest)) + [country, ..rest] -> + country_code.parse(country) + |> result.map(Some) + |> result.map(pair.new(_, rest)) + }) + use #(region, rest) <- result.try(case rest { + [] -> Ok(#(None, [])) + ["", ..rest] -> Ok(#(None, rest)) + [region, ..rest] -> + region_code.parse(region) + |> result.map(Some) + |> result.map(pair.new(_, rest)) + }) + use #(city, rest) <- result.try(case rest { + [] -> Ok(#(None, [])) + ["", ..rest] -> Ok(#(None, rest)) + [city, ..rest] -> + city.parse(city) + |> result.map(Some) + |> result.map(pair.new(_, rest)) + }) + use #(postcode, rest) <- result.try(case rest { + [] -> Ok(#(None, [])) + ["", ..rest] -> Ok(#(None, rest)) + [postcode, ..rest] -> + postcode.parse(postcode) + |> result.map(Some) + |> result.map(pair.new(_, rest)) + }) + use <- bool.guard(rest != [], Error(Nil)) + Ok(GeofeedItem(cidr:, country:, region:, city:, postcode:)) +} + +pub fn to_csv_line(item: GeofeedItem) -> String { + [ + cidr.to_string(item.cidr), + case item.country { + Some(country) -> country_code.to_string(country) + None -> "" + }, + case item.region { + Some(region) -> region_code.to_string(region) + None -> "" + }, + case item.city { + Some(city) -> city.to_string(city) + None -> "" + }, + case item.postcode { + Some(postcode) -> postcode.to_string(postcode) + None -> "" + }, + ] + |> string.join(",") +} diff --git a/shared/src/shared/ip_address.gleam b/shared/src/shared/ip_address.gleam new file mode 100644 index 0000000..2a34562 --- /dev/null +++ b/shared/src/shared/ip_address.gleam @@ -0,0 +1,143 @@ +import gleam/bool +import gleam/dynamic/decode.{type Decoder} +import gleam/int +import gleam/json.{type Json} +import gleam/list +import gleam/result +import gleam/string + +pub opaque type Ipv4Address { + Ipv4Address(value: String) +} + +pub const v4_localhost = Ipv4Address("127.0.0.1") + +pub fn parse_v4(value: String) -> Result(Ipv4Address, Nil) { + let value = string.lowercase(value) + case + string.split(value, ".") + |> list.try_map(fn(part) { + case int.base_parse(part, 10) { + Ok(x) if x >= 0 && x <= 255 -> Ok(x) + _ -> Error(Nil) + } + }) + { + Ok([_, _, _, _]) -> { + Ok(Ipv4Address(value:)) + } + _ -> Error(Nil) + } +} + +pub fn v4_decoder() -> Decoder(Ipv4Address) { + use value <- decode.then(decode.string) + case parse_v4(value) { + Ok(ipv4_address) -> decode.success(ipv4_address) + Error(Nil) -> decode.failure(v4_localhost, "Ipv4Address") + } +} + +pub fn v4_to_string(ipv4_address: Ipv4Address) -> String { + ipv4_address.value +} + +pub fn v4_encode(ipv4_address: Ipv4Address) -> Json { + json.string(v4_to_string(ipv4_address)) +} + +pub opaque type Ipv6Address { + Ipv6Address(value: String) +} + +pub const v6_localhost = Ipv6Address("::1") + +fn get_v6_parts(value: String) -> List(String) { + string.split(value, on: ":") +} + +fn parse_v6_parts( + parts: List(String), + value: String, +) -> Result(Ipv6Address, Nil) { + case + parts + |> list.try_map(fn(part) { + case int.base_parse(part, 16) { + Ok(x) if x >= 0 && x <= 0xffff -> Ok(x) + _ -> Error(Nil) + } + }) + { + Ok([_, _, _, _, _, _, _, _]) -> Ok(Ipv6Address(value:)) + _ -> Error(Nil) + } +} + +pub fn parse_v6(value: String) -> Result(Ipv6Address, Nil) { + let value = string.lowercase(value) + case string.split(value, on: "::") { + [left, right] -> { + let left_parts = case left { + "" -> [] + _ -> get_v6_parts(left) + } + let right_parts = case right { + "" -> [] + _ -> get_v6_parts(right) + } + let omitted_parts = 8 - list.length(left_parts) - list.length(right_parts) + use <- bool.guard(omitted_parts <= 0, Error(Nil)) + [left_parts, list.repeat("0", omitted_parts), right_parts] + |> list.flatten + |> parse_v6_parts(value) + } + [_] -> value |> get_v6_parts |> parse_v6_parts(value) + _ -> Error(Nil) + } +} + +pub fn v6_decoder() -> Decoder(Ipv6Address) { + use value <- decode.then(decode.string) + case parse_v6(value) { + Ok(ipv6_address) -> decode.success(ipv6_address) + Error(Nil) -> decode.failure(v6_localhost, "Ipv6Address") + } +} + +pub fn v6_to_string(ipv6_address: Ipv6Address) -> String { + ipv6_address.value +} + +pub fn v6_encode(ipv6_address: Ipv6Address) -> Json { + json.string(v6_to_string(ipv6_address)) +} + +pub type IpAddress { + V4(address: Ipv4Address) + V6(address: Ipv6Address) +} + +pub fn parse(value: String) -> Result(IpAddress, Nil) { + use <- result.lazy_or(parse_v4(value) |> result.map(V4)) + parse_v6(value) |> result.map(V6) +} + +pub fn decoder() -> Decoder(IpAddress) { + use value <- decode.then(decode.string) + case parse(value) { + Ok(ip_address) -> decode.success(ip_address) + Error(Nil) -> decode.failure(V4(v4_localhost), "IpAddress") + } +} + +pub fn to_string(ip_address: IpAddress) -> String { + case ip_address { + V4(address) -> v4_to_string(address) + V6(address) -> v6_to_string(address) + } +} + +pub fn encode(ip_address: IpAddress) -> Json { + json.string(to_string(ip_address)) +} diff --git a/shared/src/shared/postcode.gleam b/shared/src/shared/postcode.gleam new file mode 100644 index 0000000..9af37e4 --- /dev/null +++ b/shared/src/shared/postcode.gleam @@ -0,0 +1,30 @@ +import gleam/bool +import gleam/dynamic/decode.{type Decoder} +import gleam/json.{type Json} +import gleam/string + +pub opaque type Postcode { + Postcode(value: String) +} + +pub fn parse(value: String) -> Result(Postcode, Nil) { + use <- bool.guard(value == "", Error(Nil)) + use <- bool.guard(string.contains(value, ","), Error(Nil)) + Ok(Postcode(value:)) +} + +pub fn decoder() -> Decoder(Postcode) { + use value <- decode.then(decode.string) + case parse(value) { + Ok(postcode) -> decode.success(postcode) + Error(Nil) -> decode.failure(Postcode("invalid"), "Postcode") + } +} + +pub fn to_string(postcode: Postcode) -> String { + postcode.value +} + +pub fn encode(postcode: Postcode) -> Json { + json.string(to_string(postcode)) +} diff --git a/shared/src/shared/region_code.gleam b/shared/src/shared/region_code.gleam new file mode 100644 index 0000000..b5c4fe3 --- /dev/null +++ b/shared/src/shared/region_code.gleam @@ -0,0 +1,95 @@ +import gleam/bool +import gleam/dynamic/decode.{type Decoder} +import gleam/json.{type Json} +import gleam/result +import gleam/string + +pub opaque type RegionCode { + RegionCode(value: String) +} + +fn parse_alphabetic(value: String) -> Result(String, Nil) { + case value { + "A" <> rest -> Ok(rest) + "B" <> rest -> Ok(rest) + "C" <> rest -> Ok(rest) + "D" <> rest -> Ok(rest) + "E" <> rest -> Ok(rest) + "F" <> rest -> Ok(rest) + "G" <> rest -> Ok(rest) + "H" <> rest -> Ok(rest) + "I" <> rest -> Ok(rest) + "J" <> rest -> Ok(rest) + "K" <> rest -> Ok(rest) + "L" <> rest -> Ok(rest) + "M" <> rest -> Ok(rest) + "N" <> rest -> Ok(rest) + "O" <> rest -> Ok(rest) + "P" <> rest -> Ok(rest) + "Q" <> rest -> Ok(rest) + "R" <> rest -> Ok(rest) + "S" <> rest -> Ok(rest) + "T" <> rest -> Ok(rest) + "U" <> rest -> Ok(rest) + "V" <> rest -> Ok(rest) + "W" <> rest -> Ok(rest) + "X" <> rest -> Ok(rest) + "Y" <> rest -> Ok(rest) + "Z" <> rest -> Ok(rest) + _ -> Error(Nil) + } +} + +fn parse_alphanumeric(value: String) -> Result(String, Nil) { + case value { + "0" <> rest -> Ok(rest) + "1" <> rest -> Ok(rest) + "2" <> rest -> Ok(rest) + "3" <> rest -> Ok(rest) + "4" <> rest -> Ok(rest) + "5" <> rest -> Ok(rest) + "6" <> rest -> Ok(rest) + "7" <> rest -> Ok(rest) + "8" <> rest -> Ok(rest) + "9" <> rest -> Ok(rest) + _ -> parse_alphabetic(value) + } +} + +fn parse_optional_alphanumeric(value: String) -> Result(String, Nil) { + case value { + "" -> Ok("") + _ -> parse_alphanumeric(value) + } +} + +pub fn parse(value: String) -> Result(RegionCode, Nil) { + let value = string.uppercase(value) + use rest <- result.try(parse_alphabetic(value)) + use rest <- result.try(parse_alphabetic(rest)) + use rest <- result.try(case rest { + "-" <> rest -> Ok(rest) + _ -> Error(Nil) + }) + use rest <- result.try(parse_alphanumeric(rest)) + use rest <- result.try(parse_optional_alphanumeric(rest)) + use rest <- result.try(parse_optional_alphanumeric(rest)) + use <- bool.guard(rest != "", Error(Nil)) + Ok(RegionCode(value:)) +} + +pub fn decoder() -> Decoder(RegionCode) { + use value <- decode.then(decode.string) + case parse(value) { + Ok(region_code) -> decode.success(region_code) + Error(Nil) -> decode.failure(RegionCode("invalid"), "RegionCode") + } +} + +pub fn to_string(region_code: RegionCode) -> String { + region_code.value +} + +pub fn encode(region_code: RegionCode) -> Json { + json.string(to_string(region_code)) +} diff --git a/shared/test/shared_test.gleam b/shared/test/shared_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/shared/test/shared_test.gleam @@ -0,0 +1,13 @@ +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 <> "!" + + assert greeting == "Hello, Joe!" +}