1. Euphoria JSON parser
- Posted by ghaberek (admin) Mar 13, 2015
- 3250 views
Good news, everyone!
I have implemented an experimental JSON parser powered by JSMN (pronounced like 'jasmine'). I created a new branch for this in SCM named jsmn. I think I did a pretty good job of "Euphoriaizing" the functionality. I've done a lot of testing so far with JSON data from many sources and I have verified the JSON output with several online validators.
Basically, JSMN parses a JSON string into a series of tokens that simply describe the contents of string. The tokens contain the following values:
- J_TYPE (a JSON type constant, see below)
- J_START (start of the JSON value in the string)
- J_END (end of the JSON value in the string)
- J_SIZE (number of sub-tokens following this token)
- J_PARENT (index of this token's parent token)
We then take those tokens and assemble Euphoria objects such as maps, sequences, strings, and atoms.
These values are always stored in a sequence with their respective type:
- JSON_OBJECT (a key/value map)
- JSON_ARRAY (an sequence of values)
- JSON_STRING (a basic string)
- JSON_PRIMITIVE (a number or true/false/null)
Documentation is complete but may need to be touched up. I have not tested building on all platforms.
Example 1
-- -- Parse a raw JSON string into a sequence of tokens. -- include std/console.e include std/json.e sequence js = `{"key": "hello", "value": "world"}` object tokens = json:parse( js ) if atom( tokens ) then -- token is an error number end if for i = 1 to length( tokens ) do sequence t = tokens[i] integer j_type = t[J_TYPE] integer j_start = t[J_START] integer j_end = t[J_END] integer j_size = t[J_SIZE] integer j_parent = t[J_PARENT] sequence j_value = js[j_start..j_end] display( "token [1]", {i} ) display( " j_type = [1]", {j_type} ) display( " j_start = [1]", {j_start} ) display( " j_end = [1]", {j_end} ) display( " j_size = [1]", {j_size} ) display( " j_parent = [1]", {j_parent} ) display( " j_value = [1]", {j_value} ) end for
Output
token 1 j_type = 1 j_start = 1 j_end = 34 j_size = 2 j_parent = 0 j_value = {"key": "hello", "value": "world"} token 2 j_type = 3 j_start = 3 j_end = 5 j_size = 1 j_parent = 1 j_value = key ...
Example 2
-- -- Parse a sequence of JSON tokens into a Euphoria object. -- include std/console.e include std/json.e include std/map.e object result = json:value( `{"key": "hello", "value": "world"}` ) if atom( result ) then -- result is an error number end if map m = result[J_VALUE] object key = map:get( m, "key" ) object value = map:get( m, "value" ) display( `key = "[1]", value = "[2]"`, {key[J_VALUE],value[J_VALUE]} )
Output
key = "hello", value = "world"
Example 3
-- -- Create a simple JSON object. -- include std/json.e -- a JSON_OBJECT is a map of key/value pairs object test = json:new(JSON_OBJECT, { -- a JSON_OBJECT is a map of key/value pairs -- values get converted to JSON_STRING automatically {"key", "hello"}, {"value", "world"} }) json:write(1, test)
Output
{ "key": "hello", "value": "world" }
Example 4
-- -- Create a complex JSON object. -- include std/json.e object test = json:new(JSON_OBJECT, { -- a JSON_OBJECT is a map of key/value pairs {"array", json:new(JSON_ARRAY, { -- JSON_ARRAY is a sequence of values, which can be nested JSON_OBJECTs json:new( JSON_OBJECT, { {"name","one"}, {"value",1} } ), json:new( JSON_OBJECT, { {"name","two"}, {"value",2} } ), json:new( JSON_OBJECT, { {"name","three"}, {"value",3} } ), json:new( JSON_OBJECT, { {"name","four"}, {"value",4} } ), json:new( JSON_OBJECT, { {"name","five"}, {"value",5} } ) })} }) json:write(1, test)
Output
{ "array": [ { "value": 1, "name": "one" }, { "value": 2, "name": "two" }, { "value": 3, "name": "three" }, { "value": 4, "name": "four" }, { "value": 5, "name": "five" } ] }
-Greg
3. Re: Euphoria JSON parser
- Posted by ghaberek (admin) Mar 14, 2015
- 3305 views
What does it do?
Kat
It's a JSON parser. It parses JSON. It also allows you to create JSON objects in memory and output them as JSON text.
With this, you can make use of REST APIs that send and receive JSON data by combining this with http_get() and http_post() calls. Or you could just store some data in a portable human-readable format. Or support importing and exporting data that is compatible with other applications and services.
There have been several unsuccessfuly attempts at adding JSON to Euphoria. I've also run into my own roadblocks trying to get a good JSON parser written in Euphoria. Sometimes it's better to avoid reinventing the wheel and just seek out something that Just Works.
- XML to JSON in Euphoria (April 2007)
- JSON to euphoria (April 2010)
- json (August 2012)
There is also cjson in The Archive, which was added by "ras" in May 2012. That library probably works well but it has a few short-comings:
- It requires an external library. If the libraries provided are not compatible with the user's system, they will have to be recompiled accordingly (not a trivial task). I have compiled JSMN into the backend of Euphoria.
- It has a lot of functions to manipulate JSON objects in memory. I have implemented JSMN such that all of the JSON object manipulation is as "close to the bone" as possible in native Euphoria.
- It has not been updated since 2012 and most likely will not be. With JSMN built into the backend, we will pull updates from upstream when updating Euphoria.
I original started along the same vein as cjson: I compiled JSMN into a shared library and wrapped its two functions, then wrote all of the additional code to format Euphoria objects from the tokens it provides. However, given how small the library is, it was only a few hours work to get the code compiled into the backend so that there are no external dependancies.
This, I believe, is key to adding a lot of good features to Euphoria: zero external dependancies.
-Greg
4. Re: Euphoria JSON parser
- Posted by ghaberek (admin) Oct 16, 2015
- 2799 views
So it's been like six months since I did this. Any thoughts on merging this into the trunk for 4.1 release? Is this a sufficiently "Euphoria" approach to handling JSON? I would like to start work on building Mini-XML into Euphoria as well. I think it is sufficiently small enouch (like JSMN) to be embedded into the interpreter directly and exposed through machine_func(). Mini-XML has about 70 functions whereas JSMN had only two, but when compiled into a shared library, Mini-XML is only about 70 KB. I guess I'm just looking for input on moving forward with this effort.
-Greg
5. Re: Euphoria JSON parser
- Posted by jimcbrown (admin) Oct 16, 2015
- 2832 views
So it's been like six months since I did this. Any thoughts on merging this into the trunk for 4.1 release? Is this a sufficiently "Euphoria" approach to handling JSON? I would like to start work on building Mini-XML into Euphoria as well. I think it is sufficiently small enouch (like JSMN) to be embedded into the interpreter directly and exposed through machine_func(). Mini-XML has about 70 functions whereas JSMN had only two, but when compiled into a shared library, Mini-XML is only about 70 KB. I guess I'm just looking for input on moving forward with this effort.
-Greg
I'm in favor.
6. Re: Euphoria JSON parser
- Posted by SDPringle Oct 16, 2015
- 2762 views
You know, it is funny, but I have been able to use c_func/c_proc interface without external dlls.
include std/unittest.e include std/dll.e include std/machine.e constant no_dll = -1 constant malloc_function = define_c_func(no_dll, "malloc", {C_INT}, C_POINTER) constant Emalloc = define_c_func(no_dll, "Emalloc", {C_UINT}, C_POINTER) test_true("Malloc function returns non-zero", malloc_function) test_true("EMalloc function returns non-zero", Emalloc) test_report()
You can link the C code with the rest into the "library" section, and then you use a Euphoria wrapper that you first can debug as an external dll while the team resists.
In the case of Regex, we didn't do this. Because of the way as Open Euphoria has integrated PCRE. Extra glue code in C via be_regex.c had to handle whether we pass a number implemented as an encoded pointer to a struct d or as a C integer. The code had to be written with great care to ensure people were not passing sequences where integers were expected. Initially, it did not do this.
Now, if you use the c_func/c_proc interface and compile the files into the library, you don't get external dependencies. And it is in the be_callc.c that makes sure your integers are converted into c integers when they need to and whether to error out. The EUPHORIA wrapper code has to be written correctly but it is less work in the end. This approach eliminates a need for a be_json.c which would be needed if you pass everything through machine_func/machine_proc.
Shawn
7. Re: Euphoria JSON parser
- Posted by ghaberek (admin) Oct 16, 2015
- 2756 views
You can link the C code with the rest into the "library" section, and then you use a Euphoria wrapper that you first can debug as an external dll while the team resists.
In the case of Regex, we didn't do this. Because of the way as Open Euphoria has integrated PCRE. Extra glue code in C via be_regex.c had to handle whether we pass a number implemented as an encoded pointer to a struct d or as a C integer. The code had to be written with great care to ensure people were not passing sequences where integers were expected. Initially, it did not do this.
Now, if you use the c_func/c_proc interface and compile the files into the library, you don't get external dependencies. And it is in the be_callc.c that makes sure your integers are converted into c integers when they need to and whether to error out. The EUPHORIA wrapper code has to be written correctly but it is less work in the end.
Right, for wrapping Mini-XML, I was thinking I could put all of the function addresses into a table and just peek them all from memory, get the pointer to that table with one machine_func() call, and then wrap the functions with define_c_func()/proc() as if I were wrapping a shared library. That would save me from having to burn through ~70 machine_func() constants in the backend. But, if I can just use define_c_func()/proc() to access backend functions directly, then I can just skip the machine_func() nonsense entirely. (And if that's true, why do we still have machine_func()? Just for legacy purposes?)
This approach eliminates a need for a be_json.c which would be needed if you pass everything through machine_func/machine_proc.
True, but JSMN is only two functions and my wrapper is only one function. It was easier to just expose that via machine_func() and be done with it. Most of the magic happens directly within json.e anyway. In fact, all of the "build a new JSON object and write it to a file" parts are 100% Euphoria using well-structured map objects.
-Greg
8. Re: Euphoria JSON parser
- Posted by SDPringle Oct 16, 2015
- 2708 views
Try it. Emalloc is something in the static EUPHORIA library. It is not in any shared-object file in /usr/lib or alike. So, try the code I gave on Windows. I noticed this sometime back but we never exploited it.
9. Re: Euphoria JSON parser
- Posted by jimcbrown (admin) Oct 16, 2015
- 2937 views
You can link the C code with the rest into the "library" section, and then you use a Euphoria wrapper that you first can debug as an external dll
Now, if you use the c_func/c_proc interface and compile the files into the library, you don't get external dependencies. And it is in the be_callc.c that makes sure your integers are converted into c integers when they need to and whether to error out. The EUPHORIA wrapper code has to be written correctly but it is less work in the end.
Does this work on Windoze though? Using -1 in place of open_dll("") works fine on nix because they all use the same dlsym() - which supports use of RTLD_DEFAULT and RTLD_NEXT in place of an dlopen handle. (See http://www.scs.stanford.edu/histar/src/pkg/uclibc/include/dlfcn.h and http://linux.die.net/man/3/dlsym for details on how this works.)
I can't find any documentation suggesting that GetProcAddress() on the stupid platform supports anything similar. I even looked through WINE sources ( http://source.winehq.org/source/dlls/ntdll/loader.c and http://source.winehq.org/source/dlls/kernel32/module.c ) but afaict WINE doesn't implement such behavior either.
Another reason this works is because on nix, both shared objects and executables are ELF objects, and you can dlopen() an executable with no problems. However, I've seen evidence that the stupid platform can't do this (e.g. http://stackoverflow.com/questions/19110747/loadlibrary-an-exe )
while the team resists.
Huh?
In the case of Regex, we didn't do this. Because of the way as Open Euphoria has integrated PCRE. Extra glue code in C via be_regex.c had to handle whether we pass a number implemented as an encoded pointer to a struct d or as a C integer.
The main reason we did it this way was for DOS support. Since DOS was dropped I've advocated for loading PCRE via a shared library instead of having it built in, but no one has put in the effort to actually separate it back out again.
Right, for wrapping Mini-XML, I was thinking I could put all of the function addresses into a table and just peek them all from memory, get the pointer to that table with one machine_func() call, and then wrap the functions with define_c_func()/proc() as if I were wrapping a shared library. That would save me from having to burn through ~70 machine_func() constants in the backend.
That's a really clever idea!
But, if I can just use define_c_func()/proc() to access backend functions directly, then I can just skip the machine_func() nonsense entirely. (And if that's true, why do we still have machine_func()? Just for legacy purposes?)
Probably. A big part of this was that the define_c() stuff didn't work on DOS. (At least not this way. You could use define_c_func() to call a machine code routine, but it'd be a heck of a lot harder to call a backend routine in DOS this way. At the very least, you'd need a way to look up the memory address of the routine yourself, as there's no linker (ala dlopen/LoadLibrary) that can do it for you in the DOS version of Euphoria.)
10. Re: Euphoria JSON parser
- Posted by CraigWelch Mar 19, 2017
- 2122 views
Seeking an update on this project - I could really use a JSON parser right now. I'm hard-coding some specific routines, which is slow, painstaking work.
If it's released, where can i find it?
Thanks
11. Re: Euphoria JSON parser
- Posted by ghaberek (admin) Mar 19, 2017
- 2177 views
Seeking an update on this project - I could really use a JSON parser right now. I'm hard-coding some specific routines, which is slow, painstaking work.
If it's released, where can i find it?
Thanks
I'm not exactly sure how to answer that. My hope was that by adding it to the repository as a branch would allow it to be pulled into the master branch as part of a future release.
Ultimately we need somebody to do a release soon. It look like Shawn fixed the two outstanding issues in May. (Further discussions about releasing should continue in that thread.)
-Greg
12. Re: Euphoria JSON parser
- Posted by jmduro Mar 20, 2017
- 2083 views
Don't know if this can fit your need but there is a JSON to Euphoria sequence converter in my Eu4 Standard library (v0.8.3 uploaded today).
It could be extracted with only needed dependancies. Here is the code:
include std/convert.e include std/search.e include std/text.e include _types_.e include _conv_.e include _sequence_.e include _debug_.e include _file_.e function find_matching(integer c, sequence s, integer from) integer n, e if c = '(' then e = ')' elsif c = '[' then e = ']' elsif c = '{' then e = '}' elsif c = '<' then e = '>' elsif c = '"' then e = '"' elsif c = 39 then e = 39 end if n = 0 for i = from to length(s) do if s[i] = c then n += 1 end if if s[i] = e then n -= 1 end if if n = -1 then return i end if end for return 0 end function function split_list(sequence json, integer sep) sequence result integer c, e, previous c = 1 result = {} previous = 1 while c < length(json) do if (json[c] = '{') or (json[c] = '[') then e = find_matching(json[c], json, c+1) c = e elsif json[c] = sep then result = append(result, trim(json[previous..c-1])) c += 1 previous = c end if c += 1 end while result = append(result, trim(json[previous..$])) return result end function global function json_to_sequence(sequence json) sequence s, t object result result = {} if length(json) = 0 then return result end if if (json[1] = '{') and (json[$] = '}') then s = split_list(json[2..$-1], ',') for i = 1 to length(s) do t = split_list(s[i], ':') if length(t) != 2 then error_message("missing colon!", 1) end if if not begins("\"", t[1]) or not ends("\"", t[1]) then error_message("missing string!", 1) end if result = append(result, {dequote(t[1], {}), json_to_sequence(t[2])}) end for elsif (json[1] = '[') and (json[$] = ']') then s = split_list(json[2..$-1], ',') for i = 1 to length(s) do if length(s[i]) then result = append(result, json_to_sequence(s[i])) end if end for elsif is_number(json) then result = to_number(json) elsif is_string(json) then result = dequote(json, {}) end if return result end function
Jean-Marc
13. Re: Euphoria JSON parser
- Posted by jmduro Mar 20, 2017
- 2058 views
These JSON sequences
constant JSON_DATA = { "{\"key1\" : {\"key1.1\" : 2}, \"key2\" : 2}", "[120,183,\"alpha\",\"beta\",\"gamma\"]", "{\"age\" : 51.5, \"details\" : [120,183], \"embedded\" : { \"three\" : 3.0, \"arr\" : [1,2,3,\"alpha\",\"beta\",\"gamma\"]} }", "{\"him\" : true, \"her\" : false, \"nothing\" : null }", "{\"dog\" : {\"name\" : \"Rover\", \"gender\" : \"male\"}}", "{\"configure\":[{\"access-control\":[{\"access-list\":[]}]},{\"slot\":[]},{\"system\":[{\"date-and-time\":[{\"sntp\":[{\"server\":[]}]}]},{\"syslog\":[]}]},{\"management\":[{\"login-user\":[]},{\"snmp\":[{\"community\":[]}]},{\"access\":[]},{\"tacacsplus\":[{\"group\":[]}]}]},{\"qos\":[{\"policer-profile\":[]},{\"shaper-profile\":[]},{\"wred-profile\":[]},{\"queue-block-profile\":[{\"queue\":[]}]},{\"queue-group-profile\":[{\"queue-block\":[]}]}]},{\"port\":[{\"l2cp-profile\":[]},{\"ethernet\":[]},{\"svi\":[]}]},{\"bridge\":[{\"port\":[]},{\"vlan\":[]}]},{\"flows\":[{\"classifier-profile\":[]},{\"flow\":[]}]},{\"router\":[{\"interface\":[{\"dhcp-client\":[]}]}]},{\"oam\":[{\"cfm\":[{\"maintenance-domain\":[{\"maintenance-association\":[{\"mep\":[]}]}]}]}]},{\"test\":[{\"rfc2544\":[{\"profile-name\":[]}]}]}]}" }
are converted to this
result = . [1] . . [1] "key1" . . [2] . . . [1] . . . . [1] "key1.1" . . . . [2] 2 . [2] . . [1] "key2" . . [2] 2 result = . [1] 120 . [2] 183 . [3] "alpha" . [4] "beta" . [5] "gamma" result = . [1] . . [1] "age" . . [2] 51.500000 . [2] . . [1] "details" . . [2] "x·" . [3] . . [1] "embedded" . . [2] . . . [1] . . . . [1] "three" . . . . [2] 3 . . . [2] . . . . [1] "arr" . . . . [2] . . . . . [1] 1 . . . . . [2] 2 . . . . . [3] 3 . . . . . [4] "alpha" . . . . . [5] "beta" . . . . . [6] "gamma" result = . [1] . . [1] "him" . . [2] "true" . [2] . . [1] "her" . . [2] "false" . [3] . . [1] "nothing" . . [2] "null" result = . [1] . . [1] "dog" . . [2] . . . [1] . . . . [1] "name" . . . . [2] "Rover" . . . [2] . . . . [1] "gender" . . . . [2] "male" result = . [1] . . [1] "configure" . . [2] . . . [1] . . . . [1] . . . . . [1] "access-control" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "access-list" . . . . . . . . [2] "" . . . [2] . . . . [1] . . . . . [1] "slot" . . . . . [2] "" . . . [3] . . . . [1] . . . . . [1] "system" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "date-and-time" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "sntp" . . . . . . . . . . . [2] . . . . . . . . . . . . [1] . . . . . . . . . . . . . [1] . . . . . . . . . . . . . . [1] "server" . . . . . . . . . . . . . . [2] "" . . . . . . [2] . . . . . . . [1] . . . . . . . . [1] "syslog" . . . . . . . . [2] "" . . . [4] . . . . [1] . . . . . [1] "management" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "login-user" . . . . . . . . [2] "" . . . . . . [2] . . . . . . . [1] . . . . . . . . [1] "snmp" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "community" . . . . . . . . . . . [2] "" . . . . . . [3] . . . . . . . [1] . . . . . . . . [1] "access" . . . . . . . . [2] "" . . . . . . [4] . . . . . . . [1] . . . . . . . . [1] "tacacsplus" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "group" . . . . . . . . . . . [2] "" . . . [5] . . . . [1] . . . . . [1] "qos" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "policer-profile" . . . . . . . . [2] "" . . . . . . [2] . . . . . . . [1] . . . . . . . . [1] "shaper-profile" . . . . . . . . [2] "" . . . . . . [3] . . . . . . . [1] . . . . . . . . [1] "wred-profile" . . . . . . . . [2] "" . . . . . . [4] . . . . . . . [1] . . . . . . . . [1] "queue-block-profile" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "queue" . . . . . . . . . . . [2] "" . . . . . . [5] . . . . . . . [1] . . . . . . . . [1] "queue-group-profile" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "queue-block" . . . . . . . . . . . [2] "" . . . [6] . . . . [1] . . . . . [1] "port" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "l2cp-profile" . . . . . . . . [2] "" . . . . . . [2] . . . . . . . [1] . . . . . . . . [1] "ethernet" . . . . . . . . [2] "" . . . . . . [3] . . . . . . . [1] . . . . . . . . [1] "svi" . . . . . . . . [2] "" . . . [7] . . . . [1] . . . . . [1] "bridge" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "port" . . . . . . . . [2] "" . . . . . . [2] . . . . . . . [1] . . . . . . . . [1] "vlan" . . . . . . . . [2] "" . . . [8] . . . . [1] . . . . . [1] "flows" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "classifier-profile" . . . . . . . . [2] "" . . . . . . [2] . . . . . . . [1] . . . . . . . . [1] "flow" . . . . . . . . [2] "" . . . [9] . . . . [1] . . . . . [1] "router" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "interface" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "dhcp-client" . . . . . . . . . . . [2] "" . . . [10] . . . . [1] . . . . . [1] "oam" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "cfm" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "maintenance-domain" . . . . . . . . . . . [2] . . . . . . . . . . . . [1] . . . . . . . . . . . . . [1] . . . . . . . . . . . . . . [1] "maintenance-association" . . . . . . . . . . . . . . [2] . . . . . . . . . . . . . . . [1] . . . . . . . . . . . . . . . . [1] . . . . . . . . . . . . . . . . . [1] "mep" . . . . . . . . . . . . . . . . . [2] "" . . . [11] . . . . [1] . . . . . [1] "test" . . . . . [2] . . . . . . [1] . . . . . . . [1] . . . . . . . . [1] "rfc2544" . . . . . . . . [2] . . . . . . . . . [1] . . . . . . . . . . [1] . . . . . . . . . . . [1] "profile-name" . . . . . . . . . . . [2] ""
14. Re: Euphoria JSON parser
- Posted by petelomax Mar 27, 2017
- 2035 views
For anyone interested, I have just pushed a new builtins/json.e module to the Phix repository (see https://bitbucket.org/petelomax/phix/src ), which should be compatible with OpenEuphoria.
Example use:
include builtins/json.e puts(1,"roundtrip (10 examples):\n") sequence json_strings = {"{\"this\":\"that\",\"age\":{\"this\":\"that\",\"age\":29}}", "1", "\"hello\"", "null", "[12]", "[null,12]", "[]", "{\"this\":\"that\",\"age\":29}", "{}", "[null,[null,12]]"} for i=1 to length(json_strings) do string s = json_strings[i] puts(1,s&"\n") object json_object = parse_json(s) puts(1,print_json("",json_object,true)&"\n") if not equal(print_json("",json_object,true),s) then ?9/0 end if end for
I would be interested to hear any tales of success or failure with it.
Pete
15. Re: Euphoria JSON parser
- Posted by CraigWelch Mar 28, 2017
- 2086 views
Thanks to the various replies - plenty there for me to work with. Appreciated.