Initial commit
This commit is contained in:
commit
b305f83808
34 changed files with 1632 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
*.beam
|
||||
*.ez
|
||||
/build
|
||||
erl_crash.dump
|
||||
/priv
|
9
README.md
Normal file
9
README.md
Normal file
|
@ -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 <geofeed_path> # Run the server
|
||||
```
|
23
client/.github/workflows/test.yml
vendored
Normal file
23
client/.github/workflows/test.yml
vendored
Normal file
|
@ -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
|
4
client/.gitignore
vendored
Normal file
4
client/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
*.beam
|
||||
*.ez
|
||||
/build
|
||||
erl_crash.dump
|
5
client/README.md
Normal file
5
client/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# client
|
||||
|
||||
```sh
|
||||
gleam run -m lustre/dev build --outdir=../server/priv/static
|
||||
```
|
19
client/gleam.toml
Normal file
19
client/gleam.toml
Normal file
|
@ -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"
|
62
client/manifest.toml
Normal file
62
client/manifest.toml
Normal file
|
@ -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" }
|
524
client/src/client.gleam
Normal file
524
client/src/client.gleam
Normal file
|
@ -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",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
23
client/src/client/form_field.gleam
Normal file
23
client/src/client/form_field.gleam
Normal file
|
@ -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)
|
||||
})
|
||||
}
|
15
client/src/client/geofeed_form.gleam
Normal file
15
client/src/client/geofeed_form.gleam
Normal file
|
@ -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
|
||||
}
|
65
client/src/client/geofeed_form_item.gleam
Normal file
65
client/src/client/geofeed_form_item.gleam
Normal file
|
@ -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:))
|
||||
}
|
13
client/test/client_test.gleam
Normal file
13
client/test/client_test.gleam
Normal file
|
@ -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!"
|
||||
}
|
23
server/.github/workflows/test.yml
vendored
Normal file
23
server/.github/workflows/test.yml
vendored
Normal file
|
@ -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
|
5
server/.gitignore
vendored
Normal file
5
server/.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
*.beam
|
||||
*.ez
|
||||
/build
|
||||
erl_crash.dump
|
||||
priv/static/client.mjs
|
5
server/README.md
Normal file
5
server/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# server
|
||||
|
||||
```sh
|
||||
gleam run <geofeed_path>
|
||||
```
|
18
server/gleam.toml
Normal file
18
server/gleam.toml
Normal file
|
@ -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"
|
44
server/manifest.toml
Normal file
44
server/manifest.toml
Normal file
|
@ -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" }
|
104
server/src/server.gleam
Normal file
104
server/src/server.gleam
Normal file
|
@ -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()
|
||||
}
|
||||
}
|
13
server/test/server_test.gleam
Normal file
13
server/test/server_test.gleam
Normal file
|
@ -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!"
|
||||
}
|
23
shared/.github/workflows/test.yml
vendored
Normal file
23
shared/.github/workflows/test.yml
vendored
Normal file
|
@ -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
|
4
shared/.gitignore
vendored
Normal file
4
shared/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
*.beam
|
||||
*.ez
|
||||
/build
|
||||
erl_crash.dump
|
5
shared/README.md
Normal file
5
shared/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# shared
|
||||
|
||||
```sh
|
||||
gleam test
|
||||
```
|
9
shared/gleam.toml
Normal file
9
shared/gleam.toml
Normal file
|
@ -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"
|
13
shared/manifest.toml
Normal file
13
shared/manifest.toml
Normal file
|
@ -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" }
|
9
shared/src/shared.gleam
Normal file
9
shared/src/shared.gleam
Normal file
|
@ -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)
|
58
shared/src/shared/cidr.gleam
Normal file
58
shared/src/shared/cidr.gleam
Normal file
|
@ -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))
|
||||
}
|
30
shared/src/shared/city.gleam
Normal file
30
shared/src/shared/city.gleam
Normal file
|
@ -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))
|
||||
}
|
65
shared/src/shared/country_code.gleam
Normal file
65
shared/src/shared/country_code.gleam
Normal file
|
@ -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))
|
||||
}
|
31
shared/src/shared/geofeed.gleam
Normal file
31
shared/src/shared/geofeed.gleam
Normal file
|
@ -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")
|
||||
}
|
125
shared/src/shared/geofeed_item.gleam
Normal file
125
shared/src/shared/geofeed_item.gleam
Normal file
|
@ -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(",")
|
||||
}
|
143
shared/src/shared/ip_address.gleam
Normal file
143
shared/src/shared/ip_address.gleam
Normal file
|
@ -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))
|
||||
}
|
30
shared/src/shared/postcode.gleam
Normal file
30
shared/src/shared/postcode.gleam
Normal file
|
@ -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))
|
||||
}
|
95
shared/src/shared/region_code.gleam
Normal file
95
shared/src/shared/region_code.gleam
Normal file
|
@ -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))
|
||||
}
|
13
shared/test/shared_test.gleam
Normal file
13
shared/test/shared_test.gleam
Normal file
|
@ -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!"
|
||||
}
|
Loading…
Reference in a new issue