diff --git a/.gitignore b/.gitignore index b84b594..d9c9084 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ dist/ .vscode/tasks.json examples/test.eve +*.log diff --git a/Cargo.toml b/Cargo.toml index 0b4968a..9924498 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,5 +28,11 @@ unicode-segmentation = "1.1.0" iron = "0.5" staticfile = "*" mount = "*" +hyper = "0.11.2" +hyper-tls = "0.1.2" +futures = "0.1.14" +tokio-core = "0.1.9" +data-encoding = "1.2.0" +urlencoding = "1.0.0" natord = "1.0.9" notify = "4.0.0" diff --git a/examples/bouncing-balls.eve b/examples/bouncing-balls.eve index d3f56a7..ed2c497 100644 --- a/examples/bouncing-balls.eve +++ b/examples/bouncing-balls.eve @@ -8,7 +8,7 @@ commit end search - order = math/range[from:1 to:200] + order = math/range[from:1 to:1] rand = random/number[seed: order] rand2 = random/number[seed: order * order] x = rand * 500 @@ -20,17 +20,17 @@ commit end search - boid = [#boid x > 490] + boid = [#boid x > 500] boid.vx > 0 commit boid.vx := boid.vx * -0.9 end search - boid = [#boid y > 490] + boid = [#boid y > 500] boid.vy > 0 commit - boid.vy := boid.vy * -0.9 + boid.vy := boid.vy * -0.7 end search @@ -53,7 +53,7 @@ search commit boid.x := x + vx boid.y := y + vy - boid.vy := vy + 0.07 + boid.vy := vy + 0.16 end search diff --git a/examples/card-validation.eve b/examples/card-validation.eve new file mode 100644 index 0000000..84e2b26 --- /dev/null +++ b/examples/card-validation.eve @@ -0,0 +1,168 @@ +# Credit Card Validation + +commit + [#credit-card number: "438857601841070"] +end + +## Rules for Valid cards + +- Be between 13 and 16 digits + +- Must start with: +- 4 for Visa cards +- 5 for Master cards +- 37 for American Express cards +- 6 for Discover cards + +- Each number goes through a transformation, then is checked whether or not the + result is divisible by 10. If divisible, then the number is valid. If not, + then it is not. +- The transformation can be described below: + 1. Double every second digit from right to left. If doubling of a digit results + in a two-digit number, add up the two digits to get a single-digit number. + 2. Now add all single-digit numbers from Step 1. + 3. Add all digits in the odd places from right to left in the card number + 4. Sum the results from Step 2 and Step 3. + 5. The result is the number transformed + +## Identify Bank + +search + cc = [#credit-card number] + first-digits = string/substring[text: number from: 1 to: 3] +bind + cc.first-digits += first-digits +end + +search + cc = [#credit-card first-digits] + bank = if "4" = string/substring[text: first-digits from: 1 to: 2] then "Visa" + if "5" = string/substring[text: first-digits from: 1 to: 2] then "Mastercard" + if "37" = first-digits then "American Express" + if "6" = string/substring[text: first-digits from: 1 to: 2] then "Discover" +bind + cc.bank += bank +end + +## Transform the cc number + +Index every digit in each number + +search + cc = [#credit-card number] + (token,index) = string/split[text: number by: ""] + index - 1 > 0 + index - 1 < 17 + numerical-digit = eve/parse-value[value: token] +bind + [#digits cc index: index - 1 digit: numerical-digit] +end + +search + digits = [#digits cc index digit] + number-length = string/length[text: cc.number] + reverse-index = -1 * (index - number-length) + 1 +bind + + digits.reverse-index += reverse-index +end + +### Step 1. + +Double every second digit from the right. We'll tag these digits as `#even-index` + +search + digits = [#digits cc reverse-index digit] + 0 = math/mod[value: reverse-index, by: 2] +bind + digits += #even-index +end + +If a doubled digit is >= 10, sum the digits of that doubled digit. Othewise +just double the digit + +search + digi = [#digits #even-index] + doubled = if digi.digit < 5 then digi.digit * 2 + else if digi.digit = 5 then 1 + else if digi.digit = 6 then 3 + else if digi.digit = 7 then 5 + else if digi.digit = 8 then 7 + else if digi.digit = 9 then 9 +bind + digi.doubled += doubled +end + +### Step 2 + +Sum all of the doubled digits in Step 1 together + +search + digi = [#digits #even-index cc reverse-index doubled] + doubled-sum = gather/sum[value: doubled for: (doubled,reverse-index) per: cc] +bind + cc.step-two-sum += doubled-sum +end + +### Step 3 + +Sum the odd digits starting from the right + +search + digits = [#digits cc reverse-index] + 1 = math/mod[value: reverse-index, by: 2] +bind + digits += #odd-index +end + +search + digits = [#digits #odd-index cc reverse-index] + step-three-sum = gather/sum[value: digits.digit for: reverse-index per: cc] +bind + cc.step-three-sum += step-three-sum +end + +### Step 4 + +Sum the results from step 2 and 3 + +search + cc = [#credit-card step-two-sum step-three-sum] + transformed-number = step-two-sum + step-three-sum +bind + cc.transformed-number += transformed-number +end + +## Test Validity + +A number is valid if it is between 13 and 16 digits and the transformation +is divisible by 10 + +search + cc = [#credit-card number transformed-number] + number-length = string/length[text: number] + number-length >= 13 + number-length <= 16 + 0 = math/mod[value: transformed-number by: 10] +bind + cc += #valid +end + + +## Some Output for Testing + +search + digi = [#digits cc index reverse-index digit] + doubled = if digi.doubled then digi.doubled + else "" + valid = if cc = [#valid] then "valid" + else "invalid" +bind + [#html/div #container cc | children: + [#html/div sort: -1 text: cc.number] + [#html/div sort: -2 text: cc.bank] + [#html/div sort: -2 text: valid]] +end + + + diff --git a/examples/gui.eve b/examples/gui.eve new file mode 100644 index 0000000..cea69f8 --- /dev/null +++ b/examples/gui.eve @@ -0,0 +1,34 @@ +# Gui Editor + +This is a drag and drop editor for interfaces in Eve + +commit + [#app/settings name: "GUI Builder"] + showlist = [#app/page #default name: "Editor" icon: "ios-list-outline" sort: 1] + [#app/ui] +end + + +search + page.name = "Editor" + [#app/ui page] +bind + page <- [#html/div #html/listener/context-menu #main-area | children: + [#html/div text: "Hello world"]] +end + + +search + [#html/event/mouse-up button: "right" target: container page-x page-y] + //canvas = [#main-area] +commit + //container.children += [#container page-x page-y] + container.children += [#container text: "{{page-x}}{{page-y}}" x: page-x y: page-y] +end + + +search + container = [#container x y] +bind + container <- [#html/div #html/listener/context-menu style: [min-width: "100px" min-height: "100px" padding: "10px" border: "1px solid black"]] +end \ No newline at end of file diff --git a/examples/json-demo.eve b/examples/json-demo.eve new file mode 100644 index 0000000..c2fce1d --- /dev/null +++ b/examples/json-demo.eve @@ -0,0 +1,159 @@ +# JSON Testing + +## Test Encoding + +commit + corey = [#person name: "Corey" | age: 31] + giselle = [#cat name: "Giselle" | age: 7] + twylah = [#cat name: "Twylah" | age: 7] + rachel = [#person name: "Rachel" | age: 28 cats: (giselle, twylah) husband: corey] +end + +commit + [#system/timer resolution: 1000] +end + +search + p = [#person name: "Rachel" age] + [#system/timer second] +bind + p.time += second +end + +search + p = [#person name: "Rachel"] +bind + [#json/encode record: p] +end + +commit + [#ui/button #change-record sort: -1 text: "Change"] + [#ui/button #add-record sort: -1 text: "Add"] +end + +search + [#html/event/click element: [#change-record]] + p = [#person name: "Rachel"] +commit + p.age := 29 +end + +search + [#html/event/click element: [#add-record]] + p = [#person name: "Rachel"] +commit + p.foo := "Hello" +end + + + +search + [#json/encode json-string] +bind + [#html/div text: "Encoded JSON: {{json-string}}"] +end + +## Test Decode + +Decode the record we just encoded + +//search + //[#json/encode json-string] +//commit + //[#json/decode json: json-string] +//end + +//search + //[#json/decode json-object] +//commit + //[#html/div text: "Decoded JSON: {{json-object.name}} is {{json-object.age}} years old"] +//end + + +//commit + //[#ui/button #add-record text: "Add to record"] + //[#ui/button #remove-record text: "Remove From Record"] +//end + +## Debug Display + +search + [#json/encode] + [#json/encode/record record json-string] + output = [#output] +bind + output.children += [#html/div sort: json-string text: "Target: {{json-string}}"] +end + +search + [#json/encode] + [#json/encode/sub-target record json-string] + output = [#output] +bind + output.children += [#html/div text: "Sub: {{json-string}}"] +end + +search + [#json/encode] + [#completed/target record json-string] + output = [#application] +bind + output.children += [#html/div text: "Completed {{json-string}}"] +end + +search + [#json/encode] + encode = [#encode-eavs] + eav = [#json/encode/eav record attribute value] + entity? = if eav = [#json/encode/entity] then "entity" + else "" +bind + encode <- [children: + [#html/div sort: 0 text: "Encode EAVs"] + [#html/table #eav-table | children: + [#html/tr #eav-row eav children: + [#html/td sort:1 text: record] + [#html/td sort:2 text: attribute] + [#html/td sort:3 text: value] + [#html/td sort:4 text: entity?] + ] + ] + + ] +end + +search + [#json/encode] + encode = [#flatten] + [#json/encode/flatten record] +bind + encode <- [children: + [#html/div sort: 0 text: "Flatten"] + [#html/div sort: 1 text: "{{record}}"] + ] +end + +search + [#json/encode] +commit + [#ui/column #application | children: + [#ui/column #output | children: + [#ui/button #next sort: -1 text: "Next"] + ] + [#ui/row #debug | children: + [#ui/column #encode-eavs] + [#ui/column #flatten] + ] + ] +end + +commit + [#html/style text: " + td {padding: 10px;} + table {margin: 10px;} + .ui/column {padding: 10px;} + .ui/row {padding: 10px;} + .output {padding: 20px;} + .encode-eavs {min-width: 350px;} + "] +end \ No newline at end of file diff --git a/examples/setlist-aggregator.eve b/examples/setlist-aggregator.eve new file mode 100644 index 0000000..2679c53 --- /dev/null +++ b/examples/setlist-aggregator.eve @@ -0,0 +1,404 @@ + # Setlist Aggregator + + +This app takes a set of concerts, and accesses the setlist.fm api to retreive +the setlist that was played at that concert. This is cross-referenced with +the MS Groove API to create playlists for those concerts. + +## App Configuration + +commit + [#app/settings name: "Setlist Aggregator" + groove: [#groove client-id: "1efe0909-4134-4740-96e6-dfb02ac095ba" client-secret: "UskJMTfCRNffOLbuBnGSBb2" redirect-uri: "http://localhost:8081"] + setlist-fm: [#setlist-fm api-key: "f6c6164c-e52f-4aa5-bd22-1c76b208d275"]] + [#app/ui] + playing = [#app/page name: "Now Playing" icon: "ios-musical-notes" sort: 0] + showlist = [#app/page #default name: "Show List" icon: "ios-list-outline" sort: 1] + collection = [#app/page name: "Collection" icon: "ios-albums-outline" sort: 2] + stats = [#app/page name: "Show Stats" icon: "stats-bars" sort: 3] + map = [#app/page name: "Map" icon: "map" sort: 4] + settings = [#app/page name: "Settings" icon: "gear-a"] +end + +### Footer + +search + middle = [#app/layout/footer/middle] +bind + middle += #global-playback +end + +search + playback = [#global-playback] + [#now-playing track] + [#html/stream track current-time duration] +bind + playback <- [children: + [#ui/spacer sort: 1] + [#ui/column #now-playing-controls sort: 2 | children: + [#groove/stream-player/playback-control #global-play sort: 1 track] + [#ui/progress #song-progress min: 0 max: duration value: current-time width: 400]] + [#ui/spacer sort: 3]] +end + +Display control placeholders + +search + playback = [#global-playback] + not([#now-playing track]) +bind + playback <- [children: + [#ui/spacer sort: 1] + [#groove/stream-player/playback-control #ui/button #global-play icon: "play"] + [#ui/spacer sort: 3]] +end + +## Header + +search + top-right = [#html/div #app/layout/header/right] + not([#groove/user]) +bind + top-right.children += [#ui/button #groove-login text: "Log in to Groove"] +end + +Logging in is kicked off by clicking the login button. + +search + [#html/event/click element: [#groove-login]] +commit + [#groove/login] +end + +When we've gotten an access token, we get get the user profile. + +search + [#groove access-token] +commit + [#groove/get-user] +end + + +## Pages + +### Now Playing + +search + page.name = "Now Playing" + [#app/ui page] + [#now-playing track] + track = [#groove/track image name duration artist album] +bind + page <-[#html/div | children: + [#ui/column track #album-result children: + [#html/div #album-image children: + [#html/img track src: image style: [width: "250px" height: "250px"]]] + [#html/div #track-name sort: 1 track text: name] + [#html/div sort: 2 track text: artist.Name] + [#html/div sort: 3 track text: album.Name] + [#ui/spacer sort: 4] + [#html/div sort: 5 track text: duration]]] +end + +Clicking on a playback control sets a new `#now-playing` stream and pauses any +currently playing track. + +search + [#html/event/click element: playback-control] + playback-control = [#groove/stream-player/playback-control track] + playback = [#groove/stream-player/playback track] + now-playing-tracks = if playing = [#groove/stream-player/playback #now-playing] then playing + else "None" +commit + playback += #now-playing + now-playing-tracks -= #now-playing + now-playing-tracks.play := "false" +end + +### Collection + +search + page.name = "Collection" + [#app/ui page] + track = [#groove/track image name image duration artist album] +bind + page <- [#html/div #scrollable | children: + [#html/table #tracks | children: + [#html/tr track | children: + [#html/td sort: 0 children: + [#html/img src: image style: [width: "25px" height: "25px"]]] + [#html/td sort: 1 text: name] + [#html/td sort: 2 text: artist.Name] + [#html/td sort: 3 text: album.Name] + [#html/td sort: 4 text: duration]]]] +end + +### Show List + +search + page.name = "Show List" + [#app/ui page] +bind + page <- [#ui/row | style: [width: "100%"] children: + [#app/page/pane #show-list/shows width: 1] + [#app/page/pane #show-list/show-detail width: 1] + [#app/page/pane #show-list/search-pane width: 2] + ] +end + +#### List all the unmatched shows + +search + show-list = [#show-list/shows] + show = [#show artist date] + not(show = [#matched]) +bind + show-list <- [children: + [#html/div #ui/header text: "Unmatched Shows"] + [#show-list/unmatched-shows #ui/list #ui/selectable #ui/single-selectable | item: + [#html/div #show-list/unmatched-show sort: "{{date}}{{artist}}" show text: "{{date}} - {{artist}} "] + ]] +end + +search + [#ui/list item] +bind + item += #ui/list/item +end + +Clicking on an unmatched show searches setlist.fm + +search + show-detail = [#show-list/show-detail] + [#html/event/click element: [#show-list/unmatched-show show]] +commit + show-detail.show := show +end + +Get the setlist details from the Setlist.fm API + +search + show-detail = [#show-list/show-detail show] + not(show = [#matched]) +commit + [#setlist-fm/search/setlists artist: show.artist date: show.date] +end + +Display search results + +search + show-detail = [#show-list/show-detail show] + [#setlist-fm/search/setlists artist: show.artist date: show.date setlist] + not(show = [#matched]) +bind + show-detail <- [children: + [#ui/row #info-head | children: + [#ui/column #show-info sort: 1 | children: + [#html/div #artist sort: -1 text: setlist.artist.name] + [#html/div #tour sort: -2 text: setlist.tour] + [#html/div #date sort: -3 text: setlist.date] + [#html/div #venue sort: 0 text: setlist.venue.name] + ] + [#ui/spacer sort: 2] + [#ui/button #circle-button #large-button #match-show show setlist sort: 3 icon: "checkmark"] + [#ui/spacer sort: 4] + ] + [#html/div #setlists | children: + [#ui/list #ui/selectable #ui/single-selectable #setlist + set: setlist.sets sort: setlist.sets.number | item: + [#ui/row #song song: setlist.sets.songs sort: setlist.sets.songs.number | children: + [#ui/text #song-number text: setlist.sets.songs.number] + [#ui/text #song-name text: setlist.sets.songs.name] + [#ui/spacer]]]]] +end + +Display a loading screen while results are being fetched +TODO This isn't working right +search + show-detail = [#show-list/show-detail show] + find-setlist = [#setlist-fm/search/setlists artist: show.artist date: show.date] + not(find-setlist = [#finished]) +bind + show-detail <- [#html/div | children: + [#html/div text: "Loading..."]] +end + +When the `#match-show` button is click, mark the show as matched and attach a setlist. + + +search + [#html/event/click element: [#match-show show setlist]] +commit + show += #matched + show.setlist := setlist +end + +#### List all matched Shows + +search + show-list = [#show-list/shows] + show = [#show #matched artist date setlist] +bind + show-list += #ui/column + show-list.children += + [#ui/column | children: + [#html/div #ui/header text: "Matched Shows"] + [#show-list/matched-shows #ui/list #ui/selectable #ui/single-selectable | item: + [#html/div #show-list/matched-show sort: "{{date}}{{artist}}" show text: "{{date}} - {{artist}} "]]] +end + + + + + + +#### Get search results for a song + +Clicking on an unmatched song kicks off a groove search for that track + +search + [#html/event/click element: [#song song]] + not(song = [#matched]) + search-pane = [#show-list/search-pane] +commit + search-pane.song := song + search-pane.query := "{{song.artist}} {{song.name}}" +end + +Create a request for a query + +search + [#show-list/search-pane query] +commit + [#groove/search-track query] +end + + +search + + search-pane = [#show-list/search-pane song query] + track = [#groove/track query name image duration artist album] + song.artist = artist.Name +bind + search-pane <- [children: + [#ui/row #track-search children: + [#ui/spacer sort: 1] + [#ui/input sort: 2 icon: "play" value: query] + [#ui/spacer sort: 3]] + [#html/div #results-list | children: + [#ui/list | item: + [#ui/row track #album-result children: + [#html/div #album-image children: + [#html/img track src: image style: [width: "100px" height: "100px"]]] + [#ui/column #track-info track | children: + [#html/div #track-name sort: 1 track text: name] + [#html/div sort: 2 track text: artist.Name] + [#html/div sort: 3 track text: album.Name] + [#ui/spacer sort: 4] + [#html/div sort: 5 track text: duration] + ] + [#ui/spacer sort: 97] + [#ui/column track sort: 98 | children: + [#ui/spacer sort: 1] + [#groove/stream-player #display sort: 2 track] + [#ui/spacer sort: 3] + ] + [#ui/spacer sort: 99] + [#ui/button #link-track track icon: "link" sort: 100]]]]] +end + +search + [#html/event/click element: [#link-track track]] + search-pane = [#show-list/search-pane song] +commit + search-pane.query := none + song += #matched + song.track := track +end + +Clicking on a matched song will play it + +search + matched-song = [#song song: [#matched track]] +bind + matched-song.children += [#groove/stream-player #display track] +end + +## App Data + +commit + [#show artist: "Incubus" date: "16-08-2017"] + [#show artist: "Jimmy Eat World" date: "16-08-2017"] + [#show artist: "Third Eye Blind" date: "23-07-2017"] + [#show artist: "Silversun Pickups" date: "23-07-2017"] +end + + +### Settings + +search + page.name = "Settings" + [#app/interface page] +bind + page <- [#html/div text: "Settings"] +end + + + +## Styles + +Style all playback controls as circle buttons + +search + control = [#groove/stream-player/playback-control] +bind + control += #circle-button +end + + + +[#html/style text: " + body { background-color: rgb(24,24,24); height: 40px; color: rgb(200,200,200); } + div { user-select: none; cursor: default;} + div::-webkit-scrollbar { width: 5px; } + div::-webkit-scrollbar-track { -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0); } + div::-webkit-scrollbar-thumb { background-color: rgba(255,255,255,.5); outline: 1px solid slategrey; border-radius: 25px; } + .ui-button { background-color: rgb(40,40,40); color: rgb(200,200,200); padding: 10px; border: 1px solid rgb(60,60,60); margin-bottom: 10px; } + .ui-button:hover { background-color: rgb(50,50,50); color: rgb(200,200,200); border: 1px solid rgb(60,60,60);} + .header { background-color: rgb(18,18,18); padding: 10px; } + .global-playback { background-color: rgb(40,40,40); height: 100px; color: rgb(200,200,200); } + + .show-list-shows { width: 250px; } + .show-list-show-detail {padding-left: 20px; padding-right: 20px; background-color: rgb(22,22,22); width: 400px; margin-right: 10px;} + .artist { font-size: 26px; margin-bottom: 10px; } + .tour { margin-bottom: 5px; color: rgb(150,150,150); } + .venue { margin-bottom: 5px; color: rgb(150,150,150);} + .date { margin-bottom: 5px; color: rgb(150,150,150);} + .setlists { overflow: auto; max-height: 650px; margin-top: 15px; padding-right: 10px;} + .setlist { margin-top: 10px; margin-bottom: 20px; } + .song { padding: 5px; line-height: 30px; } + .song-number { width: 15px; margin-right: 10px; color: rgb(120,120,120); text-align: right } + .song:hover { background-color: rgb(40,40,40);} + .show-list-search-pane {min-width: 400px; max-width: 500px; overflow: auto;} + .album-image {width: 100px; margin-right: 20px;} + .album-result {padding: 20px;} + + .track-search {padding: 10px; padding-top: 20px; padding-bottom: 20px; background-color: rgb(18,18,18);} + .navigation { background-color: rgb(18,18,18); width: 150px; height: 100%; } + .navigation-button { padding: 10px 20px 10px 20px; background-color: rgb(30,30,30); user-select: none; cursor: default; margin: 0px; border: 0px; width: 100%; min-height: 75px;} + .navigation-button:hover { color: rgb(255,255,255); border: 0px; } + .circle-button {padding: 0px; padding-left: 10px; border-radius: 40px; width: 30px; height: 30px; background-color: rgba(0,0,0,0); border: 1px solid white; margin: 10px;} + .circle-button:hover {background-color: rgb(0,158,224,1); border-color: white; color: white;} + .track-name {padding-bottom: 10px;} + .results-list .ui-list {padding-right: 10px;} + .results-list {overflow: auto; max-height: 700px;} + .track-info {width: 150px;} + table { border-collapse: collapse; margin: 20px;} + td { padding: 10px; } + table, th, td { border: 1px solid rgb(80,80,80); } + .scrollable { overflow: auto; height: 800px; } + .global-playback {padding-top: 8px;} + .global-play {width: 50px; height: 50px; font-size: 25px; padding-left: 17px;} + + .large-button {width: 50px; height: 50px; font-size: 25px; padding-left: 15px;} + "] diff --git a/libraries/app/app.eve b/libraries/app/app.eve new file mode 100644 index 0000000..761b10f --- /dev/null +++ b/libraries/app/app.eve @@ -0,0 +1,174 @@ +# App + +This library provides a set of components to assist in quickly prototyping +single and multi page applications. + +## Components + +An app has three components: + +- `#app/settings` - Holds app-wide configuration data +- `#app/page` - A page of your application. Holds the name of the page, store + page data, and hold the UI of a page. +- `#app/ui` - Holds the layout of the application. UI stored on an `#app/page` + is injected into this layout for display. + +## Configuration + +The `#app/settings` record is a place to store app-wide configuration data. You +can attach any records you like here, and they will be available for viewing +and editing in the `#app/page/settings` record, which will be displayed by +default in your app. + +Attributes of `#app/settings`: + +- name - the name of your application. Will be displayed in the header +- settings - forms that allow you to view and edit configuration settings + of your app. + +## Pages + +`#app/page`s are the core of your application. These are the top-level sections +of your application, and will be accessible by a button in the navigation bar. +You can of course have sub pages as well. The navigation bar can be configured +to display these. + +Attributes of an `#app/page`: + +- name - Required: the name of the page +- icon - an optional icon for your page. This will be displayed on the nav bar. +- sort - an optoinal sorting for your page. Determines the order pages are + displayed in the navigation bar. + +## Layout + +An `#app/ui` has four basic components: + +- Header - Located at the top of the application, spanning the entire width +- Navigation - Contains controls that switch between pages in an application. + Can be located on any of the sides of an appliation. +- Content - Contains the pages of an application +- Footer - Located at the bottom of an application, spanning the entire width. + +search + app-ui = [#app/ui] +bind + app-ui.interface += [#app/layout #ui/column | children: + [#app/layout/header sort: 1] + [#app/layout/content sort: 3] + [#app/layout/footer sort: 5]] +end + +### Header + +The `#app/layout/header` spans the entire width of the top of the application. It +automatically displays the name of your application, and contains two +containers, `#app/layout/header/left` and `#app/layout/header/right` +for displaying controls at the top corners of your application. + +search + header = [#app/layout/header] + [#app/settings name] +bind + header <- [#ui/row | children: + [#html/div #app/layout/header/left sort: 1] + [#html/div #app/layout/header/middle sort: 2 text: name] + [#html/div #app/layout/header/right sort: 3]] +end + +### Content and Navigation + +search + content = [#app/layout/content] +bind + content <- [#ui/row | children: + [#app/layout/navigation sort: 1] + [#app/layout/page-container sort: 2]] +end + +#### Navigation + +search + nav = [#app/layout/navigation] + page = [#app/page name icon sort] +bind + nav <- [#ui/column | children: + [#ui/list #ui/selectable #ui/single-selectable #navigation/pages | item: + [#app/layout/navigation-button #ui/button icon page text: name sort]] + [#ui/spacer #nav-spacer sort: 99]] +end + +Clicking a navigation button switches the page. + +search + [#html/event/click element: [#app/layout/navigation-button page]] + ui = [#app/ui] +commit + ui.page := page +end + +If no tab is selected, display the default page + +search + default-page = [#app/page #default] + default-page-button = [#app/layout/navigation-button page: default-page] + ui = [#app/ui] + nav = [#navigation/pages] + not([#app/layout/navigation-button #ui/selected]) +commit + nav.selected := default-page-button + ui.page := default-page +end + +#### Page Container + +search + page-container = [#app/layout/page-container] + [#app/ui page] +bind + page-container <- [#html/div #app/layout/page children: page] +end + +#### Panes + +search + pane = [#app/page/pane] + width = if pane.width then pane.width else 1 +bind + pane += #ui/column + pane <- [style: [flex-grow: "{{width}}"]] +end + +### Footer + +The footer spans the entire width of the bottom of the application. It +contains three containers`#app/layout/footer/left`, `#app/layout/footer/middle` +and `#app/layout/footer/right`. + +search + header = [#app/layout/footer] +bind + header <- [#ui/row | children: + [#html/div #app/layout/footer/left sort: 1] + [#html/div #app/layout/footer/middle sort: 2] + [#html/div #app/layout/footer/right sort: 3]] +end + +## Styles + +commit + [#html/style text: " + .app-layout { background-color: rgb(255,255,255); width: 100vw; height: 100vh; color: rgb(80,80,80); font-family: sans-serif; user-select: none; cursor: default; display: flex; } + .app-layout-header { background-color: rgb(200,200,200); display: flex; justify-content: space-between; } + .app-layout-header-middle { padding: 10px; } + .app-layout-footer { background-color: rgb(200,200,200); display: flex; justify-content: space-between; } + .app-layout-footer-middle { padding: 10px; } + .app-layout-content {flex-grow: 1; display: flex;} + .app-page { display: flex; height: 100%; } + .app-page-pane {padding: 10px; overflow: auto;} + .app-layout-page-container { padding: 10px; flex-grow: 1; } + .app-layout-navigation { background-color: rgb(130,130,130); } + .app-layout-navigation-button { padding: 10px 20px 10px 20px; background-color: rgb(230,230,230); margin: 0px; border: 0px; border-radius: 0px; width: 100%; min-height: 75px; } + .app-layout-navigation-button.ui-selected { background-color: rgb(255,255,255); color: rgb(0,158,224); } + "] +end \ No newline at end of file diff --git a/libraries/groove/groove.css b/libraries/groove/groove.css new file mode 100644 index 0000000..aa19dfb --- /dev/null +++ b/libraries/groove/groove.css @@ -0,0 +1,7 @@ +.groove-stream-player-playback-control { + height: "100px"; + width: "100px"; + border-radius: "50%"; + border: "2px solid #f5f5f5"; + text-align: center; +} \ No newline at end of file diff --git a/libraries/groove/groove.eve b/libraries/groove/groove.eve new file mode 100644 index 0000000..8f56c81 --- /dev/null +++ b/libraries/groove/groove.eve @@ -0,0 +1,253 @@ +# Groove API + +The groove API is configured with a record tagged `#groove` with the following shape: + +`[#groove client-id client-secret redirect-uri]` + +## Specify Groove endpoint + +search + groove = [#groove] + e-point = if groove.endpoint then groove.endpoint else "https://music.xboxlive.com" +bind + groove.endpoint += e-point +end + +## Logging Into Groove + +Commit a `#groove/login` to initiate the login process. This redirects to a +login portal, which asks the user to authorize access to Eve. Once the user +grants access, the browser is redirect back to the supplied `redirect-uri`. + +search + [#groove/login] + [#groove client-id client-secret redirect-uri] + response-type = "token" + scopes = string/url-encode[text: "MicrosoftMediaServices.GrooveApiAccess offline_access"] + encoded-redirect-uri = string/url-encode[text: redirect-uri] + address = "https://login.live.com/oauth20_authorize.srf/?client_id={{client-id}}&response_type={{response-type}}&redirect_uri={{encoded-redirect-uri}}&scope={{scopes}}" +commit + [#html/redirect url: address] +end + +search + [#html/url hash] +bind + [#html/url/parse-query query: hash] +end + +A successful login will return to the app an access token + +search + [#html/url/query key: "access_token" value: access-token] + groove = [#groove] +commit + groove.access-token := access-token +end + + +## Getting Groove User Data + +With an access token in hand, we can use that to get user-specific information. + +search + [#groove access-token endpoint] +commit + [#http/request #groove/profile address: "{{endpoint}}/1/user/music/profile" method: "GET" headers: + [#http/header key: "Authorization" value: "Bearer {{access-token}}"]] +end + +search + [#groove/profile response] +commit + [#json/decode #groove/profile json: response.body] +end + +Create a Groove user from the response + +search + [#groove/profile json-object: profile] +commit + [#groove/user region: profile.Culture subscription: profile.HasSubscription] +end + + +## Search Groove + +search + [#groove/search-track query] + [#groove access-token endpoint] + encoded-query = string/replace[text: query replace: " " with: "+"] + address = "{{endpoint}}/1/content/music/search?q={{encoded-query}}&filters=tracks" +commit + [#http/request #groove/search query address headers: + [#http/header key: "Authorization" value: "Bearer {{access-token}}"]] +end + +search + [#groove/search query response: [body]] +commit + [#json/decode #groove/search query json: body] +end + +search + [#groove/search query json-object] + json-object = [Tracks: [Items: [value: [Id Name ReleaseDate Duration ImageUrl Album Artists: [value: [Artist]]]]]] +commit + [#groove/track query name: Name, id: Id, duration: Duration, image: ImageUrl album: Album | artist: Artist] +end + + +## Get a full song strem + +search + [#groove/full-stream track] + [#groove access-token endpoint] + address = "{{endpoint}}/1/content/{{track.id}}/stream?clientInstanceId=2E19AC92-8600-11E7-8200-4CC9641576C9" +commit + [#http/request #groove/get-song track address headers: + [#http/header key: "Authorization" value: "Bearer {{access-token}}"]] +end + +search + [#groove/get-song track response: [body]] +commit + [#json/decode #groove/get-song track json: body] +end + +search + [#groove/get-song track json-object] + groove-stream = [#groove/full-stream track] +commit + groove-stream <- [stream-url: json-object.Url content-type: json-object.ContentType] +end + + +## Streaming Player + +A stream player has a playback control + +search + stream = [#groove/stream-player track] +bind + stream.controls += [#groove/stream-player/playback-control track] +end + +Playback controls are also buttons, so they render + +search + playback-control = [#groove/stream-player/playback-control track] +bind + playback-control += #ui/button +end + + +A stream player also has a playback element + +search + groove = [#groove client-id] + player = [#groove/stream-player track] +commit + playback = [#html/stream #groove/stream-player/playback track style: [display: "none"]] + player.playback := playback + groove.streams += playback +end + +The icon of a control matches the state of its player + +search + control = [#groove/stream-player/playback-control track] + playback = [#groove/stream-player/playback track] + not(playback = [#pending]) + state = if playback = [#playing] then "pause" + else "play" +bind + control.icon += state +end + +The icon of a pending stream is a loading animation + +search + control = [#groove/stream-player/playback-control track] + playback = [#groove/stream-player/playback #pending track] +bind + control.icon += "load-a" +end + +Display controls in the DOM + +search + stream = [#groove/stream-player #display controls] +bind + stream += #html/div + controls += #display + stream.children += controls +end + +Hide controls that aren't tagged `#dispaly` + +search + controls = [#groove/stream-player/playback-control] + not(controls = [#display]) +bind + controls.style.display += "none" +end + +Clicking the play button for the first time gets a link to the stream and +starts playback when the stream is ready. + +search + [#html/event/click element: [#groove/stream-player/playback-control track]] + pending-playback = [#groove/stream-player/playback track] + track = [#groove/track id] + not(pending-playback = [#ready]) +commit + pending-playback += #pending + [#groove/full-stream track] +end + +Attach the stream info to the stream playback + +search + [#groove/full-stream track stream-url content-type] + playback = [#groove/stream-player/playback track] +commit + playback <- [source: stream-url content-type] +end + +search + playback = [#groove/stream-player/playback #ready #pending track] +commit + playback.play := "true" + playback -= #pending +end + +Clicking the playback button when the stream is ready will toggle the stream + +search + q = [#html/event/click element: [#groove/stream-player/playback-control track]] + playback = [#groove/stream-player/playback #ready track] + state = if playback = [#playing] then "false" + else "true" +commit + playback.play := state +end + +If we ever get into a state where a stream is both `#playing` and `#paused` we +can default to paused. + +search + stream = [#groove/stream-player #playing #paused] +commit + stream -= #playing +end + +A streaming player that has no controls is paused, because you'd have no way to pause it yourself. + +search + [#groove streams] + streams = [#groove/stream-player/playback #playing track] + not([#groove/stream-player/playback-control track]) +commit + streams.play := "false" +end \ No newline at end of file diff --git a/libraries/groove/setlist-fm.eve b/libraries/groove/setlist-fm.eve new file mode 100644 index 0000000..c4071ff --- /dev/null +++ b/libraries/groove/setlist-fm.eve @@ -0,0 +1,55 @@ +# Setlist.fm + + +## Specify endpoint + + +search + setlist = [#setlist-fm] + e-point = if setlist.endpoint then setlist.endpoint else "https://api.setlist.fm/rest" +bind + setlist.endpoint += e-point +end + +## Search Setlists + +search + find-setlist = [#setlist-fm/search/setlists artist date] + [#setlist-fm api-key endpoint] + encoded-artist = string/url-encode[text: artist] + address = "{{endpoint}}/1.0/search/setlists?artistName={{encoded-artist}}&date={{date}}&p=1" +commit + [#http/request #setlist-fm/find-setlist find-setlist address headers: + [#http/header key: "x-api-key" value: api-key] + [#http/header key: "Accept" value: "application/json"]] +end + +search + [#setlist-fm/find-setlist find-setlist response: [body]] +commit + [#json/decode #setlist-fm/find-setlist find-setlist json: body] +end + +search + result = [#setlist-fm/find-setlist find-setlist json-object] + not(result = [#finished]) + json-object = [setlist: [value: [artist id eventDate tour venue sets: [set]]]] + set = [index: set-number value: [song: [index: song-number value: [name: song-name]]]] +commit + result += #finished + find-setlist += #finished + songs = [#setlist-fm/song artist: artist.name number: song-number, name: song-name] + sets = [#setlist-fm/set id number: set-number | songs] + find-setlist.setlist += [#setlist-fm/setlist id artist venue date: eventDate, tour: tour.name | sets] +end + +Clean up records + +search + complete = [#setlist-fm/find-setlist #finished json-object] + find-setlist = [#setlist-fm/find-setlist] +commit + find-setlist := none + complete := none +end + diff --git a/libraries/html/html.eve b/libraries/html/html.eve index 033f365..13ffce7 100644 --- a/libraries/html/html.eve +++ b/libraries/html/html.eve @@ -17,6 +17,12 @@ commit "li" "ul" "ol" + "audio" + "source" + "video" + "table" + "tr" + "td" )] end ~~~ @@ -118,6 +124,7 @@ search type != "checkbox" type != "submit" type != "radio" + type != "range" then input if input = [#html/element tagname: "input"] not(input.type) @@ -373,3 +380,11 @@ watch client/websocket ("html/export triggers" element trigger) end ~~~ + +Redirect to a url. +~~~ eve +search + [#html/redirect url] +watch client/websocket + ("html/redirect" url) +end \ No newline at end of file diff --git a/libraries/html/html.ts b/libraries/html/html.ts index e54dc1d..a70b07a 100644 --- a/libraries/html/html.ts +++ b/libraries/html/html.ts @@ -1,6 +1,7 @@ import md5 from "md5"; import "setimmediate"; import {Program, Library, createId, RawValue, RawEAV, RawMap, handleTuples} from "../../ts"; +import url from "url"; const EMPTY:never[] = []; @@ -99,23 +100,27 @@ export class HTML extends Library { this._dummy = document.createElement("div"); window.addEventListener("resize", this._resizeEventHandler("resize-window")); + + // Mouse events window.addEventListener("click", this._mouseEventHandler("click")); window.addEventListener("dblclick", this._mouseEventHandler("double-click")); window.addEventListener("mousedown", this._mouseEventHandler("mouse-down")); window.addEventListener("mouseup", this._mouseEventHandler("mouse-up")); window.addEventListener("contextmenu", this._captureContextMenuHandler()); + document.body.addEventListener("mouseenter", this._hoverEventHandler("hover-in"), true); + document.body.addEventListener("mouseleave", this._hoverEventHandler("hover-out"), true); + // Form events window.addEventListener("change", this._changeEventHandler("change")); window.addEventListener("input", this._inputEventHandler("change")); - window.addEventListener("keydown", this._keyEventHandler("key-down")); - window.addEventListener("keyup", this._keyEventHandler("key-up")); window.addEventListener("focus", this._focusEventHandler("focus"), true); window.addEventListener("blur", this._focusEventHandler("blur"), true); - document.body.addEventListener("mouseenter", this._hoverEventHandler("hover-in"), true); - document.body.addEventListener("mouseleave", this._hoverEventHandler("hover-out"), true); + // Keyboard events + window.addEventListener("keydown", this._keyEventHandler("key-down")); + window.addEventListener("keyup", this._keyEventHandler("key-up")); - // window.addEventListener("hashchange", this._hashChangeHandler("url-change")); + this.getURL(window.location); } protected decorate(elem:Element, elemId:RawValue): Instance { @@ -397,6 +402,11 @@ export class HTML extends Library { if(!instance.__capturedKeys) instance.__capturedKeys = {[code]: true}; else instance.__capturedKeys[code] = true; } + }), + "redirect": handleTuples(({adds, removes}) => { + for(let [url] of adds || EMPTY) { + window.location.replace(`${url}`); + } }) }; @@ -631,6 +641,28 @@ export class HTML extends Library { if(eavs.length) this._sendEvent(eavs); }; } + + getURL(location: Location) { + let {hash, host, hostname, href, pathname, port, protocol, search} = location; + let eavs:RawEAV[] = []; + let urlId = createId(); + eavs.push( + [urlId, "tag", "html/url"], + [urlId, "host", `${host}`], + [urlId, "hostname", `${hostname}`], + [urlId, "href", `${href}`], + [urlId, "pathname", `${pathname}`], + [urlId, "port", `${port}`], + [urlId, "protocol", `${protocol}`], + ); + if(hash !== "") { + eavs.push([urlId, "hash", `${hash.substring(1)}`]); + } + if(search !== "") { + eavs.push([urlId, "query", `${search.substring(1)}`]); + } + this._sendEvent(eavs); + } } Library.register(HTML.id, HTML); diff --git a/libraries/html/stream.eve b/libraries/html/stream.eve new file mode 100644 index 0000000..f282a08 --- /dev/null +++ b/libraries/html/stream.eve @@ -0,0 +1,56 @@ +# Stream Element + +search + stream = [#html/stream source] +watch client/websocket + ("stream/create", stream, source) +end + +search + stream = [#html/stream] +bind + stream <- [#html/video id: stream] +end + +search + stream = [#html/stream play] +watch client/websocket + ("stream/play", stream, play) +end + +search + [#html/event/stream-ready stream] + stream = [#html/stream] +commit + stream += #ready +end + +search + [#html/event/stream-play stream] + stream = [#html/stream] +commit + stream += #playing + stream -= #paused +end + +search + [#html/event/stream-pause stream] + stream = [#html/stream] +commit + stream += #paused + stream -= #playing +end + +search + time-change = [#html/event/time-change stream time] +commit + time-change := none + stream.current-time := time +end + +search + duration-change = [#html/event/duration-change stream duration] +commit + duration-change := none + stream.duration := duration +end \ No newline at end of file diff --git a/libraries/html/stream.ts b/libraries/html/stream.ts new file mode 100644 index 0000000..e6fd60b --- /dev/null +++ b/libraries/html/stream.ts @@ -0,0 +1,82 @@ +import {Library, createId, RawValue, RawEAV, handleTuples, libraries} from "../../ts"; +import Hls from "hls.js" + +const EMPTY:never[] = []; + +export class Stream extends Library { + static id = "stream"; + streams: any = {}; + + html:libraries.HTML; + + setup() { + this.html = this.program.attach("html") as libraries.HTML; + } + + handlers = { + "create": handleTuples(({adds}) => { + for(let [streamID, source] of adds || EMPTY) { + if(Hls.isSupported()) { + let video: any = document.getElementById(`${streamID}`); + var hls = new Hls(); + let program = this.program + hls.loadSource(`${source}`); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED,function() { + + }); + video.onplay = function () { + let id = createId(); + program.inputEAVs([ + [id, "tag", "html/event/stream-play"], + [id, "stream", streamID], + ]); + }; + video.onpause = function () { + let id = createId(); + program.inputEAVs([ + [id, "tag", "html/event/stream-pause"], + [id, "stream", streamID], + ]); + }; + video.onloadeddata = function () { + let id = createId(); + program.inputEAVs([ + [id, "tag", "html/event/stream-ready"], + [id, "stream", streamID], + ]); + } + video.ontimeupdate = function () { + let id = createId(); + program.inputEAVs([ + [id, "tag", "html/event/time-change"], + [id, "stream", streamID], + [id, "time", video.currentTime] + ]); + } + video.ondurationchange = function() { + let id = createId(); + program.inputEAVs([ + [id, "tag", "html/event/duration-change"], + [id, "stream", streamID], + [id, "duration", video.duration] + ]); + } + this.streams[streamID] = video; + } + } + }), + "play": handleTuples(({adds}) => { + for(let [streamID, play] of adds || EMPTY) { + let video = this.streams[streamID]; + if (play === "true") { + video.play(); + } else { + video.pause(); + } + } + }) + } +} + +Library.register(Stream.id, Stream); \ No newline at end of file diff --git a/libraries/http/http.eve b/libraries/http/http.eve new file mode 100644 index 0000000..0de3ee1 --- /dev/null +++ b/libraries/http/http.eve @@ -0,0 +1,174 @@ +# HTTP + +## Send an HTTP Request + +HTTP requests accept several attributes: + +- address - the destination for the request +- method - the method of the request is one of GET, PUT, POST, DELETE, HEAD, TRACE, CONNECT, PATCH +- body - the body of the HTTP request +- headers - request headers take the form of `[#http/header key value]` + +search + request = [#http/request address method body headers: [#http/header key value]] +watch http + ("request", request, address, method, body, key, value) +end + +Default method + +search + request = [#http/request] + method = if m = request.method then m else "GET" +bind + request.method += method +end + +Default empty body + +search + request = [#http/request] + not(request.body) +commit + request.body := "" +end + +Default empty header + +search + request = [#http/request] + not(request.headers) +commit + request.headers := [#http/header key: "" value: ""] +end + +Associate response with its request + +search + response-received = [#http/response/received response] + response = [#http/response] + request = [#http/request] +commit + response-received := none + request.response := response +end + +Associate an error with its request + +search + error = [#http/request/error request] + request = [#http/request] +commit + request.error := error +end + +Tag a finished request as such + +search + [#http/request/finished request] +commit + request += #finished +end + +Reconstruct a body from chunks, only after the request is`#finished` + +search + [#http/body-chunk response chunk index] + response = [#http/response request] + request = [#http/request #finished] +watch http + ("body", response, chunk, index) +end + +When the full body is reconstructed, attach it to the response + +search + q = [#http/full-body body response] +commit + response.body := body +end + +Clean up body chunks once the body is reconstructed + +search + chunk = [#http/body-chunk response] + response = [#http/response body] +commit + chunk := none +end + +## Receive HTTP Requests + +search + server = [#http/server address] +watch http + ("server", server, address) +end + + +## Parse Query Strings + +search + url = [#html/url query] +bind + [#html/url/parse-query url query] +end + +search + parse = [#html/url/parse-query query] + pair = if (qq, i) = string/split[text: query by: "&"] then qq else query + (token, index) = string/split[text: pair by: "="] +bind + [#html/url/query-kvs parse pair token index] +end + +search + [#html/url/query-kvs parse pair, token: key, index: 1] + [#html/url/query-kvs parse pair, token: value, index: 2] +bind + parse.result += [#html/url/query key value] +end + +search + [#html/url/parse-query url result] +bind + url.parsed-query += result +end + + +## Diagnostics + +search + [#http/request/error error] +commit + [#html/div text: error] +end + +search + [#disable] + [#http/request/finished request] +commit + [#html/div request text: "***Finished*** {{request}}"] +end + +search + [#disable] + q = [#http/response body] +commit + [#html/div text: "THIS IS THE BODY: {{body}}"] + //[#json/decode json: body] +end + +search + [#disable] + [#json/decode json-object] +commit + [#html/div text: "{{json-object.Tracks.Items.value.Name}} {{json-object.Tracks.Items.value.Album.Name}} - {{json-object.Tracks.Items.value.Id}}"] +end + +search + request = [#http/request] + not(request = [#finished]) +bind + [#html/div text: "Processing request..."] +end \ No newline at end of file diff --git a/libraries/index.ts b/libraries/index.ts index 0de5c24..07d0695 100644 --- a/libraries/index.ts +++ b/libraries/index.ts @@ -1,5 +1,6 @@ export {HTML} from "./html/html"; export {Canvas} from "./canvas/canvas"; export {Console} from "./console/console"; +export {Stream} from "./html/stream"; export {EveCodeMirror} from "./codemirror/codemirror"; export {EveGraph} from "./graph/graph"; diff --git a/libraries/json/json.eve b/libraries/json/json.eve new file mode 100644 index 0000000..bb1ed2c --- /dev/null +++ b/libraries/json/json.eve @@ -0,0 +1,161 @@ +# JSON + +A library for encoding and decoding Eve records into and from JSON + +## Encoding + +Encoding a record is kicked off with `#json/encode`. It creates two records of consequence: + +- `#json/encode/record`: handles encoding records into json. This one is tagged `#json/encode/target-record`, which means it is flagged for output. +- `#json/encode/flatten`: flattens a record into a/v pairs + +search + [#json/encode record] +bind + [#json/encode/record #json/encode/target-record record] + [#json/encode/flatten record] +end + +`#json/encode/record` are given a starting point for JSON + +search + target = [#json/encode/record record] + not(target.json-string) +commit + target.json-string := "{ " +end + +We flatten records with lookup. + +search + [#json/encode/flatten record] + lookup[entity: record attribute value] +bind + encode-eav = [#json/encode/eav record attribute value] +end + +sub-records are marked `#json/encode/entity` + +search + encode = [#json/encode/eav record attribute value] + lookup[entity: value] +bind + encode += #json/encode/entity +end + +### Encode A/V Pairs + +We can join all non entities in a json encoded string + +search + eav = [#json/encode/eav record attribute value] + not(eav = [#json/encode/entity]) +bind + [#json/encode/entity/av-pair record av: "\"{{attribute}}\": \"{{value}}\""] +end + +search + [#json/encode/entity/av-pair record av] +bind + [#string/join #json/encode/join-avs record with: ", " | strings: av] +end + +search + [#json/encode/join-avs record result] +bind + [#json/encode/complete-av record json-string: result] +end + +### Encode Sub records + +`#json/encode/entity` records can be encoded just like the target record. + +search + [#json/encode/eav record attribute value #json/encode/entity] +bind + [#json/encode/record record: value] + [#json/encode/flatten record: value] +end + +Join eavs into a json object + +search + encode = [#json/encode/eav #json/encode/entity attribute] + finished = [#json/encode/finished] + encode.value = finished.record +bind + [#string/join #json/encode/join-object parent: encode.record attribute record: "{{encode.record}}|{{attribute}}" with: ", " | strings: finished.json-string ] +end + +Put all json object strings into an array form. attaching its attribute + +search + [#json/encode/join-object result parent attribute] +bind + [#json/encode/complete-av record: parent json-string: "\"{{attribute}}\": [ {{result}} ]"] +end + +### Bring it all together + +Join all encoded avs into a complete string + +search + complete-av = [#json/encode/complete-av record] + target-record = [#json/encode/record record] +bind + [#string/join #json/encode/join-complete record with: ", " | strings: complete-av.json-string] +end + +Ensconce finished records with curly braces + +search + [#json/encode/join-complete record result] +bind + [#json/encode/finished record json-string: "{ {{result}} }"] +end + +When the full target record is encoded, hang it on the orginal `#json-encode` record + +search + [#json/encode/finished record json-string] + encode = [#json/encode record] + [#json/encode/target-record record] +bind + encode.json-string += json-string +end + +### Joining Strings in Eve + +search + join = [#string/join strings with] +watch json + ("join", join, strings, with) +end + +Put the joined string in the original `#string/join` record, and get rid of the result + +search + join-result = [#string/join/result result] + join = [#string/join strings with] + join-result.record = join +commit + join-result := none + join.result := result +end + +## Decoding + +search + decode = [#json/decode json] +watch json + ("decode", decode, json) +end + +A decoded string comes through on a change + +search + decode-change = [#json/decode/change decode json-object] +commit + decode.json-object := json-object + decode-change := none +end diff --git a/libraries/ui/ui.eve b/libraries/ui/ui.eve index 61e4e5b..8c76194 100644 --- a/libraries/ui/ui.eve +++ b/libraries/ui/ui.eve @@ -2,17 +2,17 @@ ## Deprecation Warnings -~~~ eve + search [#html/shortcut-tag shortcut tagname] not([#ui/shortcut-tag tagname]) bind [#ui/deprecated-shortcut-tag shortcut: "ui/{{tagname}}" new-shortcut: shortcut tagname] end -~~~ + Report deprecated shortcuts as warnings. -~~~ eve + search [#ui/deprecated-shortcut-tag shortcut: tag new-shortcut tagname] element = [tag] @@ -20,12 +20,12 @@ bind [#eve/warning #ui/warning #eve/deprecated message: "The shortcut tag '#{{tag}}' for creating basic HTML elements has been deprecated. Instead, use '#{{new-shortcut}}'."] element <- [#html/element tagname] end -~~~ + ## Shortcut Tags -~~~ eve + commit [#ui/shortcut-tag shortcut: "ui/row" tagname: "row"] [#ui/shortcut-tag shortcut: "ui/column" tagname: "column"] @@ -34,51 +34,51 @@ commit [#ui/shortcut-tag shortcut: "ui/button" tagname: "button"] [#ui/shortcut-tag shortcut: "ui/input" tagname: "input"] end -~~~ + Decorate shortcut elements as html. -~~~ eve + search [#ui/shortcut-tag shortcut: tag tagname] element = [tag] bind element <- [#html/element tagname] end -~~~ + ## General Setup Clear ui events. -~~~ eve + search event = [#ui/event] commit event := none end -~~~ + Translate bubble event names to event tags. -~~~ eve + search bubble = [#ui/bubble-event event: name] bind bubble.event-tag += "ui/event/{{name}}" end -~~~ + Bubble ui events. -~~~ eve + search event = [#ui/event tag: event-tag element: from] [#ui/bubble-event event-tag from to] bind event.element += to end -~~~ + Translate state/start/stop event names to event tags. -~~~ eve + search transition = [#ui/state-tag state start-event stop-event] bind @@ -86,46 +86,46 @@ bind transition.start-tag += "ui/event/{{start-event}}" transition.stop-tag += "ui/event/{{stop-event}}" end -~~~ + Apply state when start-event occurs. -~~~ eve + search [#ui/event tag: start-tag element: for] [#ui/state-tag for state-tag start-tag] commit for.tag += state-tag end -~~~ + Remove state when stop-event occurs. -~~~ eve + search [#ui/event tag: stop-tag element: for] [#ui/state-tag for state-tag stop-tag] commit for.tag -= state-tag end -~~~ + ## Buttons Give button elements icons if specified. -~~~ eve + search element = [#ui/button icon] bind element.class += "iconic" element.class += "ion-{{icon}}" end -~~~ + ## Toggle A toggle is a checkbox and a label decorated as a toggle switch. -~~~ eve + search element = [#ui/toggle] bind @@ -133,20 +133,20 @@ bind [#html/element tagname: "label" for: "ui-toggle-{{element}}"] [#html/input #ui/toggle/input type: "checkbox" id: "ui-toggle-{{element}}"]] end -~~~ + Copy checked from input to toggle. -~~~ eve + search element = [#ui/toggle children: [#ui/toggle/input checked]] bind element.checked += checked end -~~~ + Copy initial from toggle to input. -~~~ eve + search element = [#ui/toggle initial children: input] input = [#ui/toggle/input] @@ -154,33 +154,33 @@ search bind input.initial += initial end -~~~ + ## List Decorate list as html. -~~~ eve + search list = [#ui/list] bind list <- [#html/element tagname: "div"] end -~~~ + Drop items into list (will eventually be flywheel'd). -~~~ eve + search list = [#ui/list item] bind list.children += item end -~~~ + ## Selectable The default cursor for a selectable list is the first item. -~~~ eve + search selectable = [#ui/selectable item] not(selectable.cursor) @@ -188,30 +188,30 @@ search commit selectable.cursor := item end -~~~ + If the cursor is no longer an item, clear it. -~~~ eve + search selectable = [#ui/selectable cursor] not(selectable = [#ui/selectable item: cursor]) commit selectable.cursor := none end -~~~ + Selectable items are sorted by autosort if they don't specify a sort. -~~~ eve + search selectable = [#ui/selectable item] sort = if s = item.sort then s else item.eve-auto-index bind item.sort += sort end -~~~ + Build a linked list of the items in the selectable for navigation. -~~~ eve + search selectable = [#ui/selectable item] (next-sort next) = gather/next[for: (item.sort item) per: selectable] @@ -219,71 +219,71 @@ bind item.next-selectable-item += next next.prev-selectable-item += item end -~~~ + Mark the currently selected element. -~~~ eve + search [#ui/selectable selected] bind selected += #ui/selected end -~~~ + Mark the cursor element. -~~~ eve + search [#ui/selectable #ui/active cursor] bind cursor += #ui/current end -~~~ + A focused selectable is active. -~~~ eve + search selectable = [#ui/selectable #html/focused] bind selectable += #ui/active end -~~~ + ### Handlers If a selectable is focused by the client, activate it. -~~~ eve + search selectable = [#ui/selectable] [#html/event/focus element: selectable] bind [#ui/event #ui/event/activate element: selectable] end -~~~ + If a selectable is blurred by the client, deactivate it. -~~~ eve + search selectable = [#ui/selectable] [#html/event/blur element: selectable] bind [#ui/event #ui/event/deactivate element: selectable] end -~~~ + Clicking an item in a selectable selects the item. -~~~ eve + search selectable = [#ui/selectable item] [#html/event/mouse-down element: item] bind [#ui/event #ui/event/select element: selectable item] end -~~~ + Clicking outside an active selectable deactivates it. @FIXME: This just won't work :( It clashes when used as a subcomponent. -~~~ eve + // search // selectable = [#ui/selectable #ui/active] // [#html/event/click] @@ -292,10 +292,10 @@ Clicking outside an active selectable deactivates it. // bind // [#ui/event #ui/event/deactivate element: selectable] // end -~~~ + Escape or tab in a active selectable deactivates it. -~~~ eve + search selectable = [#ui/selectable #ui/active] event = if e = [#html/event/key-down key: "escape"] then e @@ -304,124 +304,124 @@ search bind [#ui/event #ui/event/deactivate element: selectable] end -~~~ + Enter in a active selectable selects its cursor. -~~~ eve + search selectable = [#ui/selectable #ui/active cursor:item] [#html/event/key-down key: "enter"] bind [#ui/event #ui/event/select element: selectable item] end -~~~ + Down in a active selectable advances the cursor. -~~~ eve + search selectable = [#ui/selectable #ui/active cursor] event = [#html/event/key-down key: "down"] commit selectable.cursor := cursor.next-selectable-item end -~~~ + Up in a active selectable retreats the cursor. -~~~ eve + search selectable = [#ui/selectable #ui/active cursor] [#html/event/key-down key: "up"] commit selectable.cursor := cursor.prev-selectable-item end -~~~ + ### Events Describe selectable states. -~~~ eve + search selectable = [#ui/selectable] bind [#ui/state-tag for: selectable state: "active" start-event: "activate" stop-event: "deactivate"] end -~~~ + Selecting an element updates the selected and cursor. -~~~ eve + search event = [#ui/event/select element: selectable item] commit selectable.selected += item selectable.cursor := item end -~~~ + In a single-selectable selectable, selection overwrites the previous selected. -~~~ eve + search event = [#ui/event/select element: selectable item] selectable = [#ui/single-selectable] commit selectable.selected := item end -~~~ + Clearing a selectable clears its selected. -~~~ eve + search event = [#ui/event/clear element: selectable] commit selectable.selected := none end -~~~ + ### Dropdown Decorate dropdown as html. -~~~ eve + search dropdown = [#ui/dropdown] bind dropdown <- [#html/element tagname: "div"] end -~~~ + A dropdown's first child is its input. -~~~ eve + search dropdown = [#ui/dropdown input] bind dropdown.children += input input.sort += 1 end -~~~ + A dropdown creates a selectable list of its items. -~~~ eve + search dropdown = [#ui/dropdown item] bind dropdown.children += [#ui/dropdown/list #ui/list #ui/selectable #ui/single-selectable dropdown container: dropdown sort: 999999 | item] end -~~~ + A dropdown's selected is its list's -~~~ eve + search [#ui/dropdown/list dropdown selected] bind dropdown.selected += selected end -~~~ + ### Handlers Clicking a dropdown opens it. -~~~ eve + search dropdown = [#ui/dropdown] not(dropdown = [#ui/active]) @@ -429,10 +429,10 @@ search bind [#ui/event #ui/event/activate element: dropdown] end -~~~ + Clicking anywhere outside an open dropdown closes it. -~~~ eve + search dropdown = [#ui/dropdown #ui/active] [#html/event/click] @@ -440,13 +440,13 @@ search bind [#ui/event #ui/event/deactivate element: dropdown] end -~~~ + ### Events Describe dropdown event bubbling and states. -~~~ eve + search dropdown = [#ui/dropdown input] list = [#ui/dropdown/list dropdown] @@ -455,83 +455,83 @@ bind [#ui/bubble-event from: list to: dropdown event: ("activate" "deactivate" "clear" "select")] [#ui/state-tag for: dropdown state: "active" start-event: "activate" stop-event: "deactivate"] end -~~~ + Opening a button-based dropdown blurs the button. -~~~ eve + search [#ui/event/activate element: dropdown] dropdown.input.tagname = "button" commit dropdown.input += #html/trigger/blur end -~~~ + ## Completer Decorate completer. -~~~ eve + search completer = [#ui/completer] bind completer <- [#ui/dropdown input: [#ui/input #ui/completer/input completer]] end -~~~ + ### Setup Copy input placeholder. -~~~ eve + search input = [#ui/completer/input completer] bind input.placeholder += completer.placeholder end -~~~ + Copy input initial. -~~~ eve + search input = [#ui/completer/input completer] bind input.initial += completer.initial end -~~~ + A completer's value is its input's. -~~~ eve + search completer = [#ui/completer] value = if [#ui/completer/input completer value: v] then v else "" bind completer.value += value end -~~~ + ### Handlers Focusing the completer opens the dropdown. -~~~ eve + search input = [#ui/completer/input completer] [#html/event/focus element: input] bind [#ui/event #ui/event/activate element: completer] end -~~~ + Blurring the completer closes the dropdown. -~~~ eve + search input = [#ui/completer/input completer] [#html/event/blur element: input] bind [#ui/event #ui/event/deactivate element: completer] end -~~~ + Changing the completer moves the cursor to the top of the list. -~~~ eve + search completer = [#ui/completer #ui/active item] list = [#ui/dropdown/list dropdown: completer] @@ -541,57 +541,57 @@ search commit list.cursor := item end -~~~ + ### Events Closing a completer blurs it. -~~~ eve + search [#ui/event/deactivate element: completer] completer = [#ui/completer input] commit input += #html/trigger/blur end -~~~ + Opening a completer focuses it. -~~~ eve + search [#ui/event/activate element: completer] completer = [#ui/completer input] commit input += #html/trigger/focus end -~~~ + ## Autocomplete Decorate autocomplete -~~~ eve + search completer = [#ui/autocomplete] bind completer <- [#ui/completer] end -~~~ + ### Logic If an autocomplete's value disagrees with its selected, clear the selected. -~~~ eve + search completer = [#ui/autocomplete value: term selected] selected.text != term commit [#ui/event #ui/event/clear element: completer] end -~~~ + Completions that match the current input value are matches, sorted by length. -~~~ eve + search completer = [#ui/autocomplete value: term completion] ix = string/index-of[text: completion.text substring: string/lowercase[text: term]] @@ -600,12 +600,12 @@ bind completer.item += completion completion.sort += "{{sort}}{{completion.text}}" end -~~~ + ### Handlers If the value matches perfectly on blur, select that match. -~~~ eve + search input = [#ui/completer/input completer] completer = [#ui/autocomplete] @@ -616,33 +616,33 @@ search bind [#ui/event #ui/event/select element: completer item: match] end -~~~ + Autocompletes update their values on select. -~~~ eve + search autocomplete = [#ui/autocomplete input] [#ui/event/select item element: autocomplete] commit input.value := item.text end -~~~ + ### Events Clear the specified autocomplete. -~~~ eve + search event = [#ui/event/clear element: autocomplete] input = [#ui/autocomplete/input autocomplete] commit input.value := none end -~~~ + When an autocomplete is opened, store its previous value. -~~~ eve + search event = [#ui/event/activate element: autocomplete] input = [#ui/autocomplete/input autocomplete] @@ -650,74 +650,74 @@ search commit autocomplete.previous := value end -~~~ + When an autocomplete is closed, erase its previous value. -~~~ eve + search event = [#ui/event/deactivate element: autocomplete] input = [#ui/autocomplete/input autocomplete] commit autocomplete.previous := none end -~~~ + When an autocomplete is closed and its value is changed, emit a change event. -~~~ eve + search event = [#ui/event/deactivate element: autocomplete] autocomplete.value != autocomplete.previous commit [#ui/event #ui/event/change element: autocomplete value: autocomplete.value] end -~~~ + When a selection is made that differs from the previous value, emit a change event. -~~~ eve + search event = [#ui/event/select element: autocomplete item] item.text != autocomplete.previous commit [#ui/event #ui/event/change element: autocomplete value: item.text] end -~~~ + ## Token Completer Token completers are completers. -~~~ eve + search completer = [#ui/token-completer] bind completer <- [#ui/completer #html/listener/key | captured-key: ("space" "up" "down")] end -~~~ + Token items are divs. -~~~ eve + search completer = [#ui/token-completer item] bind item += #html/div end -~~~ + ### Logic The current term is the last word of the value. -~~~ eve + search completer = [#ui/token-completer value] (token, 1) = eve-internal/string/split-reverse[text: value by: " "] bind completer.needle += token end -~~~ + Completions that match the current input value are matches, sorted by length. -~~~ eve + search completer = [#ui/token-completer needle: term completion] ix = string/index-of[text: string/lowercase[text: completion.text] substring: string/lowercase[text: term]] @@ -726,32 +726,32 @@ bind completer.item += completion completion.sort += "{{sort}}{{completion.text}}" end -~~~ + Space-separated words are tokens of the completer. -~~~ eve + search completer = [#ui/token-completer value] (token, ix) = string/split[text: value by: " "] bind completer.token += [#ui/token-completer/token token ix] end -~~~ + Track the index of the last token. -~~~ eve + search completer = [#ui/token-completer token: [ix]] gather/top[for: ix per: completer limit: 1] bind completer.last-ix += ix end -~~~ + ### Handlers Token completers append the new completed token in place of the in progress token on select. -~~~ eve + search event = [#ui/event/select element: completer item] input = [#ui/completer/input completer] @@ -762,10 +762,10 @@ search commit input.value := "{{value}}{{item.text}} " end -~~~ + Token completers without an in-progress token just append the new one. -~~~ eve + search event = [#ui/event/select element: completer item] input = [#ui/completer/input completer] @@ -773,10 +773,10 @@ search commit input.value := "{{completer.value}}{{item.text}} " end -~~~ + Space in a active selectable selects its cursor. -~~~ eve + search completer = [#ui/token-completer #ui/active] list = [#ui/dropdown/list dropdown: completer cursor:item] @@ -784,9 +784,240 @@ search bind [#ui/event #ui/event/select element: list item] end -~~~ +## Progress + +search + progress = [#ui/progress min max value width] + val = if value > max then max else value + percentage = val / (max - min) + percentage-display = math/to-fixed[value: percentage * 100 to: 0] + progress-width = width * val / (max - min) + display = if progress = [#label] then "inline" else "none" + color = if progress.color then progress.color else "rgb(111, 165, 81)" +bind + progress <- [#ui/row | children: + [#html/div #ui/progress/label progress | sort: 2 text: "{{percentage-display}}%" style: [display]] + [#html/div #ui/progress/container progress | sort: 1 style: [width: "{{width}}px"] children: + [#html/div #ui/progress/bar progress | style: [width: "{{progress-width}}px" background-color: color]]]] +end + +Progress bars at 100% are marked `#ui/progress/complete` + +search + progress = [#ui/progress max value] + max = value +bind + progress += #ui/progress/complete +end + +## Slider + +search + slider = [#ui/slider min max width] + initial-value = if slider.initial-value then slider.initial-value else min +bind + slider <- [#html/div children: [#ui/input min max slider type: "range" initial-value style: [width: "{{width}}px"]]] +end + +If a slider as an `initial-value` but no calculated `value`, set it + +search + slider = [#ui/slider initial-value] + input = [#ui/input slider] + not(slider.value) +commit + slider.value := initial-value + input.value := initial-value +end + +An initial value out of range of the slider is set to its closest extreme and a warning is issued + +search + slider = [#ui/slider min max] + value = if slider.value > max then max + else if slider.value < min then min +commit + slider.value := value + [#console/warn text: "Value of slider ({{slider.initial-value}}) is outside of the bounds of the slider"] +end + +When the user changes the slider value in the UI, update its `value` in Eve + +search + [#html/event/change element: input value] + input = [#ui/input slider] + numeric-value = eve/parse-value[value] +commit + slider.value := numeric-value +end + +Sliders without a width are given a default + +search + slider = [#ui/slider] + not(slider = [width]) +commit + slider.width := 400 +end + +Draw a progress bar under the slider + +search + slider = [#ui/slider min max value width] +bind + slider.children += [#ui/progress #slider-progress slider min max width sort: 0 | color: "rgb(0,121,177)" value] +end + +search + [#ui/slider value] +bind + [#html/div text: value] +end + + +## Tabbed Box + +Draw the `#ui/tabbed-box` + +search + tab-container = [#ui/tabbed-box tabs] +bind + tab-container <- [#html/div | children: + [#ui/tabbed-box/tab-row #ui/row | children: + [#html/div #ui/tabbed-box/tab-display tab: tabs sort: tabs.title text: tabs.title]] + [#ui/tabbed-box/content-display tabs container: tab-container]] +end + +Give a default tag + +search + content = [#ui/tabbed-box/content-display tabs] + gather/bottom[for: tabs limit: 1] +commit + tabs += #selected +end + +Apply the selected tag to the `tag-display` for special styling + +search + tab = [#ui/tabbed-box/tab #selected title] + display = [#ui/tabbed-box/tab-display tab] +bind + display += #selected +end + +Inject tab content into the content are of thet `#ui/tabbed-box` + +search + content-display = [#ui/tabbed-box/content-display tabs] + tabs = [#ui/tabbed-box/tab #selected] + [#ui/tabbed-box/content tab: tabs content] +bind + content-display <- [#html/div #ui/tabbed-box/has-content] + content-display.children += content +end + +Clicking on a tab changes the selected tab + +search + [#html/event/click element: [#ui/tabbed-box/tab-display tab: clicked-tab]] + [#ui/tabbed-box/tab-display tab: selected-tab] + selected-tab = [#selected] + selected-tab != clicked-tab +commit + selected-tab -= #selected + clicked-tab += #selected +end + +## Spinner + +search + spinner = [#ui/spinner size] + radius = "{{size}}px" + diameter = "{{size * 2}}px" + border = "{{size / 4}}px solid rgb(0,158,224)" + +bind + spinner <- [#html/div #ui/spinner/circle #ui/spinner/rotate | style: [ + height: radius + width: diameter + border-top-left-radius: diameter + border-top-right-radius: diameter + border-left: border + border-right: border + border-top: border]] +end + +Give spinners a default size if none is specified + +search + spinner = [#ui/spinner] + not(spinner = [size]) +commit + spinner.size += 10 +end + +Remove the default size if one is set adter the fact + +search + spinner = [#ui/spinner size != 25] +commit + spinner.size -= 25 +end + +## Styles + +commit + [#html/style text: " + .ui-tabbed-box {border: 1px solid rgb(230,230,230);} + .ui-tabbed-box-tab-display { background-color: rgb(230,230,230); padding: 20px;} + .ui-tabbed-box-tab-display.selected { border-bottom: 3px solid rgb(0,158,224);} + .ui-tabbed-box-tab-display:hover { background-color: rgb(240,240,240); } + .ui-tabbed-box-content-display {padding: 20px;} + .ui-progress {padding: 10px;} + .ui-progress-label {font-size: 14px; margin-top: -2px; margin-left: 4px;} + .ui-progress-container { background-color: rgb(200,200,200); height: 10px; border-radius: 5px; } + .ui-progress-bar { height: 100%; border-radius: 5px; } + input[type=range]{ -webkit-appearance: none; background-color: rgba(0,0,0,0);} + .ui-slider {margin: 10px; margin-left: 8px; } + .slider-progress {padding: 0px; padding-left: 2px; margin-bottom: -13px; } + input[type=range]::-webkit-slider-runnable-track { + height: 10px; + background: rgba(0,0,0,0); + border: none; + border-radius: 5px; + } + + input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + height: 20px; + width: 20px; + border-radius: 50%; + background: rgb(0,158,224); + margin-top: -5px; + } + input[type=range]:focus { outline: none; } + input[type=range]:focus::-webkit-slider-runnable-track { background: rgba(0,0,0,0); } + + .ui-spinner-circle { background-color: rgba(0,0,0,0); margin: 10px; } + .ui-spinner-rotate { animation: 1s linear infinite ui-spinner-rotate; position: relative; transform-origin: 50% 100%; } + + @keyframes ui-spinner-rotate { + from { + transform: rotate(0deg) + } + to { + transform: rotate(360deg) + } + } + .ui-header {padding: 10px; margin-bottom: 10px; font-size: 20px;} + .ui-list-item { padding-top: 10px; padding-bottom: 10px; border-bottom: 1px solid rgb(200,200,200); } + .ui-list-item:hover { background-color: rgb(250,250,250); } + .ui-input {padding: 5px 10px 5px 10px; border-radius: 20px; border: 1px solid rgb(200,200,200); outline: none;} + "] +end Todo: @@ -795,13 +1026,14 @@ Todo: - [x] Dropdown - [x] *bug* commit removal not working for enter / click (blurs list -> closes dropdown) - [x] *bug* gather/bottom filtering randomly (always includes correct answer + optionally any others in set). -- [ ] Tab Box +- [x] Tab Box - [ ] Rewrite AC on Dropdown - input - list (navigable) - [ ] container - card -- [ ] progress +- [x] progress +- [x] spinner - [ ] Tree - [ ] chip - [ ] date picker diff --git a/package.json b/package.json index b71b071..e62d119 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "license": "UNLICENSED", "dependencies": { "@types/vis": "^4.18.4", + "hls.js": "^0.7.11", "codemirror": "^5.28.0", "codemirror-mode-eve": "0.0.1", "md5": "^2.2.1", @@ -29,6 +30,7 @@ "@types/codemirror": "0.0.41", "@types/md5": "^2.1.32", "@types/uuid": "^3.4.0", + "@types/hls.js": "^0.7.5", "autoprefixer": "^7.1.2", "postcss-color-function": "^4.0.0", "postcss-custom-properties": "^6.1.0", diff --git a/src/bin/main.rs b/src/bin/main.rs index bf1e214..e8d0f48 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -11,6 +11,8 @@ use eve::ops::{DebugMode, ProgramRunner, Persister}; use eve::watchers::system::{SystemTimerWatcher, PanicWatcher}; use eve::watchers::console::{ConsoleWatcher, PrintDiffWatcher}; use eve::watchers::file::FileWatcher; +use eve::watchers::json::JsonWatcher; +use eve::watchers::http::HttpWatcher; //------------------------------------------------------------------------- // Main @@ -68,6 +70,8 @@ fn main() { runner.program.attach(Box::new(FileWatcher::new(outgoing.clone()))); runner.program.attach(Box::new(ConsoleWatcher::new())); runner.program.attach(Box::new(PrintDiffWatcher::new())); + runner.program.attach(Box::new(JsonWatcher::new(outgoing.clone()))); + runner.program.attach(Box::new(HttpWatcher::new(outgoing.clone()))); runner.program.attach(Box::new(PanicWatcher::new())); } diff --git a/src/bin/server.rs b/src/bin/server.rs index 6eefe50..a52b296 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -28,12 +28,14 @@ use eve::paths::EvePaths; use eve::ops::{ProgramRunner, RunLoop, RunLoopMessage, RawChange, Internable, Persister, JSONInternable}; use eve::watchers::system::{SystemTimerWatcher, PanicWatcher}; use eve::watchers::compiler::{CompilerWatcher}; +use eve::watchers::http::{HttpWatcher}; use eve::watchers::textcompiler::{RawTextCompilerWatcher}; use eve::watchers::console::{ConsoleWatcher}; use eve::watchers::file::{FileWatcher}; use eve::watchers::editor::EditorWatcher; use eve::watchers::remote::{Router, RouterMessage, RemoteWatcher}; use eve::watchers::websocket::WebsocketClientWatcher; +use eve::watchers::json::{JsonWatcher}; extern crate iron; extern crate staticfile; @@ -78,12 +80,15 @@ impl ClientHandler { router.lock().expect("ERROR: Failed to lock router: Cannot register new client.").register(&client_name, outgoing.clone()); if !eve_flags.clean { runner.program.attach(Box::new(SystemTimerWatcher::new(outgoing.clone()))); + runner.program.attach(Box::new(JsonWatcher::new(outgoing.clone()))); + runner.program.attach(Box::new(HttpWatcher::new(outgoing.clone()))); runner.program.attach(Box::new(CompilerWatcher::new(outgoing.clone(), false))); runner.program.attach(Box::new(RawTextCompilerWatcher::new(outgoing.clone()))); runner.program.attach(Box::new(FileWatcher::new(outgoing.clone()))); runner.program.attach(Box::new(WebsocketClientWatcher::new(out.clone(), client_name))); runner.program.attach(Box::new(ConsoleWatcher::new())); runner.program.attach(Box::new(PanicWatcher::new())); + runner.program.attach(Box::new(JsonWatcher::new(outgoing.clone()))); runner.program.attach(Box::new(RemoteWatcher::new(client_name, &router.lock().expect("ERROR: Failed to lock router: Cannot init RemoteWatcher.").deref()))); if eve_flags.editor { let editor_watcher = EditorWatcher::new(&mut runner, router.clone(), out.clone(), eve_paths.libraries(), eve_paths.programs()); diff --git a/src/compiler.rs b/src/compiler.rs index 8ffc961..785ecb9 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -122,6 +122,8 @@ lazy_static! { m.insert("string/replace".to_string(), FunctionInfo::new(vec!["text", "replace", "with"])); m.insert("string/contains".to_string(), FunctionInfo::new(vec!["text", "substring"])); m.insert("string/lowercase".to_string(), FunctionInfo::new(vec!["text"])); + m.insert("string/encode".to_string(), FunctionInfo::new(vec!["text"])); + m.insert("string/url-encode".to_string(), FunctionInfo::new(vec!["text"])); m.insert("string/uppercase".to_string(), FunctionInfo::new(vec!["text"])); m.insert("string/length".to_string(), FunctionInfo::new(vec!["text"])); m.insert("string/substring".to_string(), FunctionInfo::new(vec!["text", "from", "to"])); diff --git a/src/ops.rs b/src/ops.rs index c92859e..c89be96 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -6,6 +6,8 @@ extern crate time; extern crate serde_json; extern crate bincode; extern crate term_painter; +extern crate data_encoding; +extern crate urlencoding; extern crate natord; use unicode_segmentation::UnicodeSegmentation; @@ -35,6 +37,7 @@ use std::f32::consts::{PI}; use std::mem; use std::usize; use rand::{Rng, SeedableRng, XorShiftRng}; +use self::data_encoding::base64; use self::term_painter::ToStyle; use self::term_painter::Color::*; use parser; @@ -703,6 +706,10 @@ impl Internable { Internable::Number(value) } + pub fn from_str(s: &str) -> Internable { + Internable::String(s.to_string()) + } + pub fn print(&self) -> String { match self { &Internable::String(ref s) => { @@ -1310,6 +1317,8 @@ pub fn make_function(op: &str, params: Vec, output: Field) -> Constraint "string/length" => string_length, "eve/type-of" => eve_type_of, "eve/parse-value" => eve_parse_value, + "string/encode" => string_encode, + "string/url-encode" => string_urlencode, "concat" => concat, "gen_id" => gen_id, _ => panic!("Unknown function: {:?}", op) @@ -1600,6 +1609,23 @@ pub fn string_length(params: Vec<&Internable>) -> Option { } } +pub fn string_encode(params: Vec<&Internable>) -> Option { + match params.as_slice() { + &[&Internable::String(ref text)] => Some(Internable::String(base64::encode(text.as_bytes()))), + &[&Internable::Number(ref number)] => Some(Internable::String(number.to_string())), + _ => None + } +} + +pub fn string_urlencode(params: Vec<&Internable>) -> Option { + match params.as_slice() { + &[&Internable::String(ref text)] => Some(Internable::String(urlencoding::encode(text))), + &[&Internable::Number(ref number)] => Some(Internable::String(number.to_string())), + _ => None + } +} + + pub fn string_substring(params: Vec<&Internable>) -> Option { let params_slice = params.as_slice(); match params_slice { diff --git a/src/watchers/http.rs b/src/watchers/http.rs new file mode 100644 index 0000000..5d66ab3 --- /dev/null +++ b/src/watchers/http.rs @@ -0,0 +1,205 @@ +use super::super::indexes::{WatchDiff}; +use super::super::ops::{Internable, Interner, RawChange, RunLoopMessage}; +use std::sync::mpsc::{Sender}; +use watchers::json::{new_change}; +use super::Watcher; + +extern crate futures; +extern crate hyper; +extern crate hyper_tls; +extern crate tokio_core; +extern crate serde_json; +extern crate serde; +use self::futures::{Future, Stream}; +use self::hyper::Client; +use self::hyper_tls::HttpsConnector; +use self::tokio_core::reactor::Core; +use self::hyper::{Method}; +use std::thread; +use std::io::{Write}; +extern crate iron; +use self::iron::prelude::*; +use self::iron::{status, url}; +use std::collections::HashMap; + +pub struct HttpWatcher { + name: String, + responses: HashMap>, + outgoing: Sender, +} + +impl HttpWatcher { + pub fn new(outgoing: Sender) -> HttpWatcher { + HttpWatcher { name: "http".to_string(), responses: HashMap::new(), outgoing } + } +} + +impl Watcher for HttpWatcher { + fn get_name(& self) -> String { + self.name.clone() + } + fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } + fn on_diff(&mut self, interner:&mut Interner, diff:WatchDiff) { + let mut requests: HashMap = HashMap::new(); + for add in diff.adds { + let kind = Internable::to_string(interner.get_value(add[0])); + let id = Internable::to_string(interner.get_value(add[1])); + let address = Internable::to_string(interner.get_value(add[2])); + match &kind[..] { + "request" => { + let body = Internable::to_string(interner.get_value(add[4])); + let key = Internable::to_string(interner.get_value(add[5])); + let value = Internable::to_string(interner.get_value(add[6])); + if !requests.contains_key(&id) { + let url = address.parse::().unwrap(); + let method = Internable::to_string(interner.get_value(add[3])); + let rmethod: Method = match &method.to_lowercase()[..] { + "get" => Method::Get, + "put" => Method::Put, + "post" => Method::Post, + "delete" => Method::Delete, + "head" => Method::Head, + "trace" => Method::Trace, + "connect" => Method::Connect, + "patch" => Method::Patch, + _ => Method::Get + }; + let req = hyper::Request::new(rmethod, url); + requests.insert(id.clone(), req); + } + let req = requests.get_mut(&id).unwrap(); + if key != "" { + req.headers_mut().set_raw(key, vec![value.into_bytes().to_vec()]); + } + if body != "" { + req.set_body(body); + } + }, + "server" => { + http_server(address, &self.outgoing); + }, + "body" => { + let response_id = Internable::to_string(interner.get_value(add[1])); + let chunk = Internable::to_string(interner.get_value(add[2])); + let index = Internable::to_number(interner.get_value(add[3])) as u32; + if self.responses.contains_key(&response_id) { + match self.responses.get_mut(&response_id) { + Some(v) => v.push((index,chunk)), + _ => (), + } + } else { + self.responses.insert(response_id, vec![(index.clone(), chunk.clone())]); + } + } + _ => {}, + } + } + // Send the HTTP request + for (id, request) in requests.drain() { + send_http_request(&id, request, &self.outgoing); + }; + // Reconstruct the body from chunks + for (response_id, mut chunk_vec) in self.responses.drain() { + chunk_vec.sort(); + let body: String = chunk_vec.iter().fold("".to_string(), |acc, ref x| { + let &&(ref ix, ref chunk) = x; + acc + chunk + }); + let full_body_id = format!("http/full-body|{:?}",response_id); + self.outgoing.send(RunLoopMessage::Transaction(vec![ + new_change(&full_body_id, "tag", Internable::from_str("http/full-body"), "http/request"), + new_change(&full_body_id, "body", Internable::String(body), "http/request"), + new_change(&full_body_id, "response", Internable::String(response_id), "http/request"), + ])).unwrap(); + }; + } +} + +fn http_server(address: String, outgoing: &Sender) -> thread::JoinHandle<()> { + thread::spawn(move || { + Iron::new(|req: &mut Request| { + println!("STARTED SERVER"); + let node = "http/server"; + let hostname: String = match req.url.host() { + url::Host::Domain(s) => s.to_string(), + url::Host::Ipv4(s) => s.to_string(), + url::Host::Ipv6(s) => s.to_string(), + }; + let request_id = format!("http/request|{:?}",req.url); + let url_id = format!("http/url|{:?}",request_id); + let mut request_changes: Vec = vec![]; + request_changes.push(new_change(&request_id, "tag", Internable::from_str("http/request"), node)); + request_changes.push(new_change(&request_id, "url", Internable::String(url_id.clone()), node)); + request_changes.push(new_change(&url_id, "tag", Internable::from_str("http/url"), node)); + request_changes.push(new_change(&url_id, "hostname", Internable::String(hostname), node)); + request_changes.push(new_change(&url_id, "port", Internable::String(req.url.port().to_string()), node)); + request_changes.push(new_change(&url_id, "protocol", Internable::from_str(req.url.scheme()), node)); + match req.url.fragment() { + Some(s) => request_changes.push(new_change(&url_id, "hash", Internable::from_str(s), node)), + _ => (), + }; + match req.url.query() { + Some(s) => request_changes.push(new_change(&url_id, "query", Internable::from_str(s), node)), + _ => (), + }; + //outgoing.send(RunLoopMessage::Transaction(request_changes)); + Ok(Response::with((status::Ok, ""))) + }).http(address).unwrap(); + }) +} + +fn send_http_request(id: &String, request: hyper::Request, outgoing: &Sender) { + let node = "http/request"; + let mut core = Core::new().unwrap(); + let handle = core.handle(); + let client = Client::configure() + .connector(HttpsConnector::new(4,&handle).unwrap()) + .build(&handle); + let mut ix: f32 = 1.0; + let work = client.request(request).and_then(|res| { + let mut response_changes: Vec = vec![]; + let status = res.status().as_u16(); + let response_id = format!("http/response|{:?}",id); + let response_change_id = format!("http/response/received|{:?}",id); + response_changes.push(new_change(&response_change_id, "tag", Internable::from_str("http/response/received"), node)); + response_changes.push(new_change(&response_change_id, "response", Internable::String(response_id.clone()), node)); + response_changes.push(new_change(&response_id, "tag", Internable::from_str("http/response"), node)); + response_changes.push(new_change(&response_id, "status", Internable::String(status.to_string()), node)); + response_changes.push(new_change(&response_id, "request", Internable::String(id.clone()), node)); + outgoing.send(RunLoopMessage::Transaction(response_changes)).unwrap(); + res.body().for_each(|chunk| { + let response_id = format!("http/response|{:?}",id); + let chunk_id = format!("body-chunk|{:?}|{:?}",&response_id,ix); + let mut vector: Vec = Vec::new(); + vector.write_all(&chunk).unwrap(); + let body_string = String::from_utf8(vector).unwrap(); + outgoing.send(RunLoopMessage::Transaction(vec![ + new_change(&chunk_id, "tag", Internable::from_str("http/body-chunk"), node), + new_change(&chunk_id, "response", Internable::String(response_id), node), + new_change(&chunk_id, "chunk", Internable::String(body_string), node), + new_change(&chunk_id, "index", Internable::from_number(ix), node) + ])).unwrap(); + ix = ix + 1.0; + Ok(()) + }) + }); + match core.run(work) { + Ok(_) => (), + Err(e) => { + // Form an HTTP Error + let error_id = format!("http/request/error|{:?}",&id); + let mut error_changes: Vec = vec![]; + error_changes.push(new_change(&error_id, "tag", Internable::from_str("http/request/error"), node)); + error_changes.push(new_change(&error_id, "request", Internable::String(id.clone()), node)); + error_changes.push(new_change(&error_id, "error", Internable::String(format!("{:?}",e)), node)); + outgoing.send(RunLoopMessage::Transaction(error_changes)).unwrap(); + }, + } + let finished_id = format!("http/request/finished|{:?}",id); + outgoing.send(RunLoopMessage::Transaction(vec![ + new_change(&finished_id, "tag", Internable::from_str("http/request/finished"), node), + new_change(&finished_id, "request", Internable::from_str(id), node), + ])).unwrap(); +} \ No newline at end of file diff --git a/src/watchers/json.rs b/src/watchers/json.rs new file mode 100644 index 0000000..c10a24e --- /dev/null +++ b/src/watchers/json.rs @@ -0,0 +1,150 @@ +use super::super::indexes::{WatchDiff}; +use super::super::ops::{Internable, Interner, RawChange, RunLoopMessage}; +use std::sync::mpsc::{Sender}; +use super::Watcher; + +extern crate serde_json; +extern crate serde; +use self::serde_json::{Value}; +use std::collections::HashMap; + +pub struct JsonWatcher { + name: String, + outgoing: Sender, + join_strings_map: HashMap, +} + +impl JsonWatcher { + pub fn new(outgoing: Sender) -> JsonWatcher { + JsonWatcher { name: "json".to_string(), join_strings_map: HashMap::new(), outgoing } + } +} + +#[derive(Debug, Clone)] +pub struct JoinStrings { + strings: Vec, + with: String +} + +impl JoinStrings { + pub fn new(with: String) -> JoinStrings { + JoinStrings { with, strings: vec![] } + } + pub fn join(&self) -> String { + self.strings.join(self.with.as_ref()) + } +} + +impl Watcher for JsonWatcher { + fn get_name(& self) -> String { + self.name.clone() + } + fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } + fn on_diff(&mut self, interner:&mut Interner, diff:WatchDiff) { + let mut changes: Vec = vec![]; + for remove in diff.removes { + let kind = Internable::to_string(interner.get_value(remove[0])); + match kind.as_ref() { + "join" => { + let id = Internable::to_string(interner.get_value(remove[1])); + let string = Internable::to_string(interner.get_value(remove[2])); + let join_strings = self.join_strings_map.get_mut(&id).unwrap(); + let index = join_strings.strings.iter().position(|x| *x == string).unwrap(); + join_strings.strings.remove(index); + }, + _ => {}, + } + } + for add in diff.adds { + let kind = Internable::to_string(interner.get_value(add[0])); + let record_id = Internable::to_string(interner.get_value(add[1])); + match kind.as_ref() { + "decode" => { + let value = Internable::to_string(interner.get_value(add[2])); + let v: Value = serde_json::from_str(&value).unwrap(); let change_id = format!("json/decode/change|{:?}",record_id); + value_to_changes(change_id.as_ref(), "json-object", v, "json/decode", &mut changes); + changes.push(new_change(&change_id, "tag", Internable::from_str("json/decode/change"), "json/decode")); + changes.push(new_change(&change_id, "decode", Internable::String(record_id), "json/decode")); + }, + "join" => { + let id = Internable::to_string(interner.get_value(add[1])); + let string = Internable::to_string(interner.get_value(add[2])); + let with = Internable::to_string(interner.get_value(add[3])); + if self.join_strings_map.contains_key(&id) { + let join_strings = self.join_strings_map.get_mut(&id).unwrap(); + join_strings.strings.push(string); + } else { + let mut join_strings = JoinStrings::new(with); + join_strings.strings.push(string); + self.join_strings_map.insert(id, join_strings); + } + }, + _ => {}, + } + } + + for (record_id, join_strings) in self.join_strings_map.iter() { + let join_id = format!("string/join|{:?}",record_id); + changes.push(new_change(&join_id, "tag", Internable::from_str("string/join/result"), "string/join")); + changes.push(new_change(&join_id, "result", Internable::String(join_strings.join()), "string/join")); + changes.push(new_change(&join_id, "record", Internable::String(record_id.to_owned()), "string/join")); + } + match self.outgoing.send(RunLoopMessage::Transaction(changes)) { + Err(_) => (), + _ => (), + } + } +} + +pub fn new_change(e: &str, a: &str, v: Internable, n: &str) -> RawChange { + RawChange {e: Internable::from_str(e), a: Internable::from_str(a), v: v.clone(), n: Internable::from_str(n), count: 1} +} + +pub fn value_to_changes(id: &str, attribute: &str, value: Value, node: &str, changes: &mut Vec) { + match value { + Value::Number(n) => { + if n.is_u64() { + let v = Internable::from_number(n.as_u64().unwrap() as f32); + changes.push(new_change(id,attribute,v,node)); + } else if n.is_i64() { + let v = Internable::from_number(n.as_i64().unwrap() as f32); + changes.push(new_change(id,attribute,v,node)); + } else if n.is_f64() { + let v = Internable::from_number(n.as_f64().unwrap() as f32); + changes.push(new_change(id,attribute,v,node)); + }; + }, + Value::String(ref n) => { + changes.push(new_change(id,attribute,Internable::String(n.clone()),node)); + }, + Value::Bool(ref n) => { + let b = match n { + &true => "true", + &false => "false", + }; + changes.push(new_change(id,attribute,Internable::from_str(b),node)); + }, + Value::Array(ref n) => { + for (ix, value) in n.iter().enumerate() { + let ix = ix + 1; + let array_id = format!("array|{:?}|{:?}|{:?}", id, ix, value); + let array_id = &array_id[..]; + changes.push(new_change(id,attribute,Internable::from_str(array_id),node)); + changes.push(new_change(array_id,"tag",Internable::from_str("array"),node)); + changes.push(new_change(array_id,"index",Internable::String(ix.to_string()),node)); + value_to_changes(array_id, "value", value.clone(), node, changes); + } + }, + Value::Object(ref n) => { + let object_id = format!("{:?}",n); + changes.push(new_change(id,attribute,Internable::String(object_id.clone()),node)); + changes.push(new_change(id,"tag",Internable::from_str("json-object"),node)); + for key in n.keys() { + value_to_changes(&mut object_id.clone(), key, n[key].clone(), node, changes); + } + }, + _ => {}, + } +} \ No newline at end of file diff --git a/src/watchers/mod.rs b/src/watchers/mod.rs index 96e764d..2a15189 100644 --- a/src/watchers/mod.rs +++ b/src/watchers/mod.rs @@ -11,7 +11,9 @@ pub mod file; pub mod console; pub mod system; pub mod compiler; +pub mod http; +pub mod json; pub mod textcompiler; pub mod editor; pub mod remote; -pub mod websocket; +pub mod websocket; \ No newline at end of file diff --git a/ts/main.ts b/ts/main.ts index e6fd8d7..d481686 100644 --- a/ts/main.ts +++ b/ts/main.ts @@ -80,6 +80,7 @@ class MultiplexedConnection extends Connection { this.addPane(client, html.getContainer()); program.attach("canvas"); program.attach("console"); + program.attach("stream"); program.attach("code-block"); program.attach("graph"); },