on bfb7da4 help sign in
.gitignoredeleted−2
since splitsnapshots
unifiedsplit
@@ -1,2 +0,0 @@
1−/result
2−/result-*
README.md+25−105
@@ -1,125 +1,45 @@
1# sphinx
1# sphinx
2
2
3−A search engine powered by you and your pals for finding answers to your
3+## Desk
4−riddles.
5
4
6−sphinx keeps a directory of listings: apps, groups, notebook posts and links.
5+The desk currently has the minimum amount of files necessary to distribute an application and should be distributable immediately. Any further Hoon development should happen here.
7−The listings you publish go to your mutual pals and on to their mutual pals,
8−theirs come to you, and you search everything you've received.
9
6
10−## Install
7+TODO: Add further documentation on beginning Hoon development
11
8
12−sphinx runs on the kernels listed in `desk/sys.kelvin`, currently
9+## UI
13−`[%zuse 408]`. Check your ship first: in the dojo, `+vats %base` must show
14−`/sys/kelvin: [%zuse 408]`. If it shows an older kernel, let your ship finish
15−its update first. If the update is blocked, Landscape lists the apps that
16−block it, and `|bump` suspends them.
17
10
18−Then, in your ship's dojo:
11+sphinx is built primarily using [React], [Typescript], and [Tailwind CSS]. [Vite] ensures that all code and assets are loaded appropriately, bundles the application for distribution and provides a functional dev environment.
19
12
20−```
13+### Getting Started
21−|install ~donmec-sallud %sphinx
22−```
23−
24−For a new install, you can also search for ~donmec-sallud in Landscape and
25−install sphinx from there.
26−
27−sphinx shares listings through %pals. Install it with
28−`|install ~paldev %pals` and add your friends as pals. Without %pals you only
29−see your own listings.
30−
31−### Moving from ~nocsyx-lassul
32−
33−sphinx used to be distributed from ~nocsyx-lassul. Its last release there
34−only supports `[%zuse 412]`, so it can't run on a current ship. On a
35−`[%zuse 412]` ship it blocks the kernel update until you `|bump`. After the
36−kernel check above, switch it to ~donmec-sallud in the dojo:
37−
38−```
39−|install ~donmec-sallud %sphinx
40−```
41−
42−Your listings are kept. Use the dojo for this: Landscape's Get App can't
43−switch an existing install. It asks ~nocsyx-lassul again, and docket refuses
44−because sphinx is already installed.
45−
46−If you ran `|install` before your ship reached `[%zuse 408]`, sphinx stays
47−suspended after the update, and another `|install` only prints
48−`already syncing`. Run `|uninstall %sphinx`, then
49−`|install ~donmec-sallud %sphinx` again. Your listings are kept.
50−
51−## Development
52−
53−The repo has two parts:
54−
55−- `desk/`: the Hoon agent, its marks and libraries, and the docket file.
56−- `ui/`: the web interface, built with [React], [TypeScript],
57− [Tailwind CSS] and [Vite].
58
14
59−### Build
15+To get started using sphinx first you need to run `npm install` inside the `ui` directory.
60
16
61−With Nix:
17+To develop you'll need a running ship to point to. To do so you first need to add a `.env.local` file to the `ui` directory. This file will not be committed. Adding `VITE_SHIP_URL={URL}` where **{URL}** is the URL of the ship you would like to point to, will allow you to run `npm run dev`. This will proxy all requests to the ship except for those powering the interface, allowing you to see live data.
62
18
63−```sh
19+Regardless of what you run to develop, Vite will hot-reload code changes as you work so you don't have to constantly refresh.
64−nix build .#desk -o result-desk # the desk; Nix makes its files read-only
65−nix build .#glob -o result-glob # the UI, built for /apps/sphinx/
66−```
67−
68−To put the desk on a test ship, copy it writable and with `--delete`, as in
69−RELEASING.md step 2. vere aborts at `|unmount` if the mount is read-only.
70−
71−```sh
72−chmod -R u+w result-desk-rw 2>/dev/null; rm -rf result-desk-rw &&
73−cp -RL result-desk result-desk-rw && chmod -R u+w result-desk-rw &&
74−rsync -rpc --delete result-desk-rw/ <pier>/sphinx/
75−```
76−
77−The desk output adds what `desk/` needs from urbit's `base-dev` and
78−landscape's `desk-dev`. Both are pinned in `flake.nix` for the kelvin in
79−`desk/sys.kelvin`. When you port sphinx to a new kernel, update those two
80−inputs together with `desk/sys.kelvin`.
81−
82−Without Nix, run `npm install` and `npm run build` in `ui/` to build the UI
83−into `ui/dist/`. Use the Node version in `ui/.nvmrc`.
84−
85−To publish a release from ~donmec-sallud, follow [RELEASING.md](RELEASING.md).
86
20
87−### Working on the UI
21+### Deploying
88
22
89−You need a running ship with sphinx installed. In `ui/`:
23+To deploy, run `npm run build` in the `ui` directory which will bundle all the code and assets into the `dist/` folder. This can then be made into a glob by doing the following:
90−
91−1. Run `npm install`.
92−2. Create `ui/.env.local` with `VITE_SHIP_URL=<URL>`, where `<URL>` is your
93− ship's web address, for example `http://localhost:8080`. Git ignores this
94− file.
95−3. Run `npm run dev` and open the address it prints.
96−
97−The dev server sends every request except those for the interface itself to
98−the ship, so you see live data. Vite reloads the page as you change the code.
99−
100−### The tile image
101−
102−The tile is inlined in `desk/desk.docket-0` as a `data:` URI, so Landscape
103−doesn't load it from another server.
104−
105−To get the SVG out:
106−
107−```sh
108−sed -n "s|^ image+'data:image/svg+xml;base64,\(.*\)'\$|\1|p" desk/desk.docket-0 | base64 -d > tile.svg
109−```
110
24
111−To put a new one in, print a new `image+` line and replace the old line in
25+1. Create or launch an urbit using the -F flag
112−`desk/desk.docket-0` with it:
26+2. On that urbit, if you don't already have a desk to run from, run `|merge %work our %base` to create a new desk and mount it with `|mount %work`.
27+3. Now the `%work` desk is accessible through the host OS's filesystem as a directory of that urbit's pier ie `~/zod/work`.
28+4. From the `ui` directory you can run `rsync -avL --delete dist/ ~/zod/work/sphinx` where `~/zod` is your fake urbit's pier.
29+5. Once completed you can then run `|commit %work` on your urbit and you should see your files logged back out from the dojo.
30+6. Now run `=dir /=garden` to switch to the garden desk directory
31+7. You can now run `-make-glob %work /sphinx` which will take the folder where you just added files and create a glob which can be thought of as a sort of bundle. It will be output to `~/zod/.urb/put`.
32+8. If you navigate to `~/zod/.urb/put` you should see a file that looks like this `glob-0v5.fdf99.nph65.qecq3.ncpjn.q13mb.glob`. The characters between `glob-` and `.glob` are a hash of the glob's contents.
33+9. Now that we have the glob it can be uploaded to any publicly available HTTP endpoint that can serve files. This allows the glob to distributed over HTTP.
34+10. Once you've uploaded the glob, you should then update the corresponding entry in the docket file at `desk/desk.docket-0`. Both the full URL and the hash should be updated to match the glob we just created, on the line that looks like this:
113
35
114−```sh
36+```hoon
115−printf " image+'data:image/svg+xml;base64,%s'\n" "$(base64 < tile.svg | tr -d '\n')"
37+ glob-http+['https://bootstrap.urbit.org/glob-0v5.fdf99.nph65.qecq3.ncpjn.q13mb.glob' 0v5.fdf99.nph65.qecq3.ncpjn.q13mb]
116```
38```
117
39
118−Keep the rest of the file as it is. Docket rewrites the file when a glob is
40+11. This can now be safely committed and deployed.
119−uploaded, and the hash copy-back in RELEASING.md should only change the hash.
120−A new tile doesn't need a new glob.
121
41
122−[react]: https://react.dev/
42+[react]: https://reactjs.org/
123[typescript]: https://www.typescriptlang.org/
43[typescript]: https://www.typescriptlang.org/
124[tailwind css]: https://tailwindcss.com/
44[tailwind css]: https://tailwindcss.com/
125[vite]: https://vitejs.dev/
45[vite]: https://vitejs.dev/
RELEASING.mddeleted−166
@@ -1,166 +0,0 @@
1−# Releasing sphinx
2−
3−sphinx is published from ~donmec-sallud. Ships install it with
4−`|install ~donmec-sallud %sphinx` and then follow it: the desk syncs from
5−~donmec-sallud's `%sphinx` desk, and docket fetches the UI (the glob) over ames
6−from ~donmec-sallud.
7−
8−A release has three parts:
9−
10−1. **Desk**: copy `nix build .#desk` into ~donmec-sallud's mounted `%sphinx`
11− desk and commit it.
12−2. **Glob**: if the UI changed, upload `nix build .#glob` at `/docket/upload`.
13− Docket hashes it and writes the hash into the desk's `desk.docket-0`.
14−3. **Hash copy-back**: copy that `desk.docket-0` back into the repo and commit
15− it.
16−
17−Never skip the copy-back. On every commit of the desk, docket compares the hash
18−in `desk.docket-0` with the glob it serves, and when they differ it drops the
19−glob and waits for a new upload. So a stale hash in the repo takes the UI down
20−at the next release, on ~donmec-sallud and on every ship that installed
21−sphinx, until the glob is uploaded again.
22−
23−## Before you start
24−
25−- [ ] ~donmec-sallud runs a kernel listed in `desk/sys.kelvin`. In its dojo,
26− `+vats %base` prints it on the `/sys/kelvin:` line. If it isn't listed,
27− port the desk to that kernel first.
28−- [ ] Optional: bump `version` in `desk/desk.docket-0`. Landscape and the
29− sphinx footer show it.
30−- [ ] Everything is committed and pushed. The release is whatever the repo
31− builds.
32−
33−The commands below use these shell variables:
34−
35−```sh
36−server=user@host # ssh login of the user that runs ~donmec-sallud's vere and owns the pier
37−pier=/path/to/donmec-sallud # the pier directory on that machine
38−```
39−
40−## 1. Build
41−
42−From the repo root:
43−
44−```sh
45−nix build .#desk -o result-desk
46−nix build .#glob -o result-glob
47−```
48−
49−## 2. Desk
50−
51−First release only, in ~donmec-sallud's dojo:
52−
53−```
54−|new-desk %sphinx
55−|mount %sphinx
56−```
57−
58−Leave the desk mounted. The copy-back in step 4 reads the mounted file.
59−
60−Every release, copy the desk into the mount from a writable copy of the
61−build:
62−
63−```sh
64−chmod -R u+w result-desk-rw 2>/dev/null; rm -rf result-desk-rw &&
65−cp -RL result-desk result-desk-rw && chmod -R u+w result-desk-rw &&
66−rsync -rpc --delete result-desk-rw/ "$server:$pier/sphinx/"
67−ssh "$server" find "$pier/sphinx" '!' -perm -u+w # must print nothing
68−```
69−
70−- The `&&`s stop the copy when an earlier step fails, so a leftover
71− `result-desk-rw` from an interrupted run is never sent.
72−- Send a writable copy. Nix store files and directories are read-only, and
73− `-p` copies their modes into the mount. Don't rely on `rsync --chmod=u+w`:
74− macOS's `rsync` (openrsync) accepts it and ignores it. In a read-only
75− mount, vere aborts when it has to delete a file, for example at
76− `|unmount %sphinx` in step 3, and clay's writes to read-only files fail.
77−- `-c` compares contents. Don't use `rsync -a`: Nix store files all have
78− mtime 1, so with `-a` rsync can skip an edited file of the same size.
79−
80−Then in the dojo:
81−
82−```
83−|commit %sphinx
84−```
85−
86−The dojo lists the changed files, for example
87−`: /~donmec-sallud/sphinx/4/desk/docket-0`. A commit that changes nothing
88−prints nothing. A mark or build error rejects the whole commit, so fix the
89−desk and repeat this step.
90−
91−First release only:
92−
93−```
94−|install our %sphinx
95−|public %sphinx
96−:treaty|publish %sphinx
97−```
98−
99−- `|install our %sphinx` starts the app on ~donmec-sallud. Docket then prints
100− `docket: awaiting manual glob for %sphinx desk` until step 3.
101−- `|public %sphinx` lets other ships read the desk. Without it, clay doesn't
102− answer their requests, so `|install ~donmec-sallud %sphinx` waits without
103− an error. A waiting install continues by itself once the desk is public.
104−- `:treaty|publish %sphinx` lists sphinx under ~donmec-sallud in Landscape's
105− app search. It also makes the desk public.
106−
107−## 3. Glob
108−
109−Upload on the first release and whenever `ui/` changed since the last one. If
110−you're unsure, upload anyway: the same files give the same hash.
111−
112−1. Log in to ~donmec-sallud's web interface in Chrome or Firefox. Safari can't
113− upload folders. `+code` in the dojo prints the password.
114−2. Open `/docket/upload` on that host, pick the desk `sphinx`, choose the
115− `result-glob` folder and press **glob!**. If the folder picker won't follow
116− the `result-glob` symlink, press ⇧⌘G and paste the path that
117− `readlink result-glob` prints.
118−3. The page answers `successfully globbed`.
119−4. Refresh the mount in the dojo:
120−
121− ```
122− |unmount %sphinx
123− |mount %sphinx
124− ```
125−
126− Docket wrote the new hash into clay. vere rewrites a mounted file only
127− while it still holds exactly what clay last wrote there. If the
128− `desk.docket-0` you committed differs in any byte from what docket prints
129− (another layout, or a final newline an editor added), the mounted file
130− keeps the old hash, and any later `|commit %sphinx`, even one with no
131− edits, puts the old hash back and takes the UI down. The remount rewrites
132− the mount from clay. It's harmless when the file is already current, so
133− always do it.
134−
135−## 4. Hash copy-back
136−
137−After every upload and remount:
138−
139−```sh
140−scp "$server:$pier/sphinx/desk.docket-0" desk/desk.docket-0
141−```
142−
143−Only the hash on the `glob-ames` line should change. Commit
144−`desk/desk.docket-0` and push it.
145−
146−## 5. Check
147−
148−- [ ] `/apps/sphinx/` on ~donmec-sallud loads.
149−- [ ] The dojo has not printed `docket: awaiting manual glob for %sphinx desk`
150− since the last commit or upload.
151−- [ ] `+vats %sphinx` shows `app status: running`.
152−- [ ] First release only: another ship can `|install ~donmec-sallud %sphinx`.
153− Its log shows `kiln: merge into %sphinx succeeded` and
154− `docket: fetching %ames glob for %sphinx desk`.
155−- [ ] A ship that installed sphinx shows the release at `/apps/sphinx/`.
156− After a glob upload, it answers 404 until it has downloaded the new
157− glob from ~donmec-sallud.
158−
159−## If the UI is down
160−
161−`docket: awaiting manual glob for %sphinx desk` after a commit means the hash
162−in the desk's `desk.docket-0` doesn't match the glob docket serves, usually
163−because a copy-back or a remount was skipped. Build the glob from the released
164−commit, upload it and remount (step 3), then do the copy-back (step 4). Other
165−ships pick up the corrected `desk.docket-0` and fetch the glob again on their
166−own.
desk/app/sphinx.hoon+20−113
@@ -15,84 +15,13 @@
15 state-1:s
15 state-1:s
16 state:s
16 state:s
17 ==
17 ==
18− :: +guard-verb: refuse /verb watches from other ships
19− ::
20− :: lib/verb (here and inside lib/gossip) answers /verb/events and
21− :: /verb/events-plus for anyone, and those carry every poke, card
22− :: and wire of the agent, the mutuals in gossip's wires included
23− ::
24− ++ guard-verb
25− |= inner=agent:gall
26− ^- agent:gall
27− |_ =bowl:gall
28− +* this .
29− ag ~(. inner bowl)
30− ++ on-init
31− ^- (quip card _this)
32− =^ cards inner on-init:ag
33− [cards this]
34− ::
35− ++ on-save on-save:ag
36− ++ on-load
37− |= old=vase
38− ^- (quip card _this)
39− =^ cards inner (on-load:ag old)
40− :: and end the /verb watches other ships made before this guard
41− ::
42− :_ this
43− %+ weld cards
44− %+ murn ~(val by sup.bowl)
45− |= [=ship =path]
46− ^- (unit card)
47− ?. &(?=([%verb *] path) !=(our.bowl ship)) ~
48− `[%give %kick ~[path] `ship]
49− ::
50− ++ on-poke
51− |= [=mark =vase]
52− ^- (quip card _this)
53− =^ cards inner (on-poke:ag mark vase)
54− [cards this]
55− ::
56− ++ on-watch
57− |= =path
58− ^- (quip card _this)
59− ?< &(?=([%verb *] path) !=(our.bowl src.bowl))
60− =^ cards inner (on-watch:ag path)
61− [cards this]
62− ::
63− ++ on-leave
64− |= =path
65− ^- (quip card _this)
66− =^ cards inner (on-leave:ag path)
67− [cards this]
68− ::
69− ++ on-peek on-peek:ag
70− ++ on-agent
71− |= [=wire =sign:agent:gall]
72− ^- (quip card _this)
73− =^ cards inner (on-agent:ag wire sign)
74− [cards this]
75− ::
76− ++ on-arvo
77− |= [=wire =sign-arvo]
78− ^- (quip card _this)
79− =^ cards inner (on-arvo:ag wire sign-arvo)
80− [cards this]
81− ::
82− ++ on-fail
83− |= [=term =tang]
84− ^- (quip card _this)
85− =^ cards inner (on-fail:ag term tang)
86− [cards this]
87− --
88 --
18 --
89=| state:s
19=| state:s
90=* state -
20=* state -
91=<
21=<
92− %- guard-verb
93− %+ verb |
22+ %+ verb &
94 %- %+ agent:gossip
23 %- %+ agent:gossip
95− [2 %mutuals %mutuals |]
24+ [2 %mutuals %mutuals]
96 %- malt
25 %- malt
97 ^- (list [mark $-(* vase)])
26 ^- (list [mark $-(* vase)])
98 :~ [%directory-listing |=(n=* !>((grab-listing n)))]
27 :~ [%directory-listing |=(n=* !>((grab-listing n)))]
@@ -132,7 +61,7 @@
132 |= =directory:s
61 |= =directory:s
133 =/ purged (purge-invalid directory)
62 =/ purged (purge-invalid directory)
134 :* %2
63 :* %2
135− (reindex purged)
64+ (reindex directory)
136 purged
65 purged
137 (purge-foreign purged)
66 (purge-foreign purged)
138 ==
67 ==
@@ -146,15 +75,10 @@
146 |= =directory:s
75 |= =directory:s
147 ^- directory:s
76 ^- directory:s
148 %- malt
77 %- malt
149− %+ skim
78+ %+ skim
150− ~(tap by directory)
79+ ~(tap by directory)
151 |= [=hash:s =listing:s]
80 |= [=hash:s =listing:s]
152− :: the old %directory handler kept peers' entries under any key
153− ::
154− ?& =(hash hash.listing)
155− =(hash (digest post.listing))
156− (listing-valid listing)
81+ (listing-valid listing)
157− ==
158 ++ purge-foreign
82 ++ purge-foreign
159 |= =directory:s
83 |= =directory:s
160 ^- directory:s
84 ^- directory:s
@@ -218,16 +142,13 @@
218 cor
142 cor
219 ::
143 ::
220 %remove
144 %remove
221− ?> from-self
222 =+ !<(=hash:s vase)
145 =+ !<(=hash:s vase)
223 di-abet:di-remove:(di-abed:di-core hash)
146 di-abet:di-remove:(di-abed:di-core hash)
224 ==
147 ==
225 ++ publish
148 ++ publish
226 |= [=declare:s core=_cor]
149 |= [=declare:s core=_cor]
227 ^+ cor
150 ^+ cor
228− :: .core accumulates a %declarations batch: a skipped listing must
151+ ::
229− :: return it, not the pre-batch .cor, or the batch so far is lost
230− ::
231 ?> from-self
152 ?> from-self
232 =/ listing
153 =/ listing
233 :* post=q.declare
154 :* post=q.declare
@@ -236,10 +157,10 @@
236 source=our.bowl
157 source=our.bowl
237 time=now.bowl
158 time=now.bowl
238 ==
159 ==
239− ?: (~(has by directory.core) hash.listing)
160+ ?: (~(has by directory) hash.listing)
240 ~& 'Listing already exists.'
161 ~& 'Listing already exists.'
241− core
162+ cor
242− ?. (listing-valid listing) core
163+ ?. (listing-valid listing) cor
243 di-abet:(di-publish:(di-abed:di-core:core hash.listing) listing)
164 di-abet:(di-publish:(di-abed:di-core:core hash.listing) listing)
244 --
165 --
245::
166::
@@ -273,21 +194,10 @@
273 ::
194 ::
274 %directory
195 %directory
275 =+ !<(d=directory:s vase)
196 =+ !<(d=directory:s vase)
276− :: take only listings we don't have yet that pass the checks a
197+ =. directory (~(uni by directory) d)
277− :: %directory-listing gets: a peer's map must not replace our
278− :: listings, and each listing may be indexed only once
279− ::
280− =/ new=(list [=hash:s =listing:s])
281− %+ skim ~(tap by d)
282− |= [=hash:s l=listing:s]
283− ?& =(hash hash.l)
284− =(hash (digest post.l))
285− !(~(has by directory) hash)
286− (listing-valid l)
287− ==
288− =. directory (~(gas by directory) new)
289 =. index
198 =. index
290− %+ roll new
199+ %+ roll
200+ ~(tap by d)
291 |= [[=hash:s l=listing:s] i=_index]
201 |= [[=hash:s l=listing:s] i=_index]
292 (~(catalog delver i) hash l)
202 (~(catalog delver i) hash l)
293 cor
203 cor
@@ -329,13 +239,10 @@
329 =/ limit (slav %ud i.t.t.t.t.path)
239 =/ limit (slav %ud i.t.t.t.t.path)
330 =/ tag (slav %t i.t.t.t.t.t.path)
240 =/ tag (slav %t i.t.t.t.t.t.path)
331 %^ page start limit
241 %^ page start limit
332− %+ sort
333− %+ skim
242+ %+ skim
334− ~(val by directory)
243+ ~(val by directory)
335− |= =listing:s
244+ |= =listing:s
336− !=(~ (find ~[tag] tags.post.listing))
245+ !=(~ (find ~[tag] tags.post.listing))
337− |= [a=listing:s b=listing:s]
338− (lth time.b time.a)
339 ::
246 ::
340 [%x %lookup @ @ @ $@(~ [@ ~])]
247 [%x %lookup @ @ @ $@(~ [@ ~])]
341 =- ``search+!>(-)
248 =- ``search+!>(-)
@@ -380,8 +287,8 @@
380 ==
287 ==
381++ get-listings
288++ get-listings
382 |= l=(list hash)
289 |= l=(list hash)
383− %+ murn l
290+ %+ turn l
384− |=(=hash:s (~(get by directory) hash))
291+ |=(=hash:s (~(got by directory) hash))
385++ from-self =(our src):bowl
292++ from-self =(our src):bowl
386++ di-core
293++ di-core
387 |_ [=listing:s =hash:s gone=_|]
294 |_ [=listing:s =hash:s gone=_|]
@@ -396,7 +303,7 @@
396 di-core(hash h, listing (~(gut by directory) h *listing:s))
303 di-core(hash h, listing (~(gut by directory) h *listing:s))
397 ++ di-remove
304 ++ di-remove
398 =. gone &
305 =. gone &
399− =/ keys (~(gut by trail.index) hash ~)
306+ =/ keys (~(got by trail.index) hash)
400 =. trail.index (~(del by trail.index) hash)
307 =. trail.index (~(del by trail.index) hash)
401 =. lookup.index
308 =. lookup.index
402 ^- lookup:s
309 ^- lookup:s
desk/desk.docket-0+10−11
@@ -1,11 +1,10 @@
1−:~
2− title+'sphinx'
1+:~ title+'sphinx'
3− info+'A search engine powered by you and your pals for finding answers to your riddles'
2+ info+'A search engine powered by you and your pals for finding answers to your riddles'
4− color+0xb2.5068
3+ color+0xb2.5068
4+ image+'https://nyc3.digitaloceanspaces.com/hmillerdev/nocsyx-lassul/2022.7.16..04.35.22-sphinx-web-shifted.svg'
5+ glob-http+['https://nyc3.digitaloceanspaces.com/hmillerdev/sphinx/glob-0v7.folii.k685s.mjn1b.46up6.but26.glob' 0v7.folii.k685s.mjn1b.46up6.but26]
6+ base+'sphinx'
5− version+[0 7 0]
7+ version+[0 6 0]
6− website+'https://github.com/iko-soy/sphinx'
8+ website+'https://github.com'
7− license+'MIT'
9+ license+'MIT'
8− image+'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0zMCAxNSA2MDAgNjAwIiBmaWxsPSJub25lIj48c3R5bGU+LnN0MXtmaWxsOiNlN2FiNzl9PC9zdHlsZT48cGF0aCBjbGFzcz0ic3QxIiBkPSJNMjk0LjY5IDU2Mi45NmMtMy4zNCAwLTYuNjgtLjA5LTEwLjAyLjAyLTcuNTkuMjUtMTUuMS0uMi0yMi41NS0xLjg1LTkuNTQtMi4xMi0xNy44NS02LjU3LTI0LjYyLTEzLjUzLTQuMTUtNC4yNy03Ljk3LTguOTQtMTEuNC0xMy44MS0xMC40Mi0xNC44Mi0yMC40OS0yOS44OS0zMC44Ni00NC43NS0xMS44OC0xNy4wMi0yNC4wNC0zMy44NS0zNS44OC01MC45LTguNDMtMTIuMTQtMTYuNTYtMjQuNDgtMjQuNzItMzYuOC0yLjAxLTMuMDQtNC41Ny01LjE2LTcuNzYtNi44OC0yMS4zMy0xMS41MS0zOC44NC0yNy4xOC01MS40Ni00OC4wMy0zLjY0LTYuMDItNy4yNy0xMi4wNi0xMC41LTE4LjMtNC41Ni04LjgxLTYuOTgtMTguMzktOS4wNC0yOC4wNC00LjY3LTIxLjg2LTIuNDktNDMuMTIgNi42OC02My40NSA1Ljc4LTEyLjgyIDExLjkzLTI1LjUgMTguNjYtMzcuODQgMTQuNjctMjYuOTIgMzEuMTktNTIuNjcgNTAuMzctNzYuNjUgOC40OC0xMC42IDE3LjUzLTIwLjY1IDI4LjA5LTI5LjI2IDIwLjItMTYuNDcgNDIuOTctMjcuOTQgNjcuOTktMzUuMDUgOC40Ny0yLjQxIDE3LjA1LTQuMTggMjUuODEtNS4yNiA2Ljk3LS44NiAxMy44Mi0yLjYxIDIwLjc4LTMuNiA4LjE4LTEuMTUgMTYuNC0yLjA0IDI0LjYzLTIuNzggOS40NS0uODUgMTguOTMtMS41OCAyOC40MS0yLjA0IDE0Ljk5LS43MyAyOS44NS43MiA0NC42NCAzLjI1IDIzLjYgNC4wMyA0Ni44NSA5LjU2IDY5Ljk4IDE1LjY4IDMuMDUuODEgNi4wMSAxLjk4IDkuMDUgMi44NSAzLjE5LjkxIDQuNDYgMy4wNCA0LjU3IDYuMjMuMjggOC43OS43NCAxNy41Ny45OSAyNi4zNi4xNSA1LjQ1LjA4IDEwLjkxLjAzIDE2LjM2LS4wNCAzLjU3LjY1IDYuOTcgMS43OCAxMC4zNCAzLjQ3IDEwLjM0IDYuOTMgMjAuNjggMTAuMyAzMS4wNCAzLjEgOS41MyA2LjA1IDE5LjExIDkuMTcgMjguNjMgMS4yIDMuNjcgMi40OCA3LjM1IDQuMTQgMTAuODIgOC4zMiAxNy40NyAxNi43MiAzNC45IDI1LjE5IDUyLjI5IDUuNzggMTEuODYgMTEuNzIgMjMuNjMgMTcuNiAzNS40NS42MyAxLjI2IDEuMTkgMi41NSAxLjg5IDMuNzcgNC43IDguMjggMy44NCAxNi41OS0uMDkgMjQuNzctMi41OSA1LjM5LTUuNTIgMTAuNjEtOC4zOSAxNS44Ni01LjQgOS44OC0xMC43NiAxOS43OC0xNi4zMiAyOS41Ny0zLjMgNS44MS00LjY2IDEyLjAxLTUuMTMgMTguNjEtMS4zIDE4LjQxLTMuMDcgMzYuNzgtNS45MSA1NS4wNC0xLjc5IDExLjQ2LTIuOTEgMjMuMDItNC40NyAzNC41Mi0xLjU0IDExLjMyLTMuMiAyMi42My00LjkxIDMzLjkyLTIgMTMuMjEtNC4xNyAyNi40LTYuMTEgMzkuNjItLjM4IDIuNi0xLjEzIDQuNzQtMy4zIDYuMjgtLjQzLjMtLjguNjgtMS4yMSAxLjAyLTcuOTcgNi43Ny03Ljk3IDYuODEtMTguNSA3LjAxLTYuNjguMTItMTMuMzcuMTItMjAuMDUuMTQtMS4yMyAwLTIuNDYtLjEtMy42OS0uMTktMi4zMS0uMTYtMy44Ni0xLjE3LTQuNzEtMy41LTIuMjctNi4yOC00LjczLTEyLjQ4LTcuMDgtMTguNzMtMS4xMS0yLjk2LTIuMi01LjkzLTMuMjEtOC45Mi0uNTMtMS41OS0xLjY3LTIuMzUtMy4xNy0yLjI3LTMuNS4xOS03LjAzLjM4LTEwLjQ2IDEuMDEtMS4xLjItMi4yNCAxLjY0LTIuODMgMi43OS0xLjMxIDIuNTMtMy4yNiAzLjM4LTUuOTkgMy40Mi0xNS42NS4yNC0zMS4zLjU2LTQ2Ljk1LjkxLTIuMzMuMDUtNC4xMy0uNTctNS4yNC0yLjcyLS42My0xLjIxLTEuNjYtMS42Ny0zLjA1LTEuNjMtNS40NS4xNC0xMC45MS4xNy0xNi4zNi4zMy0yLjU5LjA4LTMuMjYuOTktMi4yMyAzLjMzIDMuMzQgNy41NiA2LjgzIDE1LjA1IDEwLjI4IDIyLjU2Ljk1IDIuMDggMi4wNiA0LjA5IDIuOSA2LjIxIDEuMTUgMi45My41MSA0LjQxLTIuNjIgNC45Mi01LjIuODUtMTAuNDUgMS41NC0xNS43IDEuODEtNS43OS4zMS0xMS42LjA3LTE3LjQxLjA3LS4wMy4wMS0uMDMtLjAxLS4wMy0uMDN6bTc3Ljk0LTQwMS40N2MtNC45NS4xNC0xMC45My4xOC0xNi45LjUtNy45LjQyLTE1Ljc5IDEuMDgtMjMuNTEgMy0xNS40NSAzLjg1LTMwLjA4IDkuNzktNDQuMDIgMTcuNDUtMi40NSAxLjM1LTQuODIgMi45Ni02LjkgNC44Mi0xLjc5IDEuNi0zLjU5IDMuNTItNC41NyA1LjY2LTMuMDYgNi43MS02LjA4IDEzLjQ4LTguNDkgMjAuNDQtMy41MiAxMC4xOC00LjIgMjAuNzMtMS41NyAzMS4yMSA0LjUxIDE4LjAxIDEyLjUxIDM0LjMzIDI0Ljg5IDQ4LjMyIDguMzMgOS40MiAxOC4wMyAxNy4yOSAyOC41NCAyNC4xMSAzLjUxIDIuMjggNy4zNiA0LjE3IDExLjI4IDUuNjggNS40MSAyLjA4IDExLjAzIDMuNiAxNi41MyA1LjQ0IDIuMjQuNzUgNC4zNS43NSA2LjY4LjE4IDExLjQ0LTIuODIgMjIuOS01LjU0IDM0LjM5LTguMTQgNy4yLTEuNjMgMTQuNS0yLjgzIDIxLjY4LTQuNTMgNS42NS0xLjM0IDkuNi41MyAxMi40NCA1LjM3LjYyIDEuMDYgMS4yOCAyLjExIDIgMy4xMSAxLjEzIDEuNTcgMi4yNCAxLjYgMy4yMS0uMTUgNC44Ny04Ljc2IDkuODEtMTcuNDkgMTMuMDktMjcuMDMgNC45My0xNC4zMSA0LjgtMjkuMDYgMy42Ny00My44NS0uNzgtMTAuMTYtMS45Ny0yMC4yOS0yLjk0LTMwLjQ0LS4zMi0zLjM2LTEuMDMtNC4wOC00LjM0LTMuNDQtNS43OSAxLjExLTExLjU0Ljg3LTE3LjMyLS4wMS0yLjYxLS40LTUuMjEtLjc5LTcuODMtMS4xMi0xLjYyLS4yLTIuNDcuNzktMS43NiAyLjIgMS4yNiAyLjUgMi42NCA0Ljk3IDQuMTUgNy4zMyAzLjEzIDQuODkgNi4zNCA5LjczIDkuNjEgMTQuNTIgMi4zOSAzLjUxIDIuODkgNy4yMSAxLjA3IDExLTIuNDEgNS4wMi00LjI2IDEwLjUyLTkuNzIgMTMuMzYtMS40NC43NS0xLjk4IDIuMTItMS42IDMuNTcuMzkgMS40OCAxLjEgMi45IDEuODUgNC4yNSAxLjExIDEuOTggMi40OSAzLjgxIDMuNTUgNS44MS45MyAxLjc2IDEuMTggMy44LjMxIDUuNjItMS4xNyAyLjQ3LTEuNDYgNC45Mi0xLjI2IDcuNjQuMjUgMy40Ni0xLjUyIDUuNzktNC45MSA2LjUtMi43Mi41Ny01LjU4Ljc1LTguMzcuNjUtMTAuMDItLjM1LTIwLjAzLS42NC0zMC4wNC4zMi0xLjc0LjE3LTMuNTIuMTktNS4yNy4xLTEuOTktLjExLTMuMTctMS4wMi0zLjAyLTIuMjMuMjMtMS44OCAxLjc5LTEuOTQgMy4xOS0yLjAzIDUuMjYtLjM1IDEwLjUzLS44NSAxNS44LS44OSA2LjUtLjA1IDEyLjk5LjM2IDE5LjQ5LjQzIDIuMjUuMDIgNC41NC0uMjMgNi43Ni0uNjMgMS4yOC0uMjMgMi4xMy0xLjMgMi4wNC0yLjcxLS4xLTEuNTMtLjg0LTIuNzQtMi40NS0zLjAxLTEuMi0uMjEtMi40NS0uMjEtMy42OC0uMjQtNC40LS4wOS04LjgtLjAzLTEzLjE5LS4yNS02LjQ0LS4zMS0xMi41OS44NS0xOC41OCAzLjEzLTIuNDYuOTMtNC45NiAxLjg4LTcuNTMgMi4zNi0xLjgzLjM0LTMuOTEuMjctNS42Ny0uMjgtMy4wOC0uOTYtMy45MS00LjQxLTEuNzMtNi44IDEuMDMtMS4xMyAyLjQ2LTEuOTggMy44NS0yLjY5IDQuMjMtMi4xNSA4LjU2LTQuMTIgMTIuOC02LjI1IDcuOTgtNC4wMSAxNi40Mi02LjE5IDI1LjM0LTYuNTMgMS40LS4wNSAyLjgyLS4xNCA0LjE4LS40Ni41Mi0uMTIgMS4zMS0xLjA0IDEuMjItMS40LS4xOS0uNzEtLjc4LTEuNTItMS40My0xLjg0LS43NC0uMzYtMS43Mi0uMzItMi41OS0uMjktNC4zOS4xOS04Ljc3LjUtMTMuMTcuNjMtMy4xNS4wOS02LjMxLjEzLTkuNDUtLjExLTIuMDItLjE2LTMuMDMtMS4yOS0yLjgxLTIuNDguMzItMS43NCAxLjczLTIuMDMgMy4xNy0yLjIzLjM1LS4wNS43LS4wNCAxLjA1LS4wNiA1LjgtLjE3IDExLjYxLS4yMyAxNy40LS41NiAzLjY3LS4yMSA3LjM0LS43MiAxMC45Ny0xLjMyIDIuMDgtLjM1IDQuMTItMS4xMyA1LjI0LTMuMTcuMjEtLjM5LjItLjk4LjEtMS40My0uMDQtLjE4LS42LS4zNi0uOTEtLjM1LTEwLjcxLjUyLTIxLjQyIDEuMDgtMzIuMTMgMS42LTIuNTguMTMtNC44MiAxLjA5LTYuNjcgMi44MS0xLjc5IDEuNjctMy40MSAzLjUzLTUuMDggNS4zNC0uNzEuNzctMS4yMyAxLjc1LTIuMDMgMi4zOS0uNDMuMzUtMS4zOS40My0xLjkxLjE5cy0xLS45OS0xLjExLTEuNTljLS4xMS0uNjMuMDYtMS40Ni40MS0yIDQuMTMtNi40MSA4Ljk0LTExLjc0IDE3LjQxLTExLjk2IDguMDktLjIxIDE2LjE3LS42NSAyNC4yNC0xLjA4IDIuODEtLjE1IDUuNjEtLjUxIDguMzktLjkgMy41Ni0uNSA0Ljc4LTIuNzUgMy4yMy02LS42OC0xLjQyLTEuNjQtMi43LTIuNTEtNC4wMi0zLjU5LTUuNDMtNy4zMS0xMC43Ny0xMC43Ni0xNi4yOS0yLjkxLTQuNjUtNS40LTkuNjMtNS40LTE1LjI0IDAtNi44NS41MS0xMy42OS44MS0yMC41NC4wMi0uMzUuMTYtLjY4LjIyLTEuMDMgMS4wNC02LjM0IDQuNzMtMTAuNDMgMTAuNzItMTIuNTUgOC40NC0yLjk4IDE2LjgzLTMuNTMgMjUuMTEuNTcuNjMuMzEgMS4yNy42MiAxLjkyLjg4IDIuMTYuODUgMi43OC41OCAzLjMzLTEuNzQgMS41NS02LjUxLjktNi4xNi00LjM1LTcuOC05Ljc4LTMuMDYtMTkuODItNC42NC0yOS45OS01LjcxLTEyLjI1LTEuMjYtMjQuNDktMi4zMy0zNy44Ni0xLjA5em0tMTU3LjA4IDYuMjljLTEuMzQtMTAuMS0yLjUtMjAuMjMtNC4wNi0zMC4zLTIuNDctMTUuOTctNS4yMS0zMS45MS03Ljg0LTQ3Ljg1LS4wOS0uNTItLjE0LTEuMDUtLjI3LTEuNTUtLjI3LTEuMDEtMS4yMi0xLjIzLTItLjQ1LS40OS41LS45NCAxLjA0LTEuMzggMS41OS0xNy4wMyAyMS4yOC0zMy45MSA0Mi42Ny00OS45OSA2NC42OC04LjcyIDExLjkzLTE3LjAyIDI0LjE0LTI0LjUyIDM2Ljg4LTYuODkgMTEuNy0xMi42OCAyMy45MS0xNi43NiAzNi44OS0yLjcxIDguNjMtNC4wMSAxNy40MS0zLjExIDI2LjUgMS4wNSAxMC42NiAxLjc2IDIxLjM2IDIuNjggMzIuMDQuNzEgOC4yMyAxLjM5IDE2LjQ2IDIuMzIgMjQuNjYuNzYgNi43MyAxLjI0IDYuOTYgNy45MyA2LjQ2IDE0LjAyLTEuMDUgMjguMDQtMi4wMyA0Mi4wNS0zLjEzIDUuMjUtLjQxIDEwLjQ4LTEuMTYgMTUuNzMtMS42MyA5LjI3LS44MyAxOC40Mi0yLjg1IDI3Ljc5LTIuNjMgMS4yMi4wMyAyLjQ2LS4xNyAzLjY3LS4zOSAyLjE3LS40IDIuNTQtMS4xMyAxLjgyLTMuMzUtLjE2LS41LS4zOC0uOTgtLjU5LTEuNDctMy45Ny05LjYtNy4xOC0xOS40NC05Ljg5LTI5LjQ3LS41My0xLjk1LTEuNzctMy43OC0yLjk1LTUuNDctMi43Mi0zLjg4LTUuODktNy40Ny04LjM5LTExLjQ5LTguODktMTQuMy0xMy43NS0yOS41NC0xMC41NS00Ni42NiAxLjkyLTEwLjI5IDYuMTgtMTkuMzUgMTIuOTYtMjcuMjggNC40LTUuMTUgOS44Ny04LjM3IDE2LjU3LTkuNDYgMS4yMS0uMiAyLjQxLS40OCAzLjYzLS42NyA1LjQ3LS44NSA1LjQ3LS44NCA1LjE1LTYuNDV6bTQ1LjAxIDY0LjFjLS40MS03LjQxIDEuMDYtMTQuNjQgMy42MS0yMS42MiAxLjkzLTUuMjcgNC40Mi0xMC4zNCA2LjU1LTE1LjU1IDMuMzEtOC4wOCA5LjMtMTMuNTggMTYuOTItMTcuMzcgNy40LTMuNjggMTQuODMtNy4zMyAyMi40OC0xMC40NCAxMC4xMi00LjExIDIwLjU1LTcuNTIgMzEuNDctOC41NiAxMS4zNy0xLjA5IDIyLjgtMS42IDM0LjIxLTIuMSAxMy4zOC0uNTkgMjYuNzIuMTkgNDAgMS45OCA3LjY3IDEuMDMgMTUuMzMgMi4wOSAyMi43NSA0LjQzIDIuMTguNjkgNC4zOSAxLjI5IDYuNjEgMS43OSAxLjU5LjM2IDIuMy0uMTggMi41NC0xLjgxLjE4LTEuMjEuMjEtMi40NS4yNS0zLjY4LjI4LTcuNTYuNS0xNS4xMi44My0yMi42OC4xMy0yLjk4LjU5LTUuOTYuNjctOC45NC4wOS0zLjE1LS43LTQuMDItMy44OS00LjUtMTEuNjUtMS43My0yMy4zMS0zLjQ1LTM0Ljk4LTUuMDctMi40Mi0uMzQtNC45MS0uMjktNy4zNy0uMjYtMS42Mi4wMi0yLjU5IDEuMDItMi43OCAyLjYzLS4xMiAxLjA0LS4xMSAyLjEtLjE1IDMuMTYtLjIzIDUuOC0uNCAxMS42LS42OSAxNy4zOS0uMjEgNC4wMS0xLjQ3IDUuMTctNS42NyA1LjE1LTIuNjMtLjAxLTUuMjYtLjQyLTcuOS0uNDUtNC4yMS0uMDUtOC40OS0uMzgtMTIuNjEuMjYtMy43NS41OC02LjEtLjYzLTcuOTktMy41My0xLjI1LTEuOTEtMi41NS0zLjgtMy42OC01Ljc5LTMuMDMtNS4zNi02LjE1LTEwLjY4LTguODktMTYuMTgtMS4zOS0yLjgtMy4xMi0zLjktNi4xOS0zLjU5LTguNC44NS0xNi44NSAxLjItMjUuMiAyLjM1LTI1LjExIDMuNDMtNDkuODYgOC44MS03NC40OSAxNC43LTguNzEgMi4wOC0xNy4yOCA0Ljc5LTI1Ljg3IDcuMzctMy40MiAxLjAzLTMuNzMgMS41My0zLjM2IDQuODkuODYgNy44NiAxLjkxIDE1LjcxIDIuODEgMjMuNTYuMzUgMy4wMiAxLjU0IDUuMDIgNC42NCA2LjI1IDYuODkgMi43MyAxMS40OSA4LjAyIDE0LjI3IDE0Ljc4IDEuNDcgMy41NiAyLjYzIDcuMjggMy41OSAxMS4wMiAyLjY4IDEwLjM4IDQuOTggMjAuODYgNy44IDMxLjIgMy44OCAxNC4yNSA3LjkgMjguNDYgMTIuMjIgNDIuNTggNC44MiAxNS43NCAxNC4xMiAyOC42NyAyNi41OSAzOS4yMyA3LjI0IDYuMTMgMTQuOTggMTEuNjcgMjIuNDcgMTcuNTEgNy41NCA1Ljg4IDE1LjcyIDEwLjcgMjQuNTkgMTQuMjIgNC41NCAxLjggNy42MyA0Ljg1IDkuOCA5LjA3IDIuNDkgNC44NSA1LjIxIDkuNTkgNy40OSAxNC41NCAxMS4zOSAyNC43NyAyMi42OCA0OS41OSAzNCA3NC4zOS41OCAxLjI4IDEuMTIgMi41OSAxLjc0IDMuODQgMS4wMSAyLjAyIDEuODEgMi4xIDMuNTIuNDMgNC41My00LjQzIDQuODEtNS43MSAyLjEtMTEuNTQtNi42LTE0LjE5LTEzLjIyLTI4LjM4LTE5LjktNDIuNTQtMy4zLTYuOTktNy4xNS0xMy43NS0xMC0yMC45Mi01LjAyLTEyLjYtMTAuNjYtMjQuODktMTYuNjMtMzcuMDYtMS4yLTIuNDUtMi44NC0zLjgyLTUuMzYtNC41NS0zLjM4LS45OC02LjY4LTIuMjEtMTAuMDQtMy4yMy02LjgtMi4wNy0xMy4wOC01LjE4LTE4LjgxLTkuMzUtNC4yNi0zLjExLTguNDctNi4yOS0xMi41OS05LjU5LTE5Ljc2LTE1Ljg0LTMyLjI5LTM2LjQtMzkuMzEtNjAuNTMtMS42MS01LjYxLTIuNDktMTEuMzMtMi4xNy0xNy4yOXptNzYuMjQgMTIwLjA0Yy0xLjgyLS45Mi0yLjg2LTEuNDQtMy44OS0xLjk3LTQuMzgtMi4yNC04Ljk5LTQuMTMtMTMuMDktNi44LTEyLjA4LTcuODktMjMuNzYtMTYuMzYtMzQuNTUtMjUuOTYtMTIuMzItMTAuOTUtMjEuNTYtMjQuMDUtMjYuNzEtMzkuODQtMS4wNC0zLjE3LTIuMTgtNi4zMi0zLjEyLTkuNTItNi40MS0yMS43Ni0xMS44Mi00My43OC0xNi45Ny02NS44Ni0xLjIxLTUuMi0zLjA1LTEwLjEtNi4xNi0xNC40NS0zLjA4LTQuMy03LjA2LTcuNzItMTIuMzQtOC4zNi0xMC4yMi0xLjI1LTE5LjczLjUtMjYuNjMgOS4xNC0xMS44MSAxNC44Mi0xNS4zMSAzMS40Ni05LjY5IDQ5LjU4IDUuMzggMTcuMzQgMTYuMjggMzAuOTggMzAuMjMgNDIuMyA0LjE3IDMuMzggOSA1LjUxIDE0LjM0IDYuMjQgMS41Mi4yMSAzLjE4LjE4IDQuNjctLjE4IDguMjItMS45OSAxMi40Mi0xMC4zMyA5LjA3LTE4LjEyLTEuMTgtMi43My0yLjg2LTUuMjQtNC4yMy03LjktLjU1LTEuMDctMS4wMi0yLjIzLTEuMjItMy40MS0uMDktLjUyLjQzLTEuNDEuOTMtMS43LjQ5LS4yOSAxLjQyLS4yNiAxLjk0LjAyLjc0LjQgMS40NiAxLjA1IDEuODcgMS43OCAyLjA3IDMuNjcgNC4yMyA3LjMxIDUuOTggMTEuMTMgMS4zNSAyLjkzIDEuMzEgNi4xMS40NSA5LjMtMS4yIDQuNDEtMy4zNSA4LjE3LTcuMTMgMTAuNzUtMi41MiAxLjcyLTIuOTIgMy43My0xLjc1IDYuMzQuMzYuOC41OSAxLjY1LjkgMi40OCA1LjggMTUuNTkgMTUuMjQgMjguODYgMjYuNDQgNDAuOTQgNC45NiA1LjM0IDExLjAxIDkuMjcgMTcuNjcgMTIuMjMgMy4zNyAxLjUgNi43OSAyLjkxIDEwLjA3IDQuNiA2LjMxIDMuMjQgMTMuMDYgNC4wNyAxOS45NyAzLjgxIDIuNTktLjEgNS4zMi0uNjEgNy42Ny0xLjY0IDQuNjctMi4wNCA5LjM4LTMuODEgMTUuMjgtNC45M3ptMTE4LjQzLTIxOS45NmMtLjMuMDctLjYuMTQtLjkxLjIxLS4xOSAxLjItLjUgMi40LS41NSAzLjYxLS4yNCA1LjI3LS4zOCAxMC41NC0uNiAxNS44MS0uMzUgOC4yNS0uMzYgMTYuNTMtMi40NSAyNC42LTIuMjIgOC41My0zLjQzIDE3LjIyLTMuNjkgMjYuMDEtLjQ3IDE2LjU0LjU3IDMyLjk5IDIuNzMgNDkuNDIgMS42MSAxMi4yMyAxLjU1IDI0LjU1LS45OSAzNi43My0uMTguODYtLjQ0IDEuNzEtLjUzIDIuNTgtLjIgMi4wNi40MSAzLjAzIDIuNDEgMy4zNiAyLjYuNDMgNS4yMi43OSA3Ljg0Ljk0IDguNzcuNTEgMTcuNTUuODQgMjYuMzIgMS4zOSA2LjY2LjQyIDEzLjMxIDEuMDUgMTkuOTYgMS42MiA1LjI1LjQ1IDEwLjUgMS4wMiAxNS43NSAxLjM5IDQuNTUuMzIgNS4zOS0uNzMgMy45My01LjE1LS44OC0yLjY1LTIuMTYtNS4xOS0zLjQyLTcuNjktNS4xMy0xMC4yMS0xMC40NC0yMC4zMy0xNS40NS0zMC42LTkuNDktMTkuNDMtMTguODUtMzguOTEtMjguMTctNTguNDItMS41OC0zLjMyLTIuODQtNi44MS0zLjk5LTEwLjMxLTUuNDUtMTYuNy0xMC44LTMzLjQ0LTE2LjIxLTUwLjE2LS41Ny0xLjgxLTEuMzEtMy41Ny0xLjk4LTUuMzR6bS0xNDUuMzkgMjY0LjRjLjIzLjgxLjQyIDIuMDMuOTIgMy4xMS45NiAyLjA3IDIuMSA0LjA2IDMuMTcgNi4wOCA1LjkxIDExLjIgMTIuMDEgMjIuMyAxNy42NyAzMy42MyA1Ljk3IDExLjk2IDExLjUgMjQuMTQgMTcuMjMgMzYuMjEgMS41MSAzLjE4IDMuMDIgNi4zNiA0LjY1IDkuNDcgMS4xMSAyLjExIDEuODcgMi4xMSAzLjU1LjM3IDQuOTktNS4xOSA5Ljk2LTEwLjQyIDE1LjAyLTE1LjU0IDMuMDktMy4xMyA2LjQzLTYgOS41LTkuMTUgNi4zNi02LjUzIDYuNC00LjMxIDIuNC0xMy4zNi03LjM5LTE2LjczLTE1LjA3LTMzLjM0LTIyLjY1LTQ5Ljk5LTQuNzQtMTAuNC05LjQ5LTIwLjgtMTQuMzEtMzEuMTctMS4xOC0yLjU1LTIuNjMtNC45OC0zLjk1LTcuNDYtLjc1LTEuNDEtMS44OS0xLjg2LTMuNS0xLjczLTQuNDMuMzQtOC42NyAxLjI5LTEyLjc1IDMuMDgtNC4yNiAxLjg3LTcuMzQgNC43My04LjcyIDkuMjgtMi41IDguMjQtNS4wNSAxNi40Ny03LjU3IDI0LjcxLS4yLjY2LS4zNiAxLjM1LS42NiAyLjQ2em0tMzEuNjIgMjYuNjdjMy4zNy4wMyA0LjEzLS42MSA0LjYyLTIuOTkuMTEtLjUyLjExLTEuMDUuMjEtMS41NyAyLjczLTE0Ljg3IDUuNDUtMjkuNzMgOC4xOS00NC42LjYxLTMuMjggMS4zNS02LjU0IDEuOTEtOS44NC41NC0zLjIyLjE5LTMuOS0yLjc1LTUuNDktMS43LS45Mi0zLjQ2LTEuNzQtNS4yNS0yLjQ2LTExLjktNC43Ni0yMS41LTEyLjQ1LTI5LjM4LTIyLjUxLTkuMDYtMTEuNTctMTYuODQtMjMuODUtMjEuNC0zNy45My0uODYtMi42Ny0yLjIzLTQuMDEtNS4xOS00LjIxLTcuMjQtLjUtMTMuNTMtMy41My0xOS4xOC04LS45Ni0uNzYtMS45My0xLjUyLTIuOTctMi4xNy0uMTQtLjA5LS45OS40OC0uOTYuNjMuMjkgMS4zNy42NiAyLjczIDEuMDggNC4wNiAzLjI5IDEwLjQyIDcuNzkgMjAuMzUgMTIuMTkgMzAuMzMgNS42NyAxMi44NyAxMS4yOCAyNS43NyAxNi43NyAzOC43MSA2LjA0IDE0LjI0IDExLjk0IDI4LjU1IDE3Ljg0IDQyLjg1IDIuMSA1LjA4IDUuMDMgOS42NyA4LjExIDE0LjE2IDIuNzMgMy45NyA2LjM4IDcuMDUgMTAuNzQgOS4xMyAyLjAzLjk4IDQuMjcgMS41MSA1LjQyIDEuOXptMTIyLjIxIDY0LjQ4YzUuNjIgMCAxMS4yNS0uMTMgMTYuODYuMDUgMy4wNy4xIDUuMy0uOTYgNy40MS0zLjE0IDUuNjEtNS44MSAxMS40LTExLjQ1IDE3LjEyLTE3LjE2IDYuMS02LjA4IDEyLjI4LTEyLjA3IDE4LjI2LTE4LjI3IDIuNDItMi41MSA0LjU0LTUuMzUgNi40OC04LjI1IDEuNC0yLjA5LjUtMy44NC0yLjAzLTQuMDYtMi43OC0uMjUtNS42Mi0uMjEtOC40MS4wMy01LjYuNDgtMTEuMiAxLTE2Ljc1IDEuODItMTAuOTQgMS42Mi0yMS44NSAzLjQ4LTMyLjc3IDUuMi0yLjM2LjM3LTQuMyAxLjMtNS44NiAzLjE3LTguOSAxMC42OS0xOSAyMC4yMi0yOSAyOS44NS0yLjc4IDIuNjgtNS41MSA1LjQzLTguMTEgOC4yOC0uOTggMS4wNy0yLjg5IDIuMjMtMS44MiAzLjg2LjQ1LjY4IDIuNTguNDUgMy45Mi4zMyAxMS41NC0xLjAzIDIzLjA4LTIuMSAzNC43LTEuNzF6bS0xMzEuMzEtNjIuMDRjLjMyIDEuMTguNCAxLjg2LjY4IDIuNDUgNS4yOCAxMS4zIDEwLjMyIDIyLjczIDE1Ljk0IDMzLjg2IDguOTYgMTcuNzMgMTguNDEgMzUuMiAyNi4yMSA1My40OS4yOC42NS42MSAxLjI2LjkzIDEuODkuNjkgMS4zOCAxLjgyIDIuMjEgMy4zNCAyLjIxIDUuNjIgMCAxMS4yNC0uMDUgMTYuODYtLjE4IDEuMjctLjAzIDEuNzYtMS4wMiAxLjI4LTIuMjktLjQzLTEuMTUtMS0yLjI1LTEuNTMtMy4zNS0zLjU4LTcuNDQtNy4xNC0xNC45LTEwLjc3LTIyLjMyLTcuNzQtMTUuNzktMTUuMTItMzEuNzMtMjEuNjItNDguMDctLjg0LTIuMTItMS42Mi00LjMyLTIuODEtNi4yNC0zLjQ5LTUuNjctOC05LjgtMTUuMzUtOS4wMy0zLjIxLjM0LTYuMy0uMTgtOS4zMy0xLjI1LTEuMTEtLjM5LTIuMjQtLjY5LTMuODMtMS4xN3ptLTE0MC4wNS04OC44NGM0Ljk3LS4zNyA4LjY1LS43OCAxMi4zMy0uOSAxOC44LS42MSAzNy41Ny0xLjcxIDU2LjMtMy40OSA2LjMtLjYgMTIuNTctMS40MiAxOC44NS0yLjE5IDIuNDYtLjMgMy0xLjIzIDIuMDItMy41OC0xLjIzLTIuOTEtMi42NS01Ljc1LTMuOTEtOC42NS0uODctMi4wMS0yLjMtMy00LjUtMi45NC01LjI3LjE1LTEwLjYxLS4yNC0xNS43OS40OC0yMy4xOCAzLjE5LTQ2LjQ5IDQuNzktNjkuODMgNi4wNS0xLjc1LjA5LTMuNTEuMjUtNS4yNS41MS0zLjM1LjUxLTMuNzYgMS4yOS0yLjEgNC4wMyAxLjE4IDEuOTUgMi42OSAzLjcyIDMuNzYgNS43MyAyLjA0IDMuODggNC44NyA1LjkzIDguMTIgNC45NXpNMzg0LjYzIDE0MC4zYzAgLjA4LjAxLjE2LjAxLjI0IDIuMS4xOCA0LjIuNDYgNi4zMS41IDIuOTkuMDUgMy43Ny0uNjYgMy45MS0zLjY4LjE4LTMuNjkuMy03LjM5LjItMTEuMDctLjE5LTcuMDItLjA3LTE0LjAyLTEuMDEtMjEuMDQtLjg2LTYuNDUtMy45NC0xMS4xOS04Ljg0LTE0Ljk2LTIuMjEtMS43LTQuNzItMy4wMi03LjE4LTQuMzctMy4yLTEuNzUtMy40Ni0yLjE0LTIuODItNS44My4xMi0uNjkuNDMtMS4zNy40My0yLjA2LS4wMi0zLjUuMzktNy4xLS4yOS0xMC40OC0xLjMzLTYuNTItNS45My0xMC42OC0xMS43Ny0xMy4yOC0zLjM0LTEuNDktNy4wMi0yLjIyLTEwLjU1LTMuMjctNy4yLTIuMTUtMTMuOTItLjUxLTIwLjM2IDIuNzktNi4wOSAzLjExLTcuNjQgNS45LTYuOTMgMTIuNy40NiA0LjQ0IDEuNjYgOC44IDUuMTQgMTEuODEgNC42OCA0LjA0IDkuOCA3LjM0IDE2LjIyIDguMjMgNC43OS42NiA1Ljk3IDIuMzYgNS4zOSA3LjIzLS43NCA2LjI3LjE5IDEyLjMxIDIuMjYgMTguMTUgMy4zNiA5LjUxIDguNzggMTcuOTggMTQuMDcgMjYuNDkgMS4zOSAyLjI0IDMuMjcgMi43OCA1Ljg0IDIuNDcgMy4yOS0uNDIgNi42NC0uNDEgOS45Ny0uNTd6bS0yMTguNSAyMzYuNTF2LS4xYzEwLjM1LS40MyAyMC43LS44NiAzMS4wNC0xLjI5LjUzLS4wMiAxLjA1LS4xMSAxLjU3LS4xNCAxMS4zOS0uNjQgMjIuNzgtMS4yNyAzNC4xNi0xLjkuODgtLjA1IDEuNzYuMDEgMi42My0uMDYgMS42Ny0uMTMgMi4xNy0uODMgMS41NS0yLjQ2LTEuMjMtMy4yOS0yLjU2LTYuNTQtMy44OS05Ljc5LTEuNzktNC40LTIuODUtNS4wNS03LjUyLTQuNzItOC4wNS41Ni0xNi4xIDEuMjgtMjQuMTYgMS42Mi0xNC4yMS42LTI4LjQzLjkyLTQyLjY0IDEuNDQtNC43My4xOC05LjQ2LjU4LTE0LjE4Ljk3LTEuOTIuMTYtMi40IDEuMjMtMS4zIDIuODggMi41MiAzLjggNS4xNyA3LjUzIDcuNzEgMTEuMzIgMS4wOSAxLjYzIDIuNTggMi4yNSA0LjQ3IDIuMjQgMy41NC0uMDMgNy4wNS0uMDEgMTAuNTYtLjAxem01Ni40IDY0LjZjMCAuMDQgMCAuMDkuMDEuMTMgMTMuMzItLjQ5IDI2LjY1LS45OCAzOS45Ny0xLjQ4IDEuNC0uMDUgMi44Mi0uMDggNC4yLS4yOCAxLjc5LS4yNiAyLjQ4LTEuMzMgMS43Ni0yLjk4LTEuNzYtNC4wMi0zLjY2LTcuOTctNS41My0xMS45My0uNzUtMS42LTIuMDktMi4yOC0zLjgyLTIuMzItMS4wNS0uMDItMi4xLS4xMS0zLjE2LS4xLTExLjkzLjA5LTIzLjg3LjA4LTM1LjguMzQtOC4wNi4xNy0xNi4xMi43Mi0yNC4xOCAxLjEtNC4wMy4xOS04LjA2LjM0LTEyLjA5LjYyLTEuOTUuMTQtMi42MSAxLjItMS43MSAyLjgzIDIuMzkgNC4yOSA0LjkgOC41MiA3LjM5IDEyLjc0Ljc2IDEuMyAxLjk0IDEuOTggMy40OSAxLjk2IDEuNC0uMDEgMi44MS4wNSA0LjIxLjAxIDguNDMtLjIgMTYuODUtLjQzIDI1LjI2LS42NHptLTQuMjktMTA2LjU3Yy0yLjM3LjItMy45Ni4yNS01LjUyLjQ3LTIyLjg0IDMuMTktNDUuODMgNC40Mi02OC44NiA1LjA5LTQuMzkuMTMtOC43Ny41LTEzLjE1Ljg3LTEuNjQuMTQtMi4yMyAxLjI2LTEuMzMgMi41NiAyLjYxIDMuNzUgNS4zMyA3LjQyIDguMDQgMTEuMS45NiAxLjMgMi4zIDEuMjQgMy44IDEuMTYgMTQuNzQtLjg2IDI5LjQ4LTEuNjggNDQuMjItMi40NCAxMC41My0uNTQgMjEuMDctLjk1IDMxLjYxLTEuNDcgMi44MS0uMTQgNS42MS0uNDUgOC40MS0uNzMgMy4xMy0uMzEgMy41Ni0uOTkgMi4zNS00LjEzLTEuMDctMi43OS0yLjQ1LTUuNDYtMy40Ny04LjI3LTEuMjEtMy4zNi0zLjM3LTQuODEtNi4xLTQuMjF6bS0xMi40NCA2Ni43MWgtMzEuODdjLTEuNCAwLTIuODEtLjA0LTQuMjEuMDYtMS4yNy4wOS0xLjgyIDEuMDktMS4xNyAyLjI1IDEuNTUgMi43NSAzLjIzIDUuNDMgNC44MiA4LjE2IDQuODEgOC4yNCA0LjgxIDguMjMgMTQuMzkgNy42OCA5LjgxLS41NyAxOS42Mi0xLjM1IDI5LjQ0LTEuNjMgMTIuMjctLjM2IDI0LjU1LTEuNDcgMzYuODQtLjQ2Ljg3LjA3IDEuNzYuMDQgMi42My0uMDEgMS43Ny0uMTEgMi4xOS0uODcgMS4zLTIuMzgtMi4xMy0zLjYzLTQuMzctNy4yLTYuMzgtMTAuODktMS4wMy0xLjktMi4zOS0yLjkzLTQuNTMtMy4wMy00LjU1LS4yMS05LjEtLjY4LTEzLjY0LS42Mi04Ljk0LjEyLTE3Ljg4LjU0LTI3LjYyLjg3em0tLjQ3LTQuNzd2LS40N2MxMi40Ny0uMTcgMjQuOTQtLjMzIDM3LjQxLS41LjE4IDAgLjM1IDAgLjUzLS4wMSAzLjI0LS4yIDMuODYtLjk5IDIuNzEtMy44OS0xLjI5LTMuMjYtMi44OS02LjQxLTQuMTUtOS42OS0xLjExLTIuOS0zLjE1LTQuMDctNi4xLTQtNS42Mi4xMy0xMS4yNS4xMS0xNi44Ni40LTE1LjYxLjgxLTMxLjIxIDEuNzgtNDYuODIgMi42LTQuNTYuMjQtOS4xNC4xNi0xMy43LjMyLTEuNjIuMDYtMi4yNSAxLjEtMS41IDIuNSAxLjU4IDIuOTQgMy40NiA1LjcyIDQuOTIgOC43MSAxLjUzIDMuMSAzLjc5IDQuMTIgNy4xOSA0LjA3IDEyLjEyLS4xNCAyNC4yNS0uMDQgMzYuMzctLjA0em02MC43NCAxMDguMzJjMCAuMDguMDEuMTYuMDEuMjMgNS45Ny0uMjUgMTEuOTMtLjU0IDE3LjktLjcyIDQuNzQtLjE0IDkuNDktLjEyIDE0LjIzLS4yMyAyLjY3LS4wNiAzLjA5LS43MiAxLjktMy4yNS0xLjc4LTMuODItMy44My03LjUyLTUuNTItMTEuMzgtMS4zMi0yLjk5LTMuMzktNC4zLTYuNi0zLjk5LTEuMDUuMS0yLjExLjAxLTMuMTYuMDYtNy45LjMzLTE1Ljc5Ljg3LTIzLjY5Ljk3LTEyLjk5LjE1LTI1Ljk4IDEuNDEtMzguOTguMzctLjM1LS4wMy0uNy0uMDItMS4wNSAwLTIuMjkuMTEtMi44NCAxLjAyLTEuNTcgMy4wMyAyLjUzIDQuMDEgNS4zNyA3LjgzIDcuNzYgMTEuOTMgMi4wNiAzLjUzIDQuODIgNC41MSA4Ljc2IDQuMjYgOS45OS0uNjEgMjAtLjg3IDMwLjAxLTEuMjh6bS00OC45Mi00MS43NWM3LjkyLS4zNSAxNS4yOS0uNyAyMi42NS0xIDExLjIyLS40NiAyMi40NS0uODkgMzMuNjctMS4zNSAxLjA0LS4wNCAyLjEtLjE1IDMuMTEtLjQxIDEuNC0uMzYgMS45OC0xLjQzIDEuMzctMi44NC0xLjYtMy43LTMuMzItNy4zNi01LjA0LTExLS42Ni0xLjQtMS44OS0yLjAzLTMuNDItMi4wMy0xLjIzIDAtMi40Ni4wMS0zLjY4LjA3LTUuMjYuMjgtMTAuNTEuNzItMTUuNzcuODUtMTcuMDIuNDItMzQuMDQuNzItNTEuMDYgMS4wNy0uNy4wMS0xLjQxIDAtMi4xLjA2LTIuMjUuMjEtMi42OCAxLjA0LTEuNDcgMy4xNCAyLjAyIDMuNDkgNC4yMiA2Ljg5IDYuMTcgMTAuNDEgMS4yNyAyLjI5IDMuMDggMy4xMiA1LjYxIDMuMDUgMy40OS0uMSA3LS4wMiA5Ljk2LS4wMnptMjIuMjcgMTkuMDV2LjE4YzE0Ljc0LS41MSAyOS40OS0xLjAyIDQ0LjIzLTEuNTMuODgtLjAzIDEuNzYtLjAzIDIuNjItLjE4IDIuMTItLjM2IDIuNzctMS40IDEuOTYtMy4zLS43Ni0xLjc3LTEuNjgtMy40OC0yLjU5LTUuMTgtMy44NS03LjI3LTMuMDgtNi44NS0xMS4yOC02LjUzLTE4LjQzLjctMzYuODUgMS40LTU1LjI4IDIuMDctMy4zMy4xMi02LjY4LjA2LTEwLjAxLjE4LTEuNjMuMDYtMi4yMSAxLjA1LTEuNCAyLjQ5IDEuNjMgMi45MSAzLjQ0IDUuNzIgNS4wOSA4LjYyIDEuMjUgMi4yIDMuMDQgMy4yMyA1LjU4IDMuMjEgNy4wMy0uMDYgMTQuMDYtLjAzIDIxLjA4LS4wM3ptMjUuNjMgMjcuOTZjLTMuNjkgMC0xMC41Ni0uMDctMTcuNDEuMDMtMy42OS4wNi03LjM4LjM2LTExLjA2LjY2LTEuNjkuMTQtMi4xNyAxLjEzLTEuMTYgMi41NCAyLjk4IDQuMTQgNi4wNiA4LjIgOS4xMyAxMi4yOCAxLjMyIDEuNzUgMy4yMiAxLjk3IDUuMjIgMS44NSA0LjkxLS4zIDkuODItLjcgMTQuNzQtLjg5IDEzLjE4LS41MiAyNi4zNi0uOTUgMzkuNTQtMS40MyAxLjIzLS4wNCAyLjQ3LS4wNiAzLjY3LS4yOSAxLjc2LS4zMyAyLjMtMS4yOSAxLjY0LTMuMS0uNzctMi4xNS0xLjk4LTQuMTYtMi41Ni02LjM1LTEuMzQtNS4wNy01LTYuMzYtOS41OS02LjM4LTEuMDYtLjAxLTIuMTEtLjAyLTMuMTcuMDItOC41OS4zLTE3LjIuNjItMjguOTkgMS4wNnptMjQwLjY2LTE5My41OGMuMDEtLjA1LjAxLS4xLjAyLS4xNCAzLjE3IDAgNi4zMy4wNCA5LjUtLjAzIDEuMDMtLjAyIDIuMzYuMDEgMy4wMi0uNTkgMi42Ny0yLjQyIDMuOTktNS43IDQuOTUtOS4wOS4xMi0uNDItLjU1LTEuMjktMS4wNy0xLjYtLjcyLS40NC0xLjYyLS42OC0yLjQ4LS43OS0zLjE0LS40LTYuMjgtLjgyLTkuNDQtMS4wNS0xOS42My0xLjQ2LTM5LjI3LTIuODgtNTguOS00LjMtLjg4LS4wNi0xLjc2LS4wNy0yLjY0LS4wOS0xLjU2LS4wMy0yLjc2LjYyLTMuNDIgMi4wMi0xLjUgMy4xOC0yLjk5IDYuMzctNC4zOCA5LjYtLjYzIDEuNDYtLjAzIDIuMzQgMS42NCAyLjQ0IDMuNTEuMjIgNy4wMy4yOCAxMC41NC40OCAxNy41NiAxLjAzIDM1LjExIDIuMDkgNTIuNjYgMy4xNHptLTkuNiAxNy43M2MwLS4wNC4wMS0uMDguMDEtLjEyIDEuNCAwIDIuODEuMDEgNC4yMSAwIDkuOTEtLjA2IDcuODEgMS4xNCAxMi44LTcuNjYgMi42LTQuNTggMi4xNi01LjE3LTMuMTctNS4zNS0xMi4xLS40Mi0yNC4xOS0uNzktMzYuMjgtMS4zOC02LjY1LS4zMi0xMy4yOC0xLjE0LTE5LjkyLTEuNTctNC41NS0uMy05LjExLS4zNC0xMy42Ny0uNDUtMS40My0uMDMtMi45MS4xNi0zLjcxIDEuNTMtMS44NiAzLjE3LTMuNjcgNi4zOC01LjM4IDkuNjMtLjQ3Ljg5LjAyIDEuNzcgMS4wNSAxLjk5IDEuMDIuMjIgMi4wOC4yNyAzLjEzLjMzIDcuNy40MiAxNS40MS44NCAyMy4xMiAxLjIzIDEyLjU5LjYxIDI1LjIgMS4yMSAzNy44MSAxLjgyem0tNjAuMDggMS43OGMwLS4wNiAwLS4xMi4wMS0uMTctLjcgMC0xLjQtLjAxLTIuMTEgMC0yLjcuMDUtMy4zNC43NS0yLjQ2IDMuMDggMS4zIDMuNDUgMi43OSA2LjgyIDQuMiAxMC4yMi44NCAyLjAyIDIuMzYgMi45MSA0LjU2IDIuODQgMi40Ni0uMDggNC45MS0uMDQgNy4zNy0uMDggMTQuNzQtLjI4IDI5LjQ4LS42IDQ0LjIxLS44NiA4Ljg2LS4xNiA3LjI2IDEuMzYgMTEuODgtNy4zMiAyLjE4LTQuMSAxLjgxLTQuNTktMy00Ljc2LTExLjA1LS4zOC0yMi4xLS42NC0zMy4xNC0xLjE0LTEwLjUxLS40Ny0yMS4wMS0xLjE5LTMxLjUyLTEuODF6TTIyOS42MyA3OS43MmMuMSAxLjI2LjEyIDEuNzkuMTkgMi4zMSAxLjM5IDEwLjQyIDIuNzIgMjAuODUgNC4yMSAzMS4yNS43IDQuODUgMS42MiA5LjY4IDIuNjIgMTQuNDguNTMgMi41NCAxLjQgMy4wOCAzLjY1IDIuNTUgNC4wOS0uOTcgOC4xNy0yLjAyIDEyLjIxLTMuMTcgMi4yOC0uNjUgMi41OC0xLjM3IDIuMTEtMy45Ni0uMTYtLjg2LS4zOS0xLjcxLS41Ni0yLjU3LTEuNzItOC43Ni0zLjUyLTE3LjUxLTUuMTEtMjYuMy0uOS01LTEuNDMtMTAuMDYtMi4xMy0xNS4wOS0uNjUtNC42OC0xLjc4LTUuNjUtNi4xNC00LjcyLTIuOTEuNjItNS43NCAxLjU3LTguNiAyLjQtMS43NS41Mi0yLjU4IDEuNzEtMi40NSAyLjgyem0tNi43OS0uOTVjLTQuNjggMS4xOS05LjEgMi4yNi0xMy40OSAzLjQ2LTEuMzUuMzctMS44NCAxLjU1LTEuNzEgMi45Mi4wOC44Ny4yNyAxLjczLjQxIDIuNiAxLjYyIDkuNTEgMy4yMyAxOS4wMiA0Ljg2IDI4LjUzIDEuMDEgNS44OCAxLjk5IDExLjc2IDMuMDYgMTcuNjMuNDkgMi42NyAxLjQyIDMuMjMgNC4wNSAyLjQ5IDMuMDQtLjg2IDYuMDYtMS43OCA5LjA0LTIuODIgMi43LS45NCAzLjA2LTEuNjMgMi43NC00LjI3LS4wNi0uNTItLjIyLTEuMDMtLjMxLTEuNTUtMi40OS0xNC4xNy00LjUyLTI4LjQxLTUuODYtNDIuNzQtLjEzLTEuMzktLjMyLTIuODMtLjgxLTQuMTItLjI5LS43OS0xLjIyLTEuMzUtMS45OC0yLjEzem0yMjYuNTggNDE4LjI2YzguNzkgMCAxNS40OC4wMSAyMi4xNy0uMDEgMS4wNSAwIDIuMi4xNiAzLjEzLS4yLjg3LS4zMyAyLjAyLTEuMTUgMi4xOC0xLjkzLjkxLTQuNDcgMS42My04Ljk4IDIuMjUtMTMuNTEuMjItMS42MS0uNzgtMi42NC0yLjQ1LTIuODMtLjctLjA4LTEuNC0uMTUtMi4xLS4xNC0xMS43OC4yOC0yMy41Ni41OC0zNS4zNC44Ni0xLjgyLjA0LTMuNDcuNDktNC43OSAxLjgzLTIuNTkgMi42My01LjMyIDUuMTUtNy43MyA3Ljk0LTEuMTggMS4zNy0yLjk0IDIuODUtMi4yIDUuMDUuODggMi42MiAzLjM2IDIuMjQgNS4zOCAyLjMzIDcuMTkuMzIgMTQuNC40NyAxOS41LjYxek0zMDAuMTcgMzYyLjNjLTEuNDYtLjA2LTEuNy4xLTIuMzkgMi4zNy0uMi42Ny0uMzYgMS4zNi0uNDkgMi4wNS0yLjQzIDEyLjk0LTQuODggMjUuODgtNy4yNSAzOC44My0uODIgNC40OS0xLjQxIDkuMDItMi4xNSAxMy41Mi0uMzggMi4zMi4xIDQuMDMgMi40NiA1LjE2IDIuMDQuOTggMy44NiAyLjQxIDUuOCAzLjYyIDEuNTYuOTcgMi4yOS43MiAyLjU4LTEuMDIuNTgtMy40Ni45NS02Ljk2IDEuNi0xMC40MSAyLjk3LTE1LjcyIDYuOTctMzEuMTggMTIuMTctNDYuMzEuNDYtMS4zMy45NC0yLjY1IDEuMjgtNC4wMS40LTEuNi0uMTEtMi40NC0xLjY1LTIuNjItMy42NS0uNDItNy4zMi0uNzMtMTEuOTYtMS4xOHpNMjYxLjE5IDY5Ljg3Yy0xLjU1LjQyLTQuNDYgMS4wNi03LjI2IDIuMDMtMi45OSAxLjAzLTMuMzcgMS42Ni0yLjg5IDQuNjUgMS44MyAxMS4yNiAzLjY4IDIyLjUxIDUuNjggMzMuNzQuNzQgNC4xNCAxLjgzIDguMjIgMi45MyAxMi4yOS41OCAyLjEzIDEuMjggMi41MSAzLjQ5IDIuMTMgMy4xMS0uNTMgNi4yMi0xLjE0IDkuMjgtMS44OSAyLjIyLS41NCAyLjU3LTEuMiAyLjIyLTMuMzktLjE5LTEuMjEtLjU1LTIuMzktLjgxLTMuNTktLjkxLTQuMjktMi04LjU1LTIuNjctMTIuODgtMS41LTkuNzEtMi43Ny0xOS40Ni00LjE1LTI5LjE5LS40NS0zLjIxLTEuMjYtMy45MS01LjgyLTMuOXptMTguNDgtMy4xOGMtLjQxLjA1LTIuMDEuMDgtMy41NC40My0zLjY3Ljg1LTQuNTEgMi4wNS00LjQxIDUuOTUuMDMgMS4wNS4wNiAyLjEyLjIyIDMuMTUgMS40NSA5LjU1IDIuODEgMTkuMTIgNC40NCAyOC42My43NyA0LjQ5IDIgOC45MSAzLjE3IDEzLjMyLjU5IDIuMjEgMS4zMiAyLjY2IDMuNDEgMi4zMyAyLjk1LS40NyA1Ljg5LTEuMDMgOC43OC0xLjc0IDIuMjMtLjU1IDIuOC0xLjY0IDIuNDItMy45MS0uMTItLjY5LS4zNy0xLjM2LS41NC0yLjA0LTIuNS0xMC4wNy00LjU1LTIwLjIyLTUuODUtMzAuNTEtLjUzLTQuMTgtMS4xMi04LjM2LTEuNzQtMTIuNTItLjQ4LTMuMTctLjc0LTMuMzMtNC4xNC0zLjIyLS4zNS4wMS0uNy4wNC0yLjIyLjEzem02LjQzIDQ2NC4xMWMwIC4yMi4wMS40NC4wMS42Ni01Ljk4IDAtMTEuOTYtLjExLTE3Ljk0LjAzLTUuMDkuMTItMTAuMTguNTEtMTUuMjcuODUtLjYxLjA0LTEuMTkuNTItMS43OC43OS4yOS42MS40NSAxLjMzLjg5IDEuOCAyLjAyIDIuMjEgNC4xOSA0LjI4IDYuMTUgNi41MiAyLjg5IDMuMzEgNi4zNiA0LjUyIDEwLjc4IDQuMTMgMTQuNzEtMS4yOCAyOS40NC0yLjI4IDQ0LjE2LTMuNDcgNC4wMy0uMzIgNC40LTEuMTIgMi44My01LjA4LS4zMi0uODItLjc4LTEuNTktMS4wMS0yLjQzLTEuMDQtMy43Mi0zLjU2LTQuNzItNy4yLTQuNTMtNy4yLjQtMTQuNDEuNTEtMjEuNjIuNzN6bTE4Mi4zLTE1OS40M2MwLS4wNi4wMS0uMTMuMDEtLjE5IDYuNDguMjkgMTIuOTcuNTkgMTkuNDUuODggMy4yNi4xNCAzLjU2LS4wOCA0LjMzLTMuMTcuNjctMi43MSAxLjA5LTUuNDIgMi41Ny03Ljk1IDIuNTUtNC4zNCAyLjA1LTUuMDUtMi45NC00Ljk3LTE1LjQ0LjI1LTMwLjg3LjU2LTQ2LjMxLjg1LS44OC4wMi0xLjc2LjA2LTIuNjMuMTctMi42Mi4zMy0zLjE1IDEuMDUtMi4zIDMuNC43OCAyLjE0IDEuNzkgNC4xOSAyLjYgNi4zMy45MyAyLjQ3IDIuNjggMy40MyA1LjI2IDMuNTUgNi42Ni4yOSAxMy4zMS43MiAxOS45NiAxLjF6TTI5Ni43OCA2NC42N2MtNS4yMS0uMi02LjMyLjkyLTUuOTIgNS45Ny4wMy4zNS4wNS43LjEgMS4wNSAxLjg0IDEzLjQgNC4wMyAyNi43MyA3LjM4IDM5Ljg1LjMgMS4xOS41MiAyLjQ3IDEuMTUgMy40OC40OS43OCAxLjYxIDEuNzggMi4zNSAxLjcgMy42NC0uMzkgNy4yNy0xLjAzIDEwLjg3LTEuNzYgMS40Mi0uMjkgMS44Mi0xLjU1IDEuNTctMi45MS0uMTYtLjg2LS40MS0xLjctLjY1LTIuNTUtMy43OS0xMy41Mi03LjU3LTI3LjA1LTExLjM4LTQwLjU2LTEuMTEtMy44Ny0xLjUtNC4xMS01LjQ3LTQuMjd6TTQ1MS40IDUwMS45MXYtLjIzYy0xLjA1IDAtMi4xMS4wMy0zLjE2IDAtNi4xNC0uMTgtMTIuMjctLjQtMTguNDEtLjU1LTItLjA1LTIuNjMuNjktMi4wNiAyLjU3Ljk2IDMuMTkgMi4xNyA2LjMgMy4xIDkuNS44IDIuNzUgMi40IDMuOTYgNS4zMyAzLjk5IDExLjU4LjExIDIzLjE2LjM2IDM0LjczLjU3IDEuNjIuMDMgMi43My0uNjEgMy0yLjI4LjU4LTMuNjQgMS4xNy03LjI3IDEuNjUtMTAuOTIuMTktMS40NC0uNjUtMi4zNi0yLjA4LTIuNTItMS4yMi0uMTQtMi40NS0uMTItMy42OC0uMTMtNi4xNC0uMDEtMTIuMjggMC0xOC40MiAwek0zMTQuOTcgNjMuOTdjLTEuNTMgMC0yLjQxLjAxLTMuMjkgMC00Ljg4LS4wNy01LjU3Ljc5LTQuNDQgNS41My4yNCAxLjAyLjQ5IDIuMDUuNzggMy4wNiAzLjIzIDExLjEyIDYuNDYgMjIuMjMgOS42OSAzMy4zNC41NCAxLjg1IDEuMDggMy43IDEuNjQgNS41NS40NyAxLjU2IDEuNTcgMi4zMyAzLjE1IDIuMjIgMS45Mi0uMTMgMy44My0uNDEgNS43NC0uNjEgMy4xOS0uMzMgMy43Ni0uNzEgMi45OC00LjUzLS40Mi0yLjA2LTEuMDUtNC4wNy0xLjU2LTYuMTItMi43Mi0xMC44OS01LjU0LTIxLjc1LTguMS0zMi42Ny0xLjM4LTUuOTYtMS4xOC02LjAxLTYuNTktNS43N3ptMTU4LjYgMzI3LjMzYzQuMzkgMCA4Ljc4LjA4IDEzLjE3LS4wMyAzLjAyLS4wOCAzLjcxLS43OSA0LjA3LTMuNjcuMjktMi4yNi41Mi00LjU0LjY5LTYuODEuMTktMi42My0uNTItMy40OS0zLjE5LTMuNjktNC43My0uMzQtOS40Ni0uNTgtMTQuMTktLjg1LTUuNDMtLjMxLTEwLjg3LS42Mi0xNi4zLS44OS0yLjYzLS4xMy01LjI2LS4yNi03LjktLjI1LTEuNzEgMC0yLjQ3Ljg4LTEuOTEgMi4yOCAxLjUgMy43NSAzLjA0IDcuNDggNC44NCAxMS4wOS40Ni45MiAxLjgxIDEuNjUgMi45IDEuOTUgMS44NC41MSAzLjguNzggNS43MS44MyA0LjAyLjEzIDguMDcuMDUgMTIuMTEuMDR6bS0yMC4yOSAxMzAuODV2LS4yNGMtNS4yNyAwLTEwLjU0LS4wNy0xNS44MS4wMy0yLjQ5LjA0LTMuMTQuOTgtMi40IDMuMzggMS4wMyAzLjM2IDIuMjggNi42NSAzLjM0IDkuOTkuNjggMi4xNSAyLjEyIDMuMDkgNC4zIDMuMTcgNC4zOS4xNiA4Ljc4LjUyIDEzLjE2LjUxIDMuODYtLjAyIDcuNzEtLjQ1IDExLjU3LS42NyAyLjA4LS4xMiAzLjItMS4xOCAzLjUtMy4yMi40Mi0yLjc4Ljk1LTUuNTQgMS4zMS04LjMyLjQxLTMuMTMtLjExLTMuODMtMy4xOS0zLjk5LTUuMjUtLjI5LTEwLjUxLS40NC0xNS43OC0uNjR6bS04My40OCAxLjEydi0uOTJjNS45NiAwIDExLjkyLjA0IDE3Ljg4LS4wMyAxLjM3LS4wMSAzLjI3LjEyIDQuMDItLjY3IDIuNjEtMi43NyA0Ljg1LTUuOSA3LjEzLTguOTcuMTMtLjE3LS41MS0xLjI2LS45OC0xLjQ0LS43OC0uMy0xLjcxLS4yNy0yLjU5LS4zLTcuMDEtLjIxLTE0LjAyLS4zOS0yMS4wMi0uNTgtMy41MS0uMS03LjAxLS4xNi0xMC41Mi0uMjktMi0uMDgtMy44Ni4yMS01LjM0IDEuNy0yLjk3IDIuOTgtNS45NCA1Ljk2LTguODQgOS0uMzkuNDItLjY5IDEuNDEtLjQ2IDEuODEuMjUuNDMgMS4xNy42NiAxLjc5LjY3IDYuMzEuMDQgMTIuNjIuMDIgMTguOTMuMDJ6bTY0Ljc0IDE3LjI4Yy0uNTQtMS45Mi0uODctMy4yOS0xLjMxLTQuNjMtNC4xMS0xMi41My04LjIzLTI1LjA2LTEyLjM3LTM3LjU4LS4zMy0xLS43NC0xLjk4LTEuMTktMi45My0uNTYtMS4xOC0xLjQzLTIuMDItMi44Mi0yLjA2LTEuNC0uMDQtMi44Mi0uMTMtNC4yMSAwLTEuOTYuMTktMi42NiAxLjI5LTIuMTggMy4zNC4zMiAxLjM3Ljc5IDIuNyAxLjI1IDQuMDMgNC43IDEzLjgyIDkuNDkgMjcuNjEgMTQuMDggNDEuNDcgMi4wOSA2LjMxIDIuOTggNC45OSA2LjcgMS4zOC45My0uOTEgMS41MS0yLjIgMi4wNS0zLjAyem0yNi43Ny02Ni44OWM0LjIyIDAgOS40OS4wNiAxNC43NS0uMDIgMy43Ny0uMDYgNC41Ni0uOCA1LjA2LTQuNTQuMzItMi40My41LTQuODkuNjUtNy4zNC4xNS0yLjUzLS40LTMuMzMtMi44Ni0zLjMzLTYuMTQgMC0xMi4yOC4xNy0xOC40Mi4zNS0yLjM3LjA3LTQuMzYgMS4wNi02LjA1IDIuNzktMy40NCAzLjUxLTcgNi45LTEwLjQ0IDEwLjQxLS41Mi41My0uNjUgMS40NC0uOTcgMi4xOC42Ni4xNCAxLjMzLjQzIDEuOTkuNCA1LjA4LS4yNSAxMC4xNi0uNTYgMTYuMjktLjl6bS00MS4zNS0zNzcuOGguMTVjMC0zLjUxLS4wNC03LjAzLjAxLTEwLjU0LjEyLTguMzQuODctNy42OC03Ljk4LTkuODctNS4wMi0xLjI0LTUuNjUtLjc1LTUuNDYgNC4zLjM0IDguOTUuODUgMTcuODkgMS4zIDI2LjgzLjMxIDYuMTMuOSA2LjcxIDcuMTMgNy4xMS4zNS4wMi43LjA1IDEuMDUuMDUgMy4wMS4wMyAzLjc2LS42NSAzLjc5LTMuNjUuMDMtNC43Ni4wMS05LjUuMDEtMTQuMjN6bS0zOS4yMSA0MDkuNTh2LjRjNC4zOCAwIDguNzYuMDMgMTMuMTQtLjAyIDEuMzktLjAxIDIuODEtLjEyIDQuMTctLjM5IDEuMTktLjI0IDEuODEtMS4xNyAxLjM2LTIuMzctLjg3LTIuMjktMS44Ny00LjUzLTIuODQtNi43OC0uOTQtMi4xNy0yLjU2LTMuMS00Ljk3LTMuMTEtOS4yOC0uMDEtMTguNTQtLjU4LTI3LjgxLjgyLTQuODMuNzMtNS40MS42NC0zLjkzIDUuODUuMTQuNTEuMzYuOTkuNDYgMS41MS41NyAyLjgzIDIuMjUgNC4xNSA1LjE4IDQuMTEgNS4wOS0uMDcgMTAuMTctLjAyIDE1LjI0LS4wMnptNDQuMTgtNDA5LjEyaC0uMjVjMCAzLjM0LS4wMSA2LjY4IDAgMTAuMDIuMDEgMS41OC0uMDMgMy4xNy4wOSA0Ljc0LjIyIDMgLjcxIDMuNjUgMy42MSA0LjMzIDEuNy40IDMuNDcuNTcgNS4yMS42OSAyLjYyLjE4IDMuMDktLjI2IDMuMjMtMi44Ni4wOS0xLjc1IDAtMy41Mi4wNy01LjI3LjI5LTcuMDIuNTctMTQuMDUuOTItMjEuMDYuMjMtNC42OS0uMTQtNS4yNS00LjktNi40MS0xLjUzLS4zNy0zLjA3LS43OC00LjYzLTEuMDEtMi42NC0uNC0zLjI4LjA1LTMuMzMgMi42MS0uMDkgNC43My0uMDIgOS40Ny0uMDIgMTQuMjJ6bTUwLjMzIDMxMy4zM3YuNjRjMy4zNCAwIDYuNjguMDkgMTAuMDItLjAzIDIuNDUtLjA5IDMuMS0uNjkgMy41Mi0zLjA2LjQzLTIuNDIuNzctNC44Ny44Ny03LjMyLjExLTIuNzEtLjY0LTMuNTItMy4zNC0zLjYtOS40OC0uMjktMTguOTctLjQ4LTI4LjQ2LS42OC0xLjE2LS4wMi0xLjU4Ljg0LTEuMTggMS43NyAxLjMxIDMuMDcgMi43OCA2LjA3IDQuMTIgOS4xMi45NyAyLjIxIDIuNjQgMy4xNyA0Ljk4IDMuMTYgMy4xNS0uMDEgNi4zMSAwIDkuNDcgMHptLTcyLjQ0LTMwMi4zN2MtLjMtOS4yNy0uNTgtMTguNTQtLjktMjcuODEtLjA0LTEuMjItLjI2LTIuNDQtLjQxLTMuNjUtLjIzLTEuOTQtMS4yMy0zLjItMy4xOS0zLjY4LTIuODktLjcxLTUuNzUtMS41Mi04LjY1LTIuMjEtMS43Ni0uNDItMi4yOS4wMy0yLjExIDEuODMuNDggNC44OC45NSA5Ljc2IDEuNjYgMTQuNjEuMTkgMS4yOC45OCAyLjY2IDEuODkgMy42MiA0Ljc1IDUuMDUgNy4xMyAxMS4xMyA4LjI4IDE3Ljg0LjI3IDEuNTgtLjM3IDMuNzkgMi4wNyA0LjI2IDEuMzEuMjQgMS43Ni0xLjEyIDEuMzYtNC44MXptMjQuMzYgODEuODN2LjI4Yy0xLjkyIDAtMy44NS0uMDktNS43Ny4wMi0zLjM4LjItNi40MSAxLjQ0LTkuMzQgMy4xNy0zLjcyIDIuMi00LjY3IDUuNDEtNC4xNSA5LjM3LjE4IDEuMzYgMSAxLjc0IDIuMjQgMS4zMy41LS4xNi45Ny0uNCAxLjQ2LS41OSA1LjgtMi4yNyAxMS43OS0zLjE4IDE4LjAxLTIuNSAzLjEzLjM0IDYuMjcuNjkgOS40MS44Ljg4LjAzIDIuMTUtLjUgMi42My0xLjIgMS4zMy0xLjk0LjY1LTUuNjctMS4xMS03LjIxLTIuMDMtMS43OC00LjQyLTIuODUtNy4xLTMuMTItMi4wOC0uMjEtNC4xOC0uMjQtNi4yOC0uMzV6bTQ4LjMgMjI2LjAxdi0uMjVjLTIuNjEtLjE3LTUuMjItLjQtNy44NC0uNDctMS42LS4wNS0yLjEzLjk0LTEuNDYgMi40My4wNy4xNi4xMy4zMy4yMy40NyAzLjQ0IDQuNDYgNi4yOCA5LjE1IDYuOCAxNC45Ny4xNCAxLjYgMS41IDIuMzUgMy4wNyAyLjIgMi4yNi0uMjEgNC41LS41NSA2Ljc1LS44NyAxLjM1LS4xOSAyLjI5LS45OCAyLjUxLTIuMzEuNzItNC4zIDEuNS04LjYgMS45OC0xMi45Mi4yNS0yLjIzLS42OC0zLjA4LTMuMTQtMy4yMS0yLjk2LS4xNi01LjkzLS4wNC04LjktLjA0ek0zMzAuMjQgODUuM2MuMzkgMS42Ny43NCAzLjM1IDEuMTggNSAxLjMxIDQuOSAyLjY1IDkuNzkgMy45OCAxNC42OSAxLjgyIDYuNzEuNzUgNy4yNiA5LjY3IDYuNzguNTItLjAzIDEuMDUtLjA5IDEuNTctLjE0IDEuNTItLjE1IDIuMDktMS4wMiAxLjk2LTIuNS0uNDMtNC44Ny0uODYtOS43NC0xLjIzLTE0LjYxLS4xMy0xLjc4LS44My0zLjAxLTIuNjYtMy4zOS01LjAzLTEuMDUtOS41NS0zLjI1LTE0LjQ3LTUuODN6bTk4LjYxIDEzMC4yNmMzLjE5LS41NyA2Ljg1LS45NyAxMC4zNS0xLjkzIDIuNjEtLjcyIDMuNDQtMi45NCAyLjcyLTUuNDItLjYtMi4wNi0yLjMzLTIuNjMtNC4wOS0yLjctNS41OS0uMjQtMTEuMTktLjQ5LTE2Ljc4LS4zNi0zLjcyLjA5LTcuMTUgMS42Mi0xMC4zNyAzLjQ3LS42LjM0LTEuMTkuNzUtMS42NiAxLjI2LS43Mi43OS0uNjIgMS43My4yOSAyLjIxIDEuMDYuNTYgMi4yNCAxLjAyIDMuNDIgMS4yMSA1LjE5Ljc4IDEwLjM5IDEuNDYgMTYuMTIgMi4yNnptLTI5LjUzIDY5LjMzYzMuNDUgMCA3LjEyLjAyIDEwLjgtLjAxIDEuMDQtLjAxIDIuMTItLjA0IDMuMTItLjMgMS42Ny0uNDQgMi4zOC0yLjI0IDEuNTctMy43OC0xLjI0LTIuMzgtMy4yOS0zLjYxLTUuOTItMy43NC0yLjgtLjEzLTUuNi0uMTYtOC40LS4xLTYuMDQuMTMtMTEuNzcgMS42LTE3LjE5IDQuMjQtMy45MyAxLjkyLTcuODIgMy45My0xMS42OSA1Ljk2LTEuNjYuODctMy4yMiAxLjkxLTQuODMgMi44NyAyLjEzIDEuMzEgMy44Mi0uNCA1LjctLjgzLjM0LS4wOC42Ny0uMjEuOTktLjM0IDguMi0zLjU0IDE2LjY4LTUuMjQgMjUuODUtMy45N3ptNTAuNDUtMTgzLjc5aC4yMlY4Ny40NmMwLTMuNDItMi4zMi00LjI4LTUuMzEtNC4zMy0uNTQtLjAxLTEuMzIuNTgtMS41OSAxLjEtLjM4Ljc0LS41MSAxLjY4LS41NCAyLjU0LS4yOCA4LjkxLS42IDE3LjgzLS43MiAyNi43NC0uMDUgMy4zNCAxLjY0IDQuNTkgNS4wMSA0LjM4IDEuNDItLjA5IDIuMzUtLjc4IDIuNjEtMi4xMi4yNC0xLjE5LjMtMi40My4zMS0zLjY1LjAzLTMuNjcuMDEtNy4zNC4wMS0xMS4wMnpNMjczLjgyIDU1MC4xN2MtLjA0LjI5LS4wNy41OC0uMTEuODcuOTguMyAxLjkzLjcxIDIuOTMuODggMi40Mi40MSA0Ljg1Ljg4IDcuMjkgMS4wMyA2LjMxLjM5IDEyLjY0LjcyIDE4Ljk2Ljg3IDUuMjcuMTMgMTAuNTUuMDggMTUuODItLjA4IDIuNS0uMDggMy43My0xLjA2IDMuNjQtMi4yNS0uMTUtMS44LTEuNzctMy45MS0zLjQyLTQuMjQtMS4wMi0uMjEtMi4xLS4yNC0zLjE1LS4yMi0zLjE2LjA2LTYuMzMuMDktOS40OC4zMi05LjI5LjY2LTE4LjU3IDEuNC0yNy44NSAyLjE1LTEuNTUuMTItMy4wOS40NC00LjYzLjY3em0xOTguNjUtOTYuNTh2LS4wN2MyLjQ2IDAgNC45MS4wNSA3LjM3LS4wMiAxLjU4LS4wNCAyLjczLS45NCAzLjAxLTIuNDcuNTgtMy4yOCAxLjA4LTYuNTcgMS40MS05Ljg4LjE3LTEuNjktLjc4LTIuNTgtMi41OS0yLjUzLTEuOTIuMDUtMy44My40NC01Ljc1LjU2LTIuMTMuMTQtMy40NCAxLjE5LTQuNTUgMy4wMi0xLjcyIDIuODQtMy43OSA1LjQ2LTUuNzIgOC4xNy0uNDEuNTctMS4wNyAxLjA1LTEuMjMgMS42Ny0uMTEuNDMuNDIgMS40Ny43IDEuNDggMi40NC4xMiA0LjkuMDcgNy4zNS4wN3ptLTI5LjEzLTI2Ny43MmMtLjczLS44MS0xLjM3LTEuODYtMi4zLTIuNDgtOC43My01LjgtMTcuODQtNS43NS0yNy4wMy0xLjMxLTEuNjIuNzgtMi43OSAyLjU0LTQuMDcgMy45My0uMjguMy0uMTYuOTctLjIyIDEuNDcuNDkuMDQgMS4wNS4yNyAxLjQ1LjExIDkuOTktNC4yNyAyMC4wNi00LjUyIDMwLjI0LS43MS4zMS4xMi43My4xNiAxLjAyLjA0LjI2LS4xMi40My0uNDkuOTEtMS4wNXptMTAuNDYgMzU3Ljg5Yy00LjItLjI2LTcuMzctLjQtMTAuNTItLjY4LTMuNDQtLjMtNS41OCAxLjctNy40NiA0LjEyLS4xOS4yNS4yMSAxLjMzLjYgMS41Ny41NC4zMyAxLjM1LjMyIDIuMDUuMzEgNi42OC0uMTUgMTMuMzYtLjM1IDIwLjA0LS40OSAyLjUtLjA1IDQuMjgtMS40NCA1LjkyLTMuMDcuMjgtLjI4LjMzLS45LjMtMS4zNS0uMDEtLjE2LS41OS0uMzktLjkxLS4zOS0zLjY4LS4wMy03LjM3LS4wMi0xMC4wMi0uMDJ6bS00Ny4wNy0yNy44OXYtLjA0Yy4zNSAwIC43LjAyIDEuMDUgMCAyLjkzLS4xOCAzLjUtLjkgMi42Ny0zLjU3LTEuNjYtNS4zNS0zLjM3LTEwLjY5LTUuMS0xNi4wMi0uMjYtLjgxLS42LTEuNzctMS4yMi0yLjIzLS41OC0uNDMtMS42My0uNDYtMi4zOC0uMjgtLjMxLjA4LS42NSAxLjI0LS41IDEuNzguMzYgMS4zNCAxLjAyIDIuNiAxLjUyIDMuOTEuODEgMi4xMiAxLjU5IDQuMjYgMi4zNyA2LjQgMS4wMiAyLjc2IDEuMzYgNS4zNS0uOSA3Ljg1LTEuMDkgMS4yMS0uMzggMi4xMiAxLjQ1IDIuMi4zNC4wMS42OSAwIDEuMDQgMHpNMzgzLjY5IDgxLjQxYy0uMjgtMy4yNC0uMzYtNS41My0uNzItNy43Ny0uMjQtMS41LS44OS0yLjkzLTEuMzYtNC4zOS0yLjE4IDEuMTUtLjcyIDMuMDgtLjk0IDQuNjMtLjMyIDIuMjUtLjQ1IDQuNTItLjU5IDYuNzktLjA4IDEuMzYgMS4xNiAyLjYyIDIuMjUgMi4xNy42MS0uMjUuOTMtMS4yMiAxLjM1LTEuODkuMDctLjEyLjAxLS4zNC4wMS40NnoiLz48cGF0aCBjbGFzcz0ic3QxIiBkPSJNMzE3LjM4IDIwNy41OGMuODcuNCAxLjQ2LjgyIDIuMS45NiA2LjM3IDEuMyAxMi43MiAyLjY2IDE5LjEyIDMuNzkgOC43MiAxLjU0IDE3LjE1LjIyIDI1LjMyLTMuMDQgMS4zMS0uNTIgMi41Ny0xLjE2IDMuODgtMS42NiAxLjk0LS43NCAzLjI0LS41MSAzLjY1LjY4LjYxIDEuNzQtLjY4IDIuNTQtMS44NiAzLjMyLS41OC4zOC0xLjIyLjctMS44Ni45OS04LjgxIDQuMDQtMTguMDQgNS45NC0yNy43MSA0Ljc5LTUuNC0uNjQtMTAuNzUtMS43My0xNi4wOC0yLjgtNC4xMy0uODMtOC4yMS0xLjkzLTEyLjI5LTIuOTktMS4xNy0uMy0yLjM1LS43NS0zLjM3LTEuMzktMS4yNi0uNzktMS40Ni0yLjY1LS4zNi0zLjY3IDEtLjk0IDIuMjItMS43IDMuNDYtMi4zMSA5LjUzLTQuNzMgMTkuNTUtNy44NSAzMC4xNS05LjAzIDcuMTYtLjggMTQuMy0yIDIxLjUxLS41NCAxLjM4LjI4IDIuNDctLjA5IDIuOTYtMS40OS41OC0xLjY5IDAtMy40LTEuMzMtNC4xNC0uMzEtLjE3LS42My0uMzItLjk2LS40Mi0xMS41LTMuMzktMjIuOTMtNC45OC0zNC42LS4zMS05LjY0IDMuODYtMTkuMjkgNy42NS0yOC4yMiAxMy4wMS0yLjA4IDEuMjUtNC4xMyAyLjc3LTUuNzEgNC41OS0zLjA1IDMuNTEtMy4xMyA3LjAzLjA4IDEwLjQxIDMuMzcgMy41NSA3LjE1IDYuNzYgMTAuOTkgOS44MiAzLjI5IDIuNjIgNi44NyA0LjkgMTAuNCA3LjIgNC42NiAzLjAzIDcuNjQgNy4xOSA4Ljg4IDEyLjU5Ljc1IDMuMjUgMS41NSA2LjUgMi4xIDkuNzkgMS4zIDcuNjYgMy40NiAxNS4wNiA2LjA3IDIyLjM1IDEuODQgNS4xMiAzLjQ2IDEwLjMzIDUuMDUgMTUuNTQuNCAxLjMxLjUxIDIuNzguMzYgNC4xNC0uMDcuNjQtLjk4IDEuNDEtMS42OCAxLjY5LS40NS4xNy0xLjQ0LS4yOS0xLjc4LS43Ni0uNi0uODItLjk1LTEuODUtMS4yOC0yLjgzLTIuMTgtNi41LTQuNDctMTIuOTYtNi40My0xOS41Mi0xLjg1LTYuMjItMy4zNi0xMi41NS00Ljk2LTE4Ljg0LS40Ny0xLjg3LS44NC0zLjc4LTEuMDktNS43LS45NS03LjI3LTQuNDItMTIuNjItMTAuODctMTYuNDctNC45NS0yLjk1LTkuMzYtNi44Mi0xMy45MS0xMC40MS0xLjc5LTEuNDEtMy4zOC0zLjA4LTQuOTMtNC43NS02LjQ5LTctNC4yNi0xMy44MiAxLjExLTE5LjAzIDEuNzQtMS42OSAzLjkxLTMgNi4wMS00LjI1IDkuNzEtNS43NiAxOS45OS0xMC4zMyAzMC42Ny0xMy45NCA4Ljg2LTMgMTcuODctMy42IDI2Ljk4LS45NyAyLjE5LjYzIDQuNDUgMS4wMyA2LjY3IDEuNTYgNS4xMiAxLjIzIDEwLjMzIDIuMTUgMTUuMDYgNC42NyAxLjY0Ljg3IDIuNTIuMDkgMi42MS0xLjg0LjExLTIuMzctLjk2LTQuMDctMi44NS01LjI5LTYuMTItMy45Ni0xMi42Mi03LjAxLTIwLTcuNTktOC44Ni0uNjktMTcuNTQuMDYtMjUuOTMgMy4zMS02LjcyIDIuNi0xMy41NiA0Ljg5LTIwLjI1IDcuNTctMy40MSAxLjM3LTYuNiAzLjI4LTkuODggNC45Ny0xLjQuNzMtMi43NCAxLjYtNC4xNyAyLjI2LTEuODMuODQtMy4xOS41OS0zLjY4LS41OS0uNjktMS42NS4yMy0yLjc5IDEuNTEtMy41MSA0LjYtMi41NyA5LjA4LTUuNDQgMTMuOTItNy40NSA3Ljk1LTMuMyAxNi4xLTYuMTIgMjQuMjMtOC45NiA4LjgxLTMuMDggMTcuOTYtMy40MiAyNy4wOS0yLjE4IDcuNDMgMS4wMSAxNC4xMiA0LjI4IDIwLjIxIDguNTkgNC44NSAzLjQzIDYuMDcgMTAuNDQgMyAxNS41NS0xLjAzIDEuNzItMi41NiAyLjMzLTQuNTEgMS40OS0xLjkzLS44My0zLjctMi4wNC01LjYxLTIuOS0yLjI1LTEuMDEtMy4xNi0uMzgtMy41IDIuMTMtLjk4IDcuMzktLjk3IDcuMzUtOC40OCA1LjkyLTIuMDYtLjM5LTQuMTktLjgxLTYuMjYtLjcyLTExLjYyLjUzLTIzLjE0IDEuNzEtMzQuMDkgNi4wMi0xLjU2LjU4LTMuMzMuOS00LjY2IDIuNTl6TTIzMS43MyAyMDUuMTRjLS42OSA3LjE1LTMuMjUgMTQuOTItMTAuMTggMTkuOTQtNC43MyAzLjQzLTUuNTQgNi45NC0zLjk1IDEyLjA3IDEuMzUgNC4zNCAyLjE2IDguODYgMy4xNyAxMy4zLjM0IDEuNSAxLjAzIDMuMTctLjc4IDQuMjItMS4yNi43My0yLjUyLS4zOS0zLjUxLTMtLjMxLS44Mi0uNjEtMS42Ni0uNzktMi41MS0xLjI2LTYuMTktMi40NS0xMi4zOS0zLjc2LTE4LjU3LS41LTIuMzYuMDgtNC4xIDIuMTctNS4zOSAxLjE5LS43NCAyLjI1LTEuNjggMy4zOC0yLjUxIDUuMDgtMy43NCA3Ljc4LTguOTggOS4xNS0xNC45NSAyLjY0LTExLjUzLTguMDEtMTkuMDItMTcuNTktMTUuNDItNC4zNyAxLjY0LTguMTUgNC4xLTExLjIgNy42Mi0zLjAxIDMuNDgtNC4yNSA3LjU5LTQuMjYgMTIuMTUgMCAxLjQxLS4yMiAyLjg5LTEuOTUgMy4zMi0xLjI2LjMxLTIuMzctMS4xMi0yLjQxLTMuNDEtLjA5LTQuODcgMS4xOC05LjQyIDQuMDQtMTMuMzggNC4zOS02LjA5IDEwLjMzLTEwLjAxIDE3LjY4LTExLjUyIDEwLjYyLTIuMjIgMjAuNzggNi4xIDIwLjc5IDE4LjA0ek0yMTcuMzkgMjcwLjEzYy4wMS0zLjM4IDIuOTUtNi4wNCA2LjY4LTYuMDQgMy43MSAwIDYuNiAyLjczIDYuNTIgNi4xNi0uMDggMy40NC0yLjk2IDYuMzctNi4zOCA2LjUtMy41NC4xNC02LjgzLTMuMDUtNi44Mi02LjYyem04LjYyLjM4Yy0uNy0uNzYtMS4xNS0xLjYxLTEuNTMtMS41OS0uNy4wNS0xLjM3LjYzLTIuMDUuOTguNC42NC43MiAxLjcxIDEuMjIgMS44MS41OC4xMiAxLjMzLS42MyAyLjM2LTEuMnpNMzkyLjA4IDEyMS4yOWMtLjM3IDMuNjQtLjY0IDYuOTctMS4wNSAxMC4yOC0uMzQgMi43Mi0xLjg4IDQuNDctNC42MyA1LjA1LS42OS4xNC0xLjM2LjM2LTIuMDUuNTEtNi43NCAxLjQ4LTguMjMuODktMTEuMDUtNS40OC0zLjY5LTguMzUtNy4yLTE2Ljc5LTEwLjYxLTI1LjI2LTEuMDQtMi41OS0xLjYxLTUuMzgtMi4yMi04LjEyLS4zLTEuMzUtLjM2LTIuOC0uMjQtNC4xOS41Mi02LjAzIDQuOTktOS4xIDEwLjc0LTcuMjkgNS4xOCAxLjYzIDkuMzcgNC43NSAxMi41OCA5LjE0IDMuOTEgNS4zNSA2LjI2IDExLjM3IDcuNTIgMTcuODIuNDkgMi41OS43IDUuMjIgMS4wMSA3LjU0em0tNC43NiAxLjQyYy0uMjEtOC41Mi0yLjI5LTE1LjctNi4zMS0yMi4yOS0yLjE0LTMuNTEtNS4wMy02LjMyLTguOC04LjA5LS45NS0uNDQtMS45NC0uODctMi45Ni0xLjA3LTIuNDYtLjQ5LTQuMTMuNzUtNC4zNiAzLjI4LS4xMSAxLjItLjA2IDIuNDkuMjUgMy42NS44MSAzLjA1IDEuNjcgNi4xIDIuNzYgOS4wNiAxLjYzIDQuNDUgMy40MiA4Ljg1IDUuMjQgMTMuMjIgMS40MSAzLjQgMi45NyA2Ljc1IDQuNDggMTAuMTEuOTcgMi4xNyAyLjggMi40MiA0Ljg2IDIuMTIgMi41NS0uMzggMy41Mi0xLjEgMy45Ni0zLjQ3LjQ1LTIuNC42Ni00Ljg2Ljg4LTYuNTJ6TTM1Mi4wMiA4NC4zNGMtMS44Mi0uMzktNC45MS0uOTktNy45Ny0xLjczLTEuMDEtLjI0LTEuOTctLjc2LTIuODgtMS4yNy05LjMyLTUuMjgtOS43MS0xNi45NS0uNzctMjIuODYgMi41NS0xLjY5IDUuMzktMi41OSA4LjQzLTIuNjUgNi4xOS0uMTEgMTEuNzggMS41OCAxNi41IDUuNzQgNS4xNCA0LjUzIDUuOTIgMTAuMyAyLjExIDE2LjAyLTMuMzYgNS4wNS04LjM5IDYuNi0xNS40MiA2Ljc1em0tMS42NS0yNC4xNWMtMS43Mi4yOC0zLjUyLjMyLTUuMTQuOS0zLjczIDEuMzMtNi4yMSAzLjk3LTYuNzUgOC0uNDMgMy4yNCAyIDcuMDUgNS44NyA4LjUyIDIuOSAxLjExIDYuMDggMS45NSA5LjE2IDIuMDQgNC4xMy4xMiA3Ljk5LTEuMiAxMC4zMi01LjEzIDIuMDEtMy4zOSAxLjczLTYuMzItMS4xNC05LjAyLTMuNDMtMy4yMi03LjM5LTUuMzMtMTIuMzItNS4zMXoiLz48L3N2Zz4='
9− base+'sphinx'
10− glob-ames+[~donmec-sallud 0v0]
11−==
10+==
desk/gen/publish-all.hoon+13−13
@@ -2,28 +2,28 @@
2/+ *sphinx
2/+ *sphinx
3:- %say
3:- %say
4|= $: [now=@da eny=@uv =beak]
4|= $: [now=@da eny=@uv =beak]
5− [~ ~]
5+ [ ~]
6 ==
6 ==
7:- %declarations
7:- %declarations
8^- (list declare:s)
8^- (list declare:s)
9−:~ :- %friends
9+:* :- %friends
10− ['networked subject' %group 'web+urbitgraph://group/~matwet/networked-subject' 'https://subject.network | networked subject' ~['hosting' 'networking' 'urbit' 'ops'] 'https://urbits3.ams3.digitaloceanspaces.com/sitful-hatred/2022.7.07..17.15.52-ns.png' '']
10+ ['networked subject' %group 'web+urbitgraph://group/~matwet/networked-subject' 'https://subject.network | networked subject' 'https://urbits3.ams3.digitaloceanspaces.com/sitful-hatred/2022.7.07..17.15.52-ns.png' ~['hosting' 'networking' 'urbit' 'ops']]
11 :- %friends
11 :- %friends
12− ['Hooniverse' %group 'web+urbitgraph://group/~hiddev-dannut/new-hooniverse' 'Community based Hoon learning for all levels. For discussion of Hoon specific to Uqbar, join ~hiddev-dannut/uhoon' ~['hoon' 'dev' 'urbit' 'programming' 'education'] 'https://i.imgur.com/ghThlz7.png' '']
12+ ['Hooniverse' %group 'web+urbitgraph://group/~hiddev-dannut/new-hooniverse' 'Community based Hoon learning for all levels. For discussion of Hoon specific to Uqbar, join ~hiddev-dannut/uhoon' 'https://i.imgur.com/ghThlz7.png' ~['hoon' 'dev' 'urbit' 'programming' 'education']]
13 :- %friends
13 :- %friends
14− ['Hollow Mars Theory' %group 'web+urbitgraph://group/~rabsef-bicrym/hollow-mars-theory' 'Definitely NOT a Conspiracy Chat' ~['conspiracies' 'ufos' 'steel beams'] '' '']
14+ ['Hollow Mars Theory' %group 'web+urbitgraph://group/~rabsef-bicrym/hollow-mars-theory' 'Definitely NOT a Conspiracy Chat' '' ~['conspiracies' 'ufos' 'steel beams']]
15 :- %friends
15 :- %friends
16− ['celestial systems' %group 'web+urbitgraph://group/~nocsyx-lassul/celestial-systems' 'A place for pilots who are building hosting providers' ~['hosting' 'ops' 'urbit'] '' '']
16+ ['celestial systems' %group 'web+urbitgraph://group/~nocsyx-lassul/celestial-systems' 'A place for pilots who are building hosting providers' '' ~['hosting' 'ops' 'urbit']]
17 :- %friends
17 :- %friends
18− ['Structure' %group 'web+urbitgraph://group/~fabled-faster/structure' 'Urbit Structural Design and Engineering Group. Always Thinking About Mechanics.' ~['design' 'ux' 'hci' 'urbit'] 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2022.1.27..17.59.43-image.png' '']
18+ ['Structure' %group 'web+urbitgraph://group/~fabled-faster/structure' 'Urbit Structural Design and Engineering Group. Always Thinking About Mechanics.' 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2022.1.27..17.59.43-image.png' ~['design' 'ux' 'hci' 'urbit']]
19 :- %friends
19 :- %friends
20− ['The Marketplace' %group 'web+urbitgraph://group/~tirrel/the-marketplace' 'Welcome to The Marketplace, featuring The Pit, Urbit\'s first open-outcry market!' ~['urbit' 'market' 'sell' 'buy'] 'https://snipboard.io/U0IYyi.jpg' '']
20+ ['The Marketplace' %group 'web+urbitgraph://group/~tirrel/the-marketplace' 'Welcome to The Marketplace, featuring The Pit, Urbit\'s first open-outcry market!' 'https://snipboard.io/U0IYyi.jpg' ~['urbit' 'market' 'sell' 'buy']]
21 :- %friends
21 :- %friends
22− ['The Forge' %group 'web+urbitgraph://group/~middev/the-forge' 'pale fire computing' ~['dev' 'programming' 'hoon' 'urbit'] 'https://nyc3.digitaloceanspaces.com/archiv/littel-wolfur/2021.5.06..21.01.58-the%20forge.png' '']
22+ ['The Forge' %group 'web+urbitgraph://group/~middev/the-forge' 'pale fire computing' 'https://nyc3.digitaloceanspaces.com/archiv/littel-wolfur/2021.5.06..21.01.58-the%20forge.png' ~['dev' 'programming' 'hoon' 'urbit']]
23 :- %friends
23 :- %friends
24− ['UFORIA' %group 'web+urbitgraph://group/~tiplec-lacnyx/ufora' 'UFO Research, Investigations, and Analysis' ~['UFO' 'conspiracies' 'research'] 'https://tiplec-lacnyx.nyc3.digitaloceanspaces.com/tiplec-lacnyx/2021.12.27..06.09.47-change1.jpg' '']
24+ ['UFORIA' %group 'web+urbitgraph://group/~tiplec-lacnyx/ufora' 'UFO Research, Investigations, and Analysis' 'https://tiplec-lacnyx.nyc3.digitaloceanspaces.com/tiplec-lacnyx/2021.12.27..06.09.47-change1.jpg' ~['UFO' 'conspiracies' 'research']]
25 :- %friends
25 :- %friends
26− ['Urbit Community' %group 'web+urbitgraph://group/~bitbet-bolbel/urbit-community' 'World hub, help desk, meet and greet, etc.' ~['general' 'urbit' 'community' 'help'] 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.4.02..21.52.41-UC.png' '']
26+ ['Urbit Community' %group 'web+urbitgraph://group/~bitbet-bolbel/urbit-community' 'World hub, help desk, meet and greet, etc.' 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.4.02..21.52.41-UC.png' ~['general' 'urbit' 'community' 'help']]
27 :- %friends
27 :- %friends
28− ['Urbytes' %group 'web+urbitgraph://group/~nartes-fasrum/urbytes' 'We feed and water Mars.' ~['cooking' 'food' 'beverages' 'peanut butter eggs'] '' '']
28+ ['Urbytes' %group 'web+urbitgraph://group/~nartes-fasrum/urbytes' 'We feed and water Mars.' '' ~['cooking' 'food' 'beverages' 'peanut butter eggs']]
29−==
29+==
desk/lib/gossip.hoon+58−283
@@ -1,10 +1,14 @@
1−:: gossip: data sharing with pals (and pals of pals, etc)
1+:: gossip: data sharing with pals
2−::
3−:: v1.1.1: sneaky whisper
4::
2::
5:: automates using /app/pals for peer & content discovery,
3:: automates using /app/pals for peer & content discovery,
6:: letting the underlying agent focus on handling the data.
4:: letting the underlying agent focus on handling the data.
7::
5::
6+:: at the time of writing, this library has not seen much real-world
7+:: use yet. it may be subject to behavioral or interface changes.
8+:: it tries its best to not generate obscene amounts of network load,
9+:: but has not yet proven itself in the real world.
10+:: for the time being, please tread carefully.
11+::
8:: usage
12:: usage
9::
13::
10:: to use this library, call its +agent arm with an initial
14:: to use this library, call its +agent arm with an initial
@@ -40,10 +44,6 @@
40:: setting hops to 1 distributes the data to direct pals only.
44:: setting hops to 1 distributes the data to direct pals only.
41:: setting hops to 0 prevents emission of any gossip data at all,
45:: setting hops to 0 prevents emission of any gossip data at all,
42:: even during initial subscription setup.
46:: even during initial subscription setup.
43−:: setting pass to true causes 50% of locally-originating data to be
44−:: proxied through a random "tell" peer. note that the proxy will have
45−:: a 50% chance of proxying in turn. for low amounts of hops, data could
46−:: escape the local social graph entirely.
47::
47::
48:: (we introduce the /~/etc path prefix convention to indicate paths
48:: (we introduce the /~/etc path prefix convention to indicate paths
49:: that are for library-specific use only.
49:: that are for library-specific use only.
@@ -75,12 +75,6 @@
75:: we may want to use pokes exclusively, instead of watches/facts,
75:: we may want to use pokes exclusively, instead of watches/facts,
76:: making it easier to exclude the src.bowl, include metadata, etc.
76:: making it easier to exclude the src.bowl, include metadata, etc.
77::
77::
78−:: we currently only emit every rumor as a fact once at most. but we might
79−:: receive it again later, with a higher hop count. if we want to try for
80−:: maximum reach within the given hops, we should re-send if we receive a
81−:: known rumor with higher hop counter. but this may not be worth the added
82−:: complexity...
83−::
84:: what if this was a userspace-infrastructure app instead of a wrapper?
78:: what if this was a userspace-infrastructure app instead of a wrapper?
85:: how would ensuring installation of this app-dependency work?
79:: how would ensuring installation of this app-dependency work?
86:: it gains us... a higher chance of peers having this installed.
80:: it gains us... a higher chance of peers having this installed.
@@ -99,7 +93,6 @@
99:: - if pals ever watch-nacks (it shouldn't), we try rewatching after ~m1.
93:: - if pals ever watch-nacks (it shouldn't), we try rewatching after ~m1.
100::
94::
101:: - for facts produced on /~/gossip/source, we
95:: - for facts produced on /~/gossip/source, we
102−:: - 50% chance to redirect into the "pass flow" (see below)
103:: - wrap them as %gossip-rumor to send them out on /~/gossip/gossip
96:: - wrap them as %gossip-rumor to send them out on /~/gossip/gossip
104:: - for new pals matching the hear mode, we watch their /~/gossip/gossip
97:: - for new pals matching the hear mode, we watch their /~/gossip/gossip
105:: - for gone pals, we leave that watch
98:: - for gone pals, we leave that watch
@@ -109,16 +102,6 @@
109:: - re-emit them as facts on /~/gossip/gossip if there are hops left
102:: - re-emit them as facts on /~/gossip/gossip if there are hops left
110:: - for nacks on those watches, we retry after ~m30
103:: - for nacks on those watches, we retry after ~m30
111::
104::
112−:: - other gossip instances may poke us with rumors directly
113−:: - the inner agent learns the rumor normally, as if it came from a fact
114−:: - based on randomness (50/50), we do one of two things:
115−:: - publish the rumor as a fact to our subscribers, like the above
116−:: - poke a randomly selected peer (in the "tell" set) with the rumor
117−:: - when poking, store the rumor until we receive a poke-ack
118−:: - upon receiving a positive poke-ack, delete the local datum
119−:: - upon receiving a negative poke-ack, do the 50/50 again
120−:: - if we do not receive a poke-ack within some time, do the 50/50 again
121−::
122/- pals
105/- pals
123/+ lp=pals, dbug, verb
106/+ lp=pals, dbug, verb
124::
107::
@@ -129,8 +112,6 @@
129 ==
112 ==
130+$ meta-0 hops=_0
113+$ meta-0 hops=_0
131::
114::
132−+$ hash @uv
133−::
134+$ whos
115+$ whos
135 $? %anybody :: any ship discoverable through pals
116 $? %anybody :: any ship discoverable through pals
136 %targets :: any ship we've added as a pal
117 %targets :: any ship we've added as a pal
@@ -142,11 +123,7 @@
142 :: (1 hop is pals only, 0 stops exposing data at all)
123 :: (1 hop is pals only, 0 stops exposing data at all)
143 hear=whos :: who to subscribe to
124 hear=whos :: who to subscribe to
144 tell=whos :: who to allow subscriptions from
125 tell=whos :: who to allow subscriptions from
145− pass=? :: whether to (50/50) emit data through proxy
146 ==
126 ==
147−::
148−++ pass-timeout ~s30
149−::
150++ invent
127++ invent
151 |= =cage
128 |= =cage
152 ^- card:agent:gall
129 ^- card:agent:gall
@@ -169,27 +146,23 @@
169 ^- $-(agent:gall agent:gall)
146 ^- $-(agent:gall agent:gall)
170 |^ agent
147 |^ agent
171 ::
148 ::
172− +$ state-3
149+ +$ state-0
173− $: %3
150+ $: %0
174− manner=config :: latest config
151+ manner=config :: latest config
175− memory=(set hash) :: datums seen (by inner agent)
152+ memory=(set @uv) :: datums seen
176− shared=(set hash) :: datums shared (as fact)
177− passed=(map hash [rumor @da]) :: pending relays & timeouts
178− misses=(set ship) :: ships that won't relay
179− future=(list rumor) :: rumors of unknown kinds
153+ future=(list rumor) :: rumors of unknown kinds
180 ==
154 ==
181 ::
155 ::
182 +$ card card:agent:gall
156 +$ card card:agent:gall
183 ::
157 ::
184 ++ helper
158 ++ helper
185− |_ [=bowl:gall state-3]
159+ |_ [=bowl:gall state-0]
186 +* state +<+
160 +* state +<+
187 pals ~(. lp bowl)
161 pals ~(. lp bowl)
188 ++ en-cage
162 ++ en-cage
189 |= =(cask *)
163 |= =(cask *)
190 ^- cage
164 ^- cage
191 ?^ to=(~(get by grab) p.cask)
165 ?^ to=(~(get by grab) p.cask)
192− ::TODO +soft or otherwise virtualize? don't want to risk crashes, right?
193 [p.cask (u.to q.cask)]
166 [p.cask (u.to q.cask)]
194 ~& [gossip+dap.bowl %no-mark p.cask]
167 ~& [gossip+dap.bowl %no-mark p.cask]
195 [%gossip-unknown !>(cask)]
168 [%gossip-unknown !>(cask)]
@@ -204,10 +177,6 @@
204 ~| [%en-rumor initial-hops=hops.manner]
177 ~| [%en-rumor initial-hops=hops.manner]
205 [%0 `meta-0`(dec hops.manner)]
178 [%0 `meta-0`(dec hops.manner)]
206 ::
179 ::
207− ++ en-hash
208− |= rumor
209− (sham data)
210− ::
211 ++ play-card :: en-rumor relevant facts, handle config changes
180 ++ play-card :: en-rumor relevant facts, handle config changes
212 |= =card
181 |= =card
213 ^- (quip ^card _state)
182 ^- (quip ^card _state)
@@ -242,68 +211,11 @@
242 ?> =(/~/gossip/source path)
211 ?> =(/~/gossip/source path)
243 :: if hops is configured at 0, we don't broadcast at all.
212 :: if hops is configured at 0, we don't broadcast at all.
244 ::
213 ::
214+ =. memory (~(put in memory) (sham (de-cage cage.p.card)))
215+ ?: =(0 hops.manner) [caz state]
216+ =- [[- caz] state]
245 =/ =rumor (en-rumor cage.p.card)
217 =/ =rumor (en-rumor cage.p.card)
246− =. memory (~(put in memory) (en-hash rumor))
247− ?: =(0 hops.manner)
248− [caz state]
249− =^ cas state (emit-rumor rumor)
250− [(weld cas caz) state]
251− ::
252− ++ emit-rumor :: gossip a rumor as-is
253− |= =rumor
254− ^- (quip card _state)
255− =/ =hash (en-hash rumor)
256− =* fact
257− :- [%give %fact [/~/gossip/gossip]~ %gossip-rumor !>(rumor)]~
218+ card(paths.p [/~/gossip/gossip]~, cage.p [%gossip-rumor !>(rumor)])
258− %_ state
259− passed (~(del by passed) hash)
260− shared (~(put in shared) hash)
261− ==
262− :: if we don't want to proxy, always send as fact
263− ::
264− ?. pass.manner
265− fact
266− :: if we want to proxy, do so 50% of the time
267− ::
268− ?. =(0 (~(rad og eny.bowl) 2))
269− fact
270− :: if we're proxying, but there's no reasonable targets, send as fact
271− ::
272− =/ aides=(set ship)
273− :: reasonable targets do not include ourselves, whoever
274− :: caused us to want to (re)send this rumor, or ships that failed
275− :: to proxy for us before.
276− :: that last one is important because we need to avoid the mistake of
277− :: occasionally sending them pokes that just linger in our outbound
278− :: ames flows and keep retrying. (ideally we'd do those all pokes for
279− :: a target on the same flow, but we really like storing information
280− :: in the wire and don't want to do per-relay queues in state. we also
281− :: don't particularly care about ordering guarantees for gossip.)
282− :: we don't just get the set of ships with active incoming or outgoing
283− :: subscriptions, because that constrains the set unnecessarily. for
284− :: some configs, ships in .tell (that aren't in .hear) might be
285− :: running the agent without actively listening to us.
286− ::
287− =- (~(dif in (~(del in (~(del in -) our.bowl)) src.bowl)) misses)
288− ?- tell.manner
289− %anybody (~(uni in (targets:pals ~.)) leeches:pals)
290− %targets (targets:pals ~.)
291− %mutuals (mutuals:pals ~.)
292− ==
293− =/ count=@ud ~(wyt in aides)
294− ?: =(0 count)
295− fact
296− :: poke a randomly chosen proxy with the rumor
297− ::
298− =/ proxy=ship (snag (~(rad og +(eny.bowl)) count) ~(tap in aides))
299− =/ =time (add now.bowl pass-timeout)
300− =. passed (~(put by passed) hash [rumor time])
301− :_ state
302− =/ =wire /~/gossip/passed/(scot %p proxy)/(scot %uv hash)
303− =/ =cage gossip-rumor+!>(rumor)
304− :~ [%pass wire %agent [proxy dap.bowl] %poke cage]
305− [%pass wire %arvo %b %wait time]
306− ==
307 ::
219 ::
308 ++ play-cards
220 ++ play-cards
309 |= cards=(list card)
221 |= cards=(list card)
@@ -330,18 +242,15 @@
330 [%gossip-rumor !>((en-rumor cage.p.i.cards))]
242 [%gossip-rumor !>((en-rumor cage.p.i.cards))]
331 $(out (snoc out i.cards), cards t.cards)
243 $(out (snoc out i.cards), cards t.cards)
332 ::
244 ::
333− ++ jump-rumor :: relay a rumor if we haven't yet
245+ ++ resend-rumor
334 |= =rumor
246 |= =rumor
335− ^- (quip card _state)
247+ ^- (unit card)
336− =/ =hash (en-hash rumor)
337− ?: (~(has in shared) hash) [~ state]
338 ?> =(%0 kind.rumor) ::NOTE should have been checked for already
248 ?> =(%0 kind.rumor) ::NOTE should have been checked for already
339− ?~ meta=((soft ,hops=@ud) meta.rumor) [~ state]
249+ ?~ meta=((soft ,hops=@ud) meta.rumor) ~
340 =* hops hops.u.meta
250 =* hops hops.u.meta
341− ?: =(0 hops) [~ state]
251+ ?: =(0 hops) ~
342 =. meta.rumor (dec hops)
252 =. meta.rumor (dec hops)
343− :- [%give %fact [/~/gossip/gossip]~ %gossip-rumor !>(rumor)]~
253+ `[%give %fact [/~/gossip/gossip]~ %gossip-rumor !>(rumor)]
344− state(shared (~(put in shared) (en-hash rumor)))
345 ::
254 ::
346 ++ may-watch
255 ++ may-watch
347 |= who=ship
256 |= who=ship
@@ -455,7 +364,7 @@
455 ::
364 ::
456 ++ agent
365 ++ agent
457 |= inner=agent:gall
366 |= inner=agent:gall
458− =| state-3
367+ =| state-0
459 =* state -
368 =* state -
460 %+ verb |
369 %+ verb |
461 %- agent:dbug
370 %- agent:dbug
@@ -473,70 +382,22 @@
473 =^ cards state (play-cards:up cards)
382 =^ cards state (play-cards:up cards)
474 [(weld watch-pals:up cards) this]
383 [(weld watch-pals:up cards) this]
475 ::
384 ::
476− ++ on-save (slop !>([%gossip state]) on-save:og)
385+ ++ on-save !>([[%gossip state] on-save:og])
477 ++ on-load
386 ++ on-load
478 |= ole=vase
387 |= ole=vase
479 ^- (quip card _this)
388 ^- (quip card _this)
480 ?. ?=([[%gossip *] *] q.ole)
389 ?. ?=([[%gossip *] *] q.ole)
481 =. manner init
390 =. manner init
482− =^ cards inner (on-load:og ole)
391+ =^ cards inner (on-load:og ole)
483 =^ cards state (play-cards:up cards)
392 =^ cards state (play-cards:up cards)
484 [(weld watch-pals:up cards) this]
393 [(weld watch-pals:up cards) this]
485 ::
394 ::
486− |^ =+ !<([%gossip old=state-any] (slot 2 ole))
395+ =+ !<([[%gossip old=state-0] ile=vase] ole)
487− =/ ile=vase (slot 3 ole)
488− =? old ?=(%0 -.old) (state-0-to-1 old)
489− =? old ?=(%1 -.old) (state-1-to-2 old)
490− :: before version 3, we stored the inner +on-save vase inside
491− :: our own +on-save vase. undoing this double-vasing coincided
492− :: with the h135 type-of-type upgrade.
493− ::
494− =? ile ?=(%2 -.old) (next-vase:h136 !<(vase:h136 ile))
495− =? old ?=(%2 -.old) (state-2-to-3 old)
496− ?> ?=(%3 -.old)
497− =. state old
396+ =. state old
498− =^ cards inner (on-load:og ile)
397+ =^ cards inner (on-load:og ile)
499− =^ cards state (play-cards:up cards)
398+ =^ cards state (play-cards:up cards)
500− ::TODO for later versions, add :future retry logic as needed
399+ ::TODO for later versions, add :future retry logic as needed
501− [cards this]
400+ [cards this]
502− ::
503− +$ state-any $%(state-0 state-1 state-2 state-3)
504− ::
505− +$ state-2
506− $: %2
507− manner=config
508− memory=(set hash)
509− shared=(set hash)
510− passed=(map hash [rumor @da])
511− misses=(set ship)
512− future=(list rumor)
513− ==
514− ++ state-2-to-3
515− |=(s=state-2 s(- %3))
516− ::
517− +$ state-1
518− $: %1
519− manner=config
520− memory=(set hash)
521− shared=(set hash)
522− passed=(map hash [rumor @da])
523− future=(list rumor)
524− ==
525− ++ state-1-to-2
526− |= state-1
527− [%2 manner memory shared passed ~ future]
528− ::
529− +$ state-0 [%0 manner=config-0 memory=(set hash) future=(list rumor)]
530− +$ config-0 [hops=_1 hear=whos tell=whos]
531− ++ state-0-to-1
532− |= state-0
533− ^- state-1
534− [%1 (config-0-to-1 manner) memory memory ~ future]
535− ++ config-0-to-1
536− |= config-0
537− ^- config
538− [hops hear tell pass.init]
539− --
540 ::
401 ::
541 ++ on-watch
402 ++ on-watch
542 |= =path
403 |= =path
@@ -553,43 +414,13 @@
553 =^ cards state (play-first-cards:up cards)
414 =^ cards state (play-first-cards:up cards)
554 [cards this]
415 [cards this]
555 ::
416 ::
556− ++ on-poke
557− |= [=mark =vase]
558− ^- (quip card _this)
559− ::TODO gossip config pokes
560− ?. =(%gossip-rumor mark)
561− =^ cards inner (on-poke:og +<)
562− =^ cards state (play-cards:up cards)
563− [cards this]
564− ?. (want-target:up src.bowl)
565− ~|(%gossip-rejected !!)
566− :: we're getting pokes from them, so we should try relaying through
567− :: them again if we stopped doing so
568− ::NOTE this only ever cleans out .misses for ships producing content
569− ::
570− =. misses (~(del in misses) src.bowl)
571− ::TODO dedupe with +on-agent %fact
572− =+ !<(=rumor vase)
573− =/ =hash (en-hash:up rumor)
574− ?: (~(has in memory) hash)
575− [~ this]
576− ?. =(%0 kind.rumor)
577− ~& [gossip+dap.bowl %delaying-unknown-rumor-kind kind.rumor]
578− [~ this(future [rumor future])]
579− =. memory (~(put in memory) hash)
580− =/ mage=cage (en-cage:up data.rumor)
581− =^ cards inner (on-agent:og /~/gossip/gossip %fact mage)
582− =^ caz1 state (play-cards:up cards)
583− =^ caz2 state (emit-rumor:up rumor)
584− [(weld caz1 caz2) this]
585− ::
586 ++ on-agent
417 ++ on-agent
587 |= [=wire =sign:agent:gall]
418 |= [=wire =sign:agent:gall]
588 ^- (quip card _this)
419 ^- (quip card _this)
589 ?. ?=([%~.~ %gossip *] wire)
420 ?. ?=([%~.~ %gossip *] wire)
590 =^ cards inner (on-agent:og wire sign)
421 =^ cards inner (on-agent:og wire sign)
591 =^ cards state (play-cards:up cards)
422 =^ cards state (play-cards:up cards)
592− [cards this]
423+ [cards this]
593 ::
424 ::
594 ?+ t.t.wire ~|([%gossip %unexpected-wire wire] !!)
425 ?+ t.t.wire ~|([%gossip %unexpected-wire wire] !!)
595 [%gossip @ ~]
426 [%gossip @ ~]
@@ -602,26 +433,18 @@
602 ?. =(%gossip-rumor mark)
433 ?. =(%gossip-rumor mark)
603 ~& [gossip+dap.bowl %ignoring-unexpected-fact mark=mark]
434 ~& [gossip+dap.bowl %ignoring-unexpected-fact mark=mark]
604 [~ this]
435 [~ this]
605− :: we're getting facts from them, so we should try relaying through
606− :: them again if we stopped doing so
607− ::NOTE this only ever cleans out .misses for ships in .hear
608− ::
609− =. misses (~(del in misses) src.bowl)
610− ::TODO de-dupe with +on-poke
611 =+ !<(=rumor vase)
436 =+ !<(=rumor vase)
612− =/ =hash (en-hash:up rumor)
437+ =/ hash (sham data.rumor)
613 ?: (~(has in memory) hash)
438 ?: (~(has in memory) hash)
614− =^ cards state (jump-rumor:up rumor)
615− [cards this]
439+ [~ this]
616 ?. =(%0 kind.rumor)
440 ?. =(%0 kind.rumor)
617 ~& [gossip+dap.bowl %delaying-unknown-rumor-kind kind.rumor]
441 ~& [gossip+dap.bowl %delaying-unknown-rumor-kind kind.rumor]
618 [~ this(future [rumor future])]
442 [~ this(future [rumor future])]
619− =. memory (~(put in memory) hash)
620 =/ mage=cage (en-cage:up data.rumor)
443 =/ mage=cage (en-cage:up data.rumor)
621 =^ cards inner (on-agent:og /~/gossip/gossip sign(cage mage))
444 =^ cards inner (on-agent:og /~/gossip/gossip sign(cage mage))
622− =^ caz1 state (play-cards:up cards)
445+ =^ cards state (play-cards:up cards)
623− =^ caz2 state (jump-rumor:up rumor)
446+ :_ this(memory (~(put in memory) hash))
624− [(weld caz1 caz2) this]
447+ (weld (drop (resend-rumor:up rumor)) cards)
625 ::
448 ::
626 %watch-ack
449 %watch-ack
627 :_ this
450 :_ this
@@ -648,32 +471,6 @@
648 ~& [gossip+dap.bowl %unexpected-poke-ack wire]
471 ~& [gossip+dap.bowl %unexpected-poke-ack wire]
649 [~ this]
472 [~ this]
650 ==
473 ==
651− ::
652− [%passed ?([@ ~] [@ @ ~])]
653− ?. ?=(%poke-ack -.sign)
654− ~& [gossip+dap.bowl %unexpected-sign wire -.sign]
655− [~ this]
656− ~| t.t.wire
657− :: regardless of anything else: if they acked the poke, they are
658− :: accepting of relay requests, if they nacked they're unaccepting
659− ::
660− =. misses
661− ?: ?=(~ p.sign)
662− (~(del in misses) src.bowl)
663− (~(put in misses) src.bowl)
664− =/ =hash
665− ?: ?=([@ ~] t.t.t.wire)
666− (slav %uv i.t.t.t.wire)
667− (slav %uv i.t.t.t.t.wire)
668− ?~ rum=(~(get by passed) hash)
669− [~ this]
670− ::NOTE emitting rest is cute, but doesn't actually work reliably,
671− :: due to userspace duct shenanigans. %wake logic will have to
672− :: be defensive...
673− =/ rest=card [%pass wire %arvo %b %rest +.u.rum]
674− ?~ p.sign [[rest]~ this(passed (~(del by passed) hash))]
675− =^ caz state (emit-rumor:up -.u.rum)
676− [[rest caz] this]
677 ::
474 ::
678 [%pals @ ~]
475 [%pals @ ~]
679 ?- -.sign
476 ?- -.sign
@@ -745,14 +542,6 @@
745 ++ on-peek
542 ++ on-peek
746 |= =path
543 |= =path
747 ^- (unit (unit cage))
544 ^- (unit (unit cage))
748− ?: =(/x/whey path)
749− :+ ~ ~
750− :- %mass
751− !> ^- (list mass)
752− :- %gossip^&+state
753− =/ dat (on-peek:og path)
754− ?: ?=(?(~ [~ ~]) dat) ~
755− (fall ((soft (list mass)) q.q.u.u.dat) ~)
756 ?: =(/x/dbug/state path)
545 ?: =(/x/dbug/state path)
757 ``noun+(slop on-save:og !>(gossip=state))
546 ``noun+(slop on-save:og !>(gossip=state))
758 ?. ?=([@ %~.~ %gossip *] path)
547 ?. ?=([@ %~.~ %gossip *] path)
@@ -771,6 +560,14 @@
771 =^ cards state (play-cards:up cards)
560 =^ cards state (play-cards:up cards)
772 [cards this]
561 [cards this]
773 ::
562 ::
563+ ++ on-poke
564+ |= cage
565+ ^- (quip card _this)
566+ ::TODO gossip config pokes?
567+ =^ cards inner (on-poke:og +<)
568+ =^ cards state (play-cards:up cards)
569+ [cards this]
570+ ::
774 ++ on-arvo
571 ++ on-arvo
775 |= [=wire sign=sign-arvo:agent:gall]
572 |= [=wire sign=sign-arvo:agent:gall]
776 ^- (quip card _this)
573 ^- (quip card _this)
@@ -778,41 +575,19 @@
778 =^ cards inner (on-arvo:og wire sign)
575 =^ cards inner (on-arvo:og wire sign)
779 =^ cards state (play-cards:up cards)
576 =^ cards state (play-cards:up cards)
780 [cards this]
577 [cards this]
578+ ?> ?=([%retry *] t.t.wire)
579+ ?> ?=(%wake +<.sign)
781− ?+ t.t.wire ~|(wire !!)
580+ ?+ t.t.t.wire ~|(wire !!)
782− [%passed ?([@ ~] [@ @ ~])]
581+ [%pals *]
783− =/ =hash
582+ ::NOTE this might result in subscription wire re-use,
784− ?: ?=([@ ~] t.t.t.wire)
583+ :: but if we hit this path we should be loud anyway.
785− (slav %uv i.t.t.t.wire)
584+ [watch-pals:up this]
786− (slav %uv i.t.t.t.t.wire)
787− ?~ rum=(~(get by passed) hash) [~ this]
788− :: since timer cancellation isn't fully reliable, this timer fire
789− :: might be from a relay that nacked our poke. if the timer fired
790− :: earlier than expected, assume that to be the case and no-op.
791− ::
792− ?: (gth +.u.rum now.bowl) [~ this]
793− :: they failed to respond to our relay request in time, mark them as
794− :: unaccepting (if we know who they are from a the new-style wire)
795− :: and try sending the rumor again
796− ::
797− =? misses ?=([@ @ ~] t.t.t.wire)
798− (~(put in misses) (slav %p i.t.t.t.wire))
799− =^ cards state (emit-rumor:up -.u.rum)
800− [cards this]
801 ::
585 ::
802− [%retry *]
803− ?> ?=(%wake +<.sign)
804− ?+ t.t.t.wire ~|(wire !!)
805− [%pals *]
806− ::NOTE this might result in subscription wire re-use,
807− :: but if we hit this path we should be loud anyway.
808− [watch-pals:up this]
809− ::
810− [%watch @ ~]
586+ [%watch @ ~]
811− :_ this
587+ :_ this
812− =/ target=ship (slav %p i.t.t.t.t.wire)
588+ =/ target=ship (slav %p i.t.t.t.t.wire)
813− ?. (want-target:up target) ~
589+ ?. (want-target:up target) ~
814− (watch-target:up target)
590+ (watch-target:up target)
815− ==
816 ==
591 ==
817 ::
592 ::
818 ++ on-fail
593 ++ on-fail
@@ -823,4 +598,4 @@
823 [cards this]
598 [cards this]
824 --
599 --
825 --
600 --
826−--
601+--
desk/lib/pals.hoon+2−8
@@ -1,9 +1,6 @@
1:: pals: manual peer discovery
1:: pals: manual peer discovery
2::
2::
3−/- *pals
4−::
5|_ bowl:gall
3|_ bowl:gall
6−++ records (s ^records /)
7++ leeches (s (set ship) /leeches)
4++ leeches (s (set ship) /leeches)
8++ targets |= list=@ta (s (set ship) %targets ?~(list / /[list]))
5++ targets |= list=@ta (s (set ship) %targets ?~(list / /[list]))
9++ mutuals |= list=@ta (s (set ship) %mutuals ?~(list / /[list]))
6++ mutuals |= list=@ta (s (set ship) %mutuals ?~(list / /[list]))
@@ -11,11 +8,8 @@
11++ target |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
8++ target |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
12++ mutual |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
9++ mutual |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
13::
10::
14−++ labels ?. running `(set @ta)`~
15− ~(key by dir:.^(arch %gy (snoc base %targets)))
16−::
17−++ base ~+ `path`/(scot %p our)/pals/(scot %da now)
11+++ base ~+ /(scot %p our)/pals/(scot %da now)
18−++ running ~+ .^(? %gu (snoc base %$))
12+++ running ~+ .^(? %gu base)
19::
13::
20++ s
14++ s
21 |* [=mold =path] ~+
15 |* [=mold =path] ~+
desk/lib/sphinx-json.hoon+4−6
@@ -26,12 +26,10 @@
26 |= =search:s
26 |= =search:s
27 %- pairs
27 %- pairs
28 :~ listings/a/(turn listings.search listing)
28 :~ listings/a/(turn listings.search listing)
29− :: numb, not (scot %ud): 1.234 would be read as a fraction
30− ::
31− start/(numb start.search)
29+ start/n/(scot %ud start.search)
32− limit/(numb limit.search)
30+ limit/n/(scot %ud limit.search)
33− size/(numb size.search)
31+ size/n/(scot %ud size.search)
34− total/(numb total.search)
32+ total/n/(scot %ud total.search)
35 ==
33 ==
36 ++ directory
34 ++ directory
37 |= d=directory:s
35 |= d=directory:s
desk/lib/sphinx.hoon+18−30
@@ -47,25 +47,13 @@
47 %+ turn
47 %+ turn
48 entries
48 entries
49 |=(=entry hash.entry)
49 |=(=entry hash.entry)
50−:: +lengths-valid: bounds on what a peer can send, checked before
51−:: anything is indexed
52−::
53−:: source and time aren't in the digest, so a peer picks them too:
54−:: +time:enjs crashes before 1970, and rendering a huge atom is
55−:: quadratic
56−::
57++ lengths-valid
50++ lengths-valid
58 |= l=listing
51 |= l=listing
59− ?& (lte (met 3 title.post.l) 77)
52+ ?& (lte (lent (trip title.post.l)) 77)
60− (lte (met 3 description.post.l) 256)
53+ (lte (lent (trip description.post.l)) 256)
61 (lte (lent tags.post.l) 8)
54 (lte (lent tags.post.l) 8)
62− (levy tags.post.l |=(tag=@t (lte (met 3 tag) 1.024)))
63− (lte (met 3 link.post.l) 1.024)
55+ (lte (lent (trip link.post.l)) 1.024)
64− (lte (met 3 image.post.l) 1.024)
56+ (lte (lent (trip image.post.l)) 1.024)
65− (lte (met 3 color.post.l) 1.024)
66− (lte (met 3 source.l) 16)
67− (gte time.l ~1970.1.1)
68− (lth time.l ~275000.1.1)
69 ==
57 ==
70++ urls-valid
58++ urls-valid
71 |= l=listing
59 |= l=listing
@@ -76,9 +64,13 @@
76 &(!=(image ~) !=(link ~))
64 &(!=(image ~) !=(link ~))
77++ listing-valid
65++ listing-valid
78 |= l=listing
66 |= l=listing
79− :: lengths first: don't parse urls of oversized listings
67+ =/ lengths (lengths-valid l)
80− ::
68+ =/ urls (urls-valid l)
69+ :: ~& title.post.l
70+ :: ~& hash.l
71+ :: ~& ['urls' ?:(urls 'valid' 'invalid')]
72+ :: ~& ['lengths' ?:(lengths 'valid' 'invalid')]
81− &((lengths-valid l) (urls-valid l))
73+ &(lengths urls)
82++ delver
74++ delver
83 |_ [=lookup =trigrams =phonetics =trail]
75 |_ [=lookup =trigrams =phonetics =trail]
84 ++ get
76 ++ get
@@ -96,11 +88,8 @@
96 (sift:sift description.post.l)
88 (sift:sift description.post.l)
97 |= word=@t
89 |= word=@t
98 [word ~[[hash rank=8]]]
90 [word ~[[hash rank=8]]]
99− :: +iching is quadratic in the length of a key, so long
100− :: tags aren't indexed
101− ::
102 %+ turn
91 %+ turn
103− (skim tags.post.l |=(tag=@t (lte (met 3 tag) 64)))
92+ tags.post.l
104 |= tag=@t
93 |= tag=@t
105 [(norm:sift tag) ~[[hash rank=12]]]
94 [(norm:sift tag) ~[[hash rank=12]]]
106 %+ turn
95 %+ turn
@@ -111,13 +100,12 @@
111 ==
100 ==
112 =/ keys ~(tap in ~(key by entries))
101 =/ keys ~(tap in ~(key by entries))
113 =. trail (~(put by trail) hash keys)
102 =. trail (~(put by trail) hash keys)
114− :: only touch the new keys: rebuilding the whole map for every
115− :: listing made cataloging a directory quadratic
116− ::
117− =. lookup
103+ =. lookup
118− %+ roll ~(tap by entries)
104+ %- ~(uni by entries)
105+ %- ~(rut by lookup)
119− |= [[=key value=(list entry)] lok=_lookup]
106+ |= [=key value=(list entry)]
107+ ?. (~(has by entries) key) value
120− (~(put by lok) key (weld value (~(gut by lok) key ~)))
108+ (weld value (~(got by entries) key))
121 =. trigrams
109 =. trigrams
122 %+ roll
110 %+ roll
123 %+ snoc
111 %+ snoc
desk/sys.kelvin+4−1
@@ -1 +1,4 @@
1−[%zuse 408]
1+[%zuse 417]
2+[%zuse 416]
3+[%zuse 415]
4+[%zuse 414]
flake.lockdeleted−63
@@ -1,63 +0,0 @@
1−{
2− "nodes": {
3− "landscape": {
4− "flake": false,
5− "locked": {
6− "lastModified": 1781809440,
7− "narHash": "sha256-woNZKzK8xlBxnMMrQOzInA7+gGxs7BgjGbmTi7OGi6g=",
8− "owner": "tloncorp",
9− "repo": "landscape",
10− "rev": "3ea2679f2003ed3ca1a6b322cd7857a774eac305",
11− "type": "github"
12− },
13− "original": {
14− "owner": "tloncorp",
15− "repo": "landscape",
16− "rev": "3ea2679f2003ed3ca1a6b322cd7857a774eac305",
17− "type": "github"
18− }
19− },
20− "nixpkgs": {
21− "locked": {
22− "lastModified": 1792210252,
23− "narHash": "sha256-Mh0PUaIv4nFY+8wAGonaEh8iBzwsmdNlwjVHmFg1Biw=",
24− "owner": "NixOS",
25− "repo": "nixpkgs",
26− "rev": "09063bd1f2b36fd9fd793c36dfade7bd40a93dfc",
27− "type": "github"
28− },
29− "original": {
30− "owner": "NixOS",
31− "ref": "nixos-unstable",
32− "repo": "nixpkgs",
33− "type": "github"
34− }
35− },
36− "root": {
37− "inputs": {
38− "landscape": "landscape",
39− "nixpkgs": "nixpkgs",
40− "urbit": "urbit"
41− }
42− },
43− "urbit": {
44− "flake": false,
45− "locked": {
46− "lastModified": 1785851358,
47− "narHash": "sha256-eHoGwqirk7zMpOn/y8dKqozz5IiQ7nqek/ypxIOaRiU=",
48− "owner": "urbit",
49− "repo": "urbit",
50− "rev": "5a187fededc4582a34fcd6055c67bb63e0917b94",
51− "type": "github"
52− },
53− "original": {
54− "owner": "urbit",
55− "ref": "408k-2",
56− "repo": "urbit",
57− "type": "github"
58− }
59− }
60− },
61− "root": "root",
62− "version": 7
63−}
flake.nixdeleted−98
@@ -1,98 +0,0 @@
1−{
2− description = "sphinx desk";
3−
4− inputs = {
5− nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6−
7− # Dependency desks. Keep them on releases for the kelvin in
8− # desk/sys.kelvin ([%zuse 408]) and bump them together with it.
9− urbit = {
10− # release tag for [%zuse 408]
11− url = "github:urbit/urbit/408k-2";
12− flake = false;
13− };
14− landscape = {
15− # No 408 tag: the staging head Tlon promoted on 2026-06-18, whose
16− # desk/sys.kelvin lists [%zuse 410] [%zuse 409] [%zuse 408].
17− url = "github:tloncorp/landscape/3ea2679f2003ed3ca1a6b322cd7857a774eac305";
18− flake = false;
19− };
20− };
21−
22− outputs =
23− {
24− self,
25− nixpkgs,
26− urbit,
27− landscape,
28− }:
29− let
30− forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
31−
32− # Only what ./desk needs from the dependency desks: its imports,
33− # transitively, and the marks for its file types plus json and mime.
34− # 408 clay builds every mark on the desk at each commit and OTA, and one
35− # that fails to build rejects the whole event, so extra marks are a
36− # liability.
37− basePicks = [
38− "lib/dbug.hoon"
39− "lib/default-agent.hoon"
40− "lib/skeleton.hoon"
41− "lib/strand.hoon"
42− "lib/strandio.hoon"
43− "lib/verb.hoon"
44− "sur/spider.hoon"
45− "sur/verb.hoon"
46− "mar/bill.hoon"
47− "mar/hoon.hoon"
48− "mar/json.hoon"
49− "mar/kelvin.hoon"
50− "mar/mime.hoon"
51− "mar/noun.hoon"
52− "mar/txt.hoon"
53− ];
54− # desk.docket-0 needs mar/docket-0, which imports lib/docket and
55− # sur/docket.
56− docketPicks = [
57− "lib/docket.hoon"
58− "mar/docket-0.hoon"
59− "sur/docket.hoon"
60− ];
61− in
62− {
63− packages = forAllSystems (
64− system:
65− let
66− pkgs = nixpkgs.legacyPackages.${system};
67− in
68− {
69− # The picks, then ./desk on top. -L copies symlink targets, so the
70− # desk is plain files.
71− desk = pkgs.runCommandLocal "sphinx-desk" { } ''
72− mkdir $out
73− for f in ${toString basePicks}; do
74− install -Dm644 ${urbit}/pkg/base-dev/$f $out/$f
75− done
76− for f in ${toString docketPicks}; do
77− install -Dm644 ${landscape}/desk-dev/$f $out/$f
78− done
79− cp -RL ${./desk}/. $out
80− '';
81− # The UI files, served under /apps/sphinx/. Upload this directory at
82− # /docket/upload on the ship in desk/desk.docket-0's glob-ames.
83− glob = pkgs.buildNpmPackage {
84− name = "sphinx-glob";
85− src = ./ui;
86− nodejs = pkgs.nodejs_22;
87− npmDepsHash = "sha256-uOUDkt0spCWdlOfto5Y3o5R9PP6oeYmGMcwUjEdzC/M=";
88− installPhase = ''
89− runHook preInstall
90− cp -R dist $out
91− runHook postInstall
92− '';
93− };
94− default = self.packages.${system}.desk;
95− }
96− );
97− };
98−}
ui/.nvmrcdeleted−1
@@ -1 +0,0 @@
1−22
ui/index.html+1−1
@@ -8,7 +8,7 @@
8 <link rel="icon" href="/src/assets/favicon.svg" sizes="any" type="image/svg+xml" />
8 <link rel="icon" href="/src/assets/favicon.svg" sizes="any" type="image/svg+xml" />
9 <link rel="mask-icon" href="/src/assets/safari-pinned-tab.svg" color="#000000" />
9 <link rel="mask-icon" href="/src/assets/safari-pinned-tab.svg" color="#000000" />
10 <link rel="apple-touch-icon" sizes="180x180" href="/src/assets/apple-touch-icon.png" />
10 <link rel="apple-touch-icon" sizes="180x180" href="/src/assets/apple-touch-icon.png" />
11− <link rel="manifest" href="/manifest.json" />
11+ <link rel="manifest" href="/src/assets/manifest.json" />
12 </head>
12 </head>
13 <body class="font-sans text-mauve bg-linen antialiased">
13 <body class="font-sans text-mauve bg-linen antialiased">
14 <div id="app"></div>
14 <div id="app"></div>
ui/package-lock.json−1
@@ -26,7 +26,6 @@
26 "react-router-dom": "^6.3.0",
26 "react-router-dom": "^6.3.0",
27 "react-select": "^5.4.0",
27 "react-select": "^5.4.0",
28 "remove-markdown-and-html": "^0.1.0",
28 "remove-markdown-and-html": "^0.1.0",
29− "urbit-ob": "^5.0.1",
30 "zustand": "^4.0.0-rc.1"
29 "zustand": "^4.0.0-rc.1"
31 },
30 },
32 "devDependencies": {
31 "devDependencies": {
ui/package.json−1
@@ -28,7 +28,6 @@
28 "react-router-dom": "^6.3.0",
28 "react-router-dom": "^6.3.0",
29 "react-select": "^5.4.0",
29 "react-select": "^5.4.0",
30 "remove-markdown-and-html": "^0.1.0",
30 "remove-markdown-and-html": "^0.1.0",
31− "urbit-ob": "^5.0.1",
32 "zustand": "^4.0.0-rc.1"
31 "zustand": "^4.0.0-rc.1"
33 },
32 },
34 "devDependencies": {
33 "devDependencies": {
ui/src/api.ts−6
@@ -3,10 +3,4 @@ import Urbit from "@urbit/http-api";
3const api = new Urbit('', '', window.desk);
3const api = new Urbit('', '', window.desk);
4api.ship = window.ship;
4api.ship = window.ship;
5
5
6−// @urbit/http-api 2.x adds `window.addEventListener('beforeunload', this.delete)`
7−// unbound, so its beacon is sent to `${location dir}/undefined` (404) and the
8−// channel is left open until eyre's 12h timeout. Replace it with a bound one.
9−window.removeEventListener('beforeunload', api.delete);
10−window.addEventListener('beforeunload', () => api.delete());
11−
12export default api;
6export default api;
ui/public/android-chrome-512x512.png → ui/src/assets/android-chrome-512x512.pngrenamed

Binary file not shown.

ui/public/manifest.json → ui/src/assets/manifest.jsonrenamed+1−1
@@ -3,7 +3,7 @@
3 "short_name": "sphinx",
3 "short_name": "sphinx",
4 "icons": [
4 "icons": [
5 {
5 {
6− "src": "/apps/sphinx/android-chrome-512x512.png",
6+ "src": "/apps/grid/android-chrome-512x512.png",
7 "sizes": "512x512",
7 "sizes": "512x512",
8 "type": "image/png"
8 "type": "image/png"
9 }
9 }
ui/src/components/Listing.tsx+6−6
@@ -3,7 +3,7 @@ import { deSig } from '@urbit/api';
3import React from 'react';
3import React from 'react';
4import { ListingImage } from './ListingImage';
4import { ListingImage } from './ListingImage';
5import { Listing as ListingType } from '../types/sphinx';
5import { Listing as ListingType } from '../types/sphinx';
6−import { channelURL, normalizeLink, refToURL } from '../utils';
6+import { normalizeLink, refToURL } from '../utils';
7import { Pals } from '../state/pals';
7import { Pals } from '../state/pals';
8import { ChannelPreview } from '../types';
8import { ChannelPreview } from '../types';
9import { useChannelPreview } from '../state/groups';
9import { useChannelPreview } from '../state/groups';
@@ -19,14 +19,14 @@ function getURL(link: string | { nest: string, where: string }, preview?: Channe
19 return link;
19 return link;
20 }
20 }
21
21
22− // Tlon routes channels under their group, which only the preview knows.
22+ const prefix = `/apps/groups`;
23− // A post deep link (post/:authorId/:postId) would also need the author,
23+ const suffix = `/${link.nest}/${link.where}`
24− // which listing refs don't carry, so link to the post's channel.
24+
25 if (!preview) {
25 if (!preview) {
26− return '/apps/groups/';
26+ return `${prefix}/1/chan${suffix}`;
27 }
27 }
28
28
29− return channelURL(preview.group.flag, link.nest);
29+ return `${prefix}/groups/${preview.group.flag}/channels${suffix}`;
30}
30}
31
31
32export const Listing = ({ hash, time, source, post, remove, removePal, outgoing }: ListingProps) => {
32export const Listing = ({ hash, time, source, post, remove, removePal, outgoing }: ListingProps) => {
ui/src/components/ListingImage.tsx+3−3
@@ -1,6 +1,6 @@
1+import { uxToHex } from '@urbit/api';
1import cn from 'classnames';
2import cn from 'classnames';
2import React from 'react';
3import React from 'react';
3−import { normalizeColor } from '../utils';
4
4
5interface ListingImageProps {
5interface ListingImageProps {
6 image: string;
6 image: string;
@@ -15,12 +15,12 @@ export const ListingImage = ({ image, color, className, imageClasses, colorClass
15 <img
15 <img
16 src={image}
16 src={image}
17 className={cn('object-cover', className, imageClasses)}
17 className={cn('object-cover', className, imageClasses)}
18− style={{ backgroundColor: color ? normalizeColor(color) : undefined }}
18+ style={{ backgroundColor: color ? `#${uxToHex(color)}` : undefined }}
19 />
19 />
20 ) : (
20 ) : (
21 <div
21 <div
22 className={cn('bg-rosy/20', className, colorClasses)}
22 className={cn('bg-rosy/20', className, colorClasses)}
23− style={{ backgroundColor: color ? normalizeColor(color) : undefined }}
23+ style={{ backgroundColor: color ? `#${uxToHex(color)}` : undefined }}
24 />
24 />
25 )
25 )
26}
26}
ui/src/components/Meta.tsx+2−4
@@ -3,8 +3,6 @@ import React, { useEffect, useState } from 'react';
3import api from '../api';
3import api from '../api';
4import { Logo } from './Logo';
4import { Logo } from './Logo';
5
5
6−const SOURCE = 'github.com/iko-soy/sphinx';
7−
8interface MetaProps {
6interface MetaProps {
9 className?: string;
7 className?: string;
10}
8}
@@ -32,9 +30,9 @@ export const Meta = ({ className }: MetaProps) => {
32 </div>
30 </div>
33 <a
31 <a
34 className="inline-block font-mono default-ring underline rounded-md hover:text-rosy"
32 className="inline-block font-mono default-ring underline rounded-md hover:text-rosy"
35− href={`https://${SOURCE}`}
33+ href="web+urbitgraph://group/~nocsyx-lassul/sphinx"
36 >
34 >
37− {SOURCE}
35+ ~nocsyx-lassul/sphinx
38 </a>
36 </a>
39 </footer>
37 </footer>
40 )
38 )
ui/src/components/TagField.tsx+54−67
@@ -3,7 +3,6 @@ import React, { Component, KeyboardEventHandler, useCallback, useState } from 'r
3import CreatableSelect from 'react-select/creatable';
3import CreatableSelect from 'react-select/creatable';
4import { ActionMeta, components, ControlProps, MultiValue, MultiValueGenericProps, MultiValueRemoveProps, OnChangeValue } from 'react-select';
4import { ActionMeta, components, ControlProps, MultiValue, MultiValueGenericProps, MultiValueRemoveProps, OnChangeValue } from 'react-select';
5import { XIcon } from '@heroicons/react/solid';
5import { XIcon } from '@heroicons/react/solid';
6−import { MAX_TAG_BYTES, MAX_TAGS, byteLength } from '../utils';
7
6
8export interface Option {
7export interface Option {
9 readonly label: string;
8 readonly label: string;
@@ -82,76 +81,64 @@ export const TagField = ({ tags, onTags, className }: TagFieldProps) => {
82 switch (event.key) {
81 switch (event.key) {
83 case 'Enter':
82 case 'Enter':
84 case 'Tab':
83 case 'Tab':
85− // %sphinx drops listings with more than 8 tags and doesn't index
86− // longer tags, so keep the input; Tab still moves on
87− if (tags.length >= MAX_TAGS || byteLength(input) > MAX_TAG_BYTES) {
88− if (event.key === 'Enter') event.preventDefault();
89− return;
90− }
91−
92− event.preventDefault();
93 setInput('');
84 setInput('');
94− if (!tags.some(t => t.value === input)) {
95− onTags([...tags, createOption(input)])
85+ onTags([...tags, createOption(input)])
96− }
86+ event.preventDefault();
97 }
87 }
98 }, [input, tags]);
88 }, [input, tags]);
99
89
100 return (
90 return (
101− <div className={className}>
102− <CreatableSelect<Option, true>
91+ <CreatableSelect<Option, true>
92+ className={className}
103− components={{
93+ components={{
104− ...components,
94+ ...components,
105− Control,
95+ Control,
106− MultiValueContainer: TagContainer,
96+ MultiValueContainer: TagContainer,
107− MultiValueLabel: TagLabel,
97+ MultiValueLabel: TagLabel,
108− MultiValueRemove: TagRemove,
98+ MultiValueRemove: TagRemove,
109− DropdownIndicator: null
99+ DropdownIndicator: null
110− }}
100+ }}
111− styles={{
101+ styles={{
112− control: (base) => ({}),
102+ control: (base) => ({}),
113− input: (base) => ({
103+ input: (base) => ({
114− ...base,
104+ ...base,
115− padding: 0,
105+ padding: 0,
116− margin: 0,
106+ margin: 0,
117− }),
107+ }),
118− clearIndicator: (base) => ({
108+ clearIndicator: (base) => ({
119− ...base,
109+ ...base,
120− cursor: 'pointer',
110+ cursor: 'pointer',
121− padding: 0
111+ padding: 0
122− }),
112+ }),
123− multiValue: (base) => ({
113+ multiValue: (base) => ({
124− ...base,
114+ ...base,
125− backgroundColor: '',
115+ backgroundColor: '',
126− margin: '0 2px',
116+ margin: '0 2px',
127− }),
117+ }),
128− multiValueRemove: (base) => ({
118+ multiValueRemove: (base) => ({
129− ...base,
119+ ...base,
130− paddingRight: '',
120+ paddingRight: '',
131− paddingLeft: '',
121+ paddingLeft: '',
132− '&:hover': {
122+ '&:hover': {
133− color: 'inherit',
123+ color: 'inherit',
134− backgroundColor: 'inherit',
124+ backgroundColor: 'inherit',
135− },
125+ },
136− }),
126+ }),
137− valueContainer: (base) => ({
127+ valueContainer: (base) => ({
138− ...base,
128+ ...base,
139− padding: 0
129+ padding: 0
140− })
130+ })
141− }}
131+ }}
142− isOptionDisabled={() => tags.length >= MAX_TAGS}
132+ isOptionDisabled={() => tags.length >= 8}
143− inputValue={input}
133+ inputValue={input}
144− isClearable
134+ isClearable
145− isMulti
135+ isMulti
146− menuIsOpen={false}
136+ menuIsOpen={false}
147− onChange={handleChange}
137+ onChange={handleChange}
148− onInputChange={handleInputChange}
138+ onInputChange={handleInputChange}
149− onKeyDown={handleKeyDown}
139+ onKeyDown={handleKeyDown}
150− placeholder="Type something and press enter..."
140+ placeholder="Type something and press enter..."
151− value={tags}
141+ value={tags}
152− />
142+ />
153− {byteLength(input) > MAX_TAG_BYTES && <div className='text-mauve/50 text-xs mt-1'>{MAX_TAG_BYTES} bytes per tag maximum</div>}
154− {tags.length >= MAX_TAGS && <div className='text-mauve/50 text-xs mt-1'>{MAX_TAGS} tags maximum</div>}
155− </div>
156 );
143 );
157}
144}
ui/src/env.d.ts−4
@@ -8,8 +8,4 @@ interface ImportMeta {
8
8
9declare module 'remove-markdown-and-html' {
9declare module 'remove-markdown-and-html' {
10 export default function removeMd(input: string): string;
10 export default function removeMd(input: string): string;
11−}
12−
13−declare module 'urbit-ob' {
14− export function isValidPatp(patp: string): boolean;
15}
11}
ui/src/manage-listings/AllListings.tsx+1−2
@@ -7,7 +7,6 @@ import { Paginator } from '../components/Paginator';
7import { useSearch } from '../state/search';
7import { useSearch } from '../state/search';
8import { Remove, Search } from '../types/sphinx';
8import { Remove, Search } from '../types/sphinx';
9import { PUBLISHED_KEY } from '../keys';
9import { PUBLISHED_KEY } from '../keys';
10−import { formatUd } from '../utils';
11
10
12interface RouteParams extends Record<string, string | undefined> {
11interface RouteParams extends Record<string, string | undefined> {
13 limit?: string;
12 limit?: string;
@@ -29,7 +28,7 @@ export const AllListings = () => {
29 key: (start, size) => ['all', start, size],
28 key: (start, size) => ['all', start, size],
30 fetcher: (start, size) => api.scry<Search>({
29 fetcher: (start, size) => api.scry<Search>({
31 app: 'sphinx',
30 app: 'sphinx',
32− path: `/lookup/all/${formatUd(start)}/${formatUd(size)}`
31+ path: `/lookup/all/${start}/${size}`
33 }),
32 }),
34 enabled: true,
33 enabled: true,
35 limit,
34 limit,
ui/src/manage-listings/Post.tsx+13−28
@@ -8,32 +8,17 @@ import { ErrorMessage } from '../components/ErrorMessage';
8import { Filter } from '../components/Filter';
8import { Filter } from '../components/Filter';
9import { Option, TagField } from '../components/TagField';
9import { Option, TagField } from '../components/TagField';
10import { Declare, Post as PostForm, PostType } from '../types/sphinx';
10import { Declare, Post as PostForm, PostType } from '../types/sphinx';
11−import {
12− MAX_DESCRIPTION_BYTES, MAX_TAG_BYTES, MAX_TAGS, MAX_TITLE_BYTES, MAX_URL_BYTES,
13− byteLength, isListingImage, isListingLink
14−} from '../utils';
15
11
16−function errorMessages(length: number, url?: string) {
12+function errorMessages(length: number) {
17 return {
13 return {
18 required: 'This field is required.',
14 required: 'This field is required.',
19− bytes: `The maximum length is ${length} bytes (accented letters and emoji count as 2-4).`,
15+ maxLength: `The maximum length is ${length}.`
20− url
21 }
16 }
22}
17}
23
18
24−// lib/sphinx +lengths-valid counts UTF-8 bytes and drops longer listings silently
25−const maxBytes = (max: number) => ({ bytes: (value: string) => byteLength(value || '') <= max });
26−
27−// what lib/validate-url rejects, which %sphinx also drops silently
28−const URL_RULES = 'no spaces, #fragments, non-ASCII letters or characters like | [ ] { }';
29−
30export const Post = () => {
19export const Post = () => {
31 const [params] = useSearchParams();
20 const [params] = useSearchParams();
32− // ?tags= can bring in tags that TagField wouldn't take
33− const defaultTags = params.getAll('tags')
21+ const defaultTags = params.getAll('tags').map(t => ({ label: t, value: t})) || [];
34− .filter((t, i, all) => t && byteLength(t) <= MAX_TAG_BYTES && all.indexOf(t) === i)
35− .slice(0, MAX_TAGS)
36− .map(t => ({ label: t, value: t}));
37 const [tags, setTags] = useState<MultiValue<Option>>(defaultTags);
22 const [tags, setTags] = useState<MultiValue<Option>>(defaultTags);
38 const [image, setImage] = useState<string>('');
23 const [image, setImage] = useState<string>('');
39 const form = useForm<PostForm>({
24 const form = useForm<PostForm>({
@@ -85,43 +70,43 @@ export const Post = () => {
85 <h1 className='text-2xl font-semibold'>Add a Listing</h1>
70 <h1 className='text-2xl font-semibold'>Add a Listing</h1>
86 </header>
71 </header>
87 <FormProvider {...form}>
72 <FormProvider {...form}>
88− {/* only our checks: the browser's type="url" check refuses relative links like /1/group/~zod/x */}
89− <form noValidate onSubmit={handleSubmit(onSubmit)}>
73+ <form onSubmit={handleSubmit(onSubmit)}>
90 <div className='flex w-full space-x-6'>
74 <div className='flex w-full space-x-6'>
91 <div className='flex-1 space-y-3'>
75 <div className='flex-1 space-y-3'>
92 <div>
76 <div>
93 <label htmlFor='title' className='text-sm font-semibold'>Title</label>
77 <label htmlFor='title' className='text-sm font-semibold'>Title</label>
94 <div className='flex items-center space-x-2'>
78 <div className='flex items-center space-x-2'>
95− <input {...register('title', { required: true, validate: maxBytes(MAX_TITLE_BYTES) })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='sphinx'/>
79+ <input {...register('title', { required: true, maxLength: 77 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='sphinx'/>
96 <Filter selected={type} onSelect={(value) => setValue('type', value)} />
80 <Filter selected={type} onSelect={(value) => setValue('type', value)} />
97 </div>
81 </div>
98− <ErrorMessage className='mt-1' field="title" messages={errorMessages(MAX_TITLE_BYTES)}/>
82+ <ErrorMessage className='mt-1' field="title" messages={errorMessages(77)}/>
99 </div>
83 </div>
100 <div className='flex items-center space-x-6'>
84 <div className='flex items-center space-x-6'>
101 <div className='flex-1 space-y-3'>
85 <div className='flex-1 space-y-3'>
102 <div>
86 <div>
103 <label htmlFor='image' className='text-sm font-semibold'>Image</label>
87 <label htmlFor='image' className='text-sm font-semibold'>Image</label>
104 <input type="url" {...register('image', {
88 <input type="url" {...register('image', {
105− validate: { ...maxBytes(MAX_URL_BYTES), url: isListingImage }
89+ maxLength: 1024
106 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='https://nyc3.digitaloceanspaces.com/...' />
90 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='https://nyc3.digitaloceanspaces.com/...' />
107− <ErrorMessage className='mt-1' field="image" messages={errorMessages(MAX_URL_BYTES, `Use a web address (https://host/path) with ${URL_RULES}.`)}/>
91+ <ErrorMessage className='mt-1' field="image" messages={errorMessages(1024)}/>
108 </div>
92 </div>
109 <div>
93 <div>
110 <label htmlFor='link' className='text-sm font-semibold'>Link</label>
94 <label htmlFor='link' className='text-sm font-semibold'>Link</label>
111− <input type="url" {...register('link', { required: true, validate: { ...maxBytes(MAX_URL_BYTES), url: isListingLink } })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='web+urbitgraph://~donmec-sallud/sphinx' />
95+ <input type="url" {...register('link', { required: true, maxLength: 1024 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='web+urbitgraph://~nocsyx-lassul/sphinx' />
112− <ErrorMessage className='mt-1' field="link" messages={errorMessages(MAX_URL_BYTES, `Use a link with ${URL_RULES}.`)} />
96+ <ErrorMessage className='mt-1' field="link" messages={errorMessages(1024)} />
113 </div>
97 </div>
114 </div>
98 </div>
115 <img className='flex-none object-cover w-28 h-28 mt-4 border-2 border-dashed border-mauve/60 rounded-lg' src={image || undefined} />
99 <img className='flex-none object-cover w-28 h-28 mt-4 border-2 border-dashed border-mauve/60 rounded-lg' src={image || undefined} />
116 </div>
100 </div>
117 <div>
101 <div>
118 <label htmlFor='description' className='text-sm font-semibold'>Description</label>
102 <label htmlFor='description' className='text-sm font-semibold'>Description</label>
119− <textarea {...register('description', { required: true, validate: maxBytes(MAX_DESCRIPTION_BYTES) })} rows={2} className='align-middle w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='An app for answering your riddles' />
103+ <textarea {...register('description', { required: true, maxLength: 256 })} rows={2} className='align-middle w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='An app for answering your riddles' />
120− <ErrorMessage className='mt-1' field="description" messages={errorMessages(MAX_DESCRIPTION_BYTES)} />
104+ <ErrorMessage className='mt-1' field="description" messages={errorMessages(256)} />
121 </div>
105 </div>
122 <div>
106 <div>
123 <label className='text-sm font-semibold'>Tags</label>
107 <label className='text-sm font-semibold'>Tags</label>
124 <TagField tags={tags} onTags={setTags} />
108 <TagField tags={tags} onTags={setTags} />
109+ {tags.length === 8 && <div className='text-mauve/50 text-xs mt-1'>8 tags maximum</div>}
125 </div>
110 </div>
126 <div className='pt-3'>
111 <div className='pt-3'>
127 <div className='flex justify-between border-t border-zinc-300 py-3'>
112 <div className='flex justify-between border-t border-zinc-300 py-3'>
ui/src/pages/Search.tsx+3−3
@@ -14,7 +14,7 @@ import { usePals } from '../state/pals';
14import { TagCloud } from '../components/TagCloud';
14import { TagCloud } from '../components/TagCloud';
15import { useTags } from '../state/tags';
15import { useTags } from '../state/tags';
16import { useSearch } from '../state/search';
16import { useSearch } from '../state/search';
17−import { encodeLookup, formatUd } from '../utils';
17+import { encodeLookup } from '../utils';
18import { lookupKey } from '../keys';
18import { lookupKey } from '../keys';
19
19
20interface RouteParams extends Record<string, string | undefined> {
20interface RouteParams extends Record<string, string | undefined> {
@@ -46,7 +46,7 @@ export const Search = () => {
46 key: lookupKey(selected, lookup),
46 key: lookupKey(selected, lookup),
47 fetcher: (start, size) => api.scry<SearchType>({
47 fetcher: (start, size) => api.scry<SearchType>({
48 app: 'sphinx',
48 app: 'sphinx',
49− path: `/lookup/${selected}/${formatUd(start)}/${formatUd(size)}/${encodeLookup(lookup)}`
49+ path: `/lookup/${selected}/${start}/${size}/${encodeLookup(lookup)}`
50 }),
50 }),
51 enabled: !!lookup,
51 enabled: !!lookup,
52 limit,
52 limit,
@@ -88,7 +88,7 @@ export const Search = () => {
88 </header>
88 </header>
89 {!palsInstalled && (
89 {!palsInstalled && (
90 <div>
90 <div>
91− <a href="/apps/landscape/search/~paldev/apps/~paldev/pals" className='inline-flex items-center py-1 px-2 mr-2 font-semibold bg-green-800 text-linen rounded-md'>
91+ <a href="/apps/grid/leap/search/~paldev/apps/~paldev/pals" className='inline-flex items-center py-1 px-2 mr-2 font-semibold bg-green-800 text-linen rounded-md'>
92 <PlusSmIcon className='h-4 w-4' />
92 <PlusSmIcon className='h-4 w-4' />
93 install pals
93 install pals
94 </a>
94 </a>
ui/src/pages/Tag.tsx+2−2
@@ -11,7 +11,7 @@ import { TagCloud } from '../components/TagCloud';
11import { useSearch } from '../state/search';
11import { useSearch } from '../state/search';
12import { useTags } from '../state/tags';
12import { useTags } from '../state/tags';
13import { Remove, Search } from '../types/sphinx';
13import { Remove, Search } from '../types/sphinx';
14−import { encodeLookup, formatUd } from '../utils';
14+import { encodeLookup } from '../utils';
15import { tagKey } from '../keys';
15import { tagKey } from '../keys';
16
16
17interface RouteParams extends Record<string, string | undefined> {
17interface RouteParams extends Record<string, string | undefined> {
@@ -34,7 +34,7 @@ export const Tag = () => {
34 key: tagKey(tag),
34 key: tagKey(tag),
35 fetcher: (start, size) => api.scry<Search>({
35 fetcher: (start, size) => api.scry<Search>({
36 app: 'sphinx',
36 app: 'sphinx',
37− path: `/lookup/tag/${formatUd(start)}/${formatUd(size)}/${encodeLookup(tag)}`
37+ path: `/lookup/tag/${start}/${size}/${encodeLookup(tag)}`
38 }),
38 }),
39 enabled: !!tag,
39 enabled: !!tag,
40 limit,
40 limit,
ui/src/state/apps.ts+4−7
@@ -3,19 +3,16 @@ import { useQuery } from "@tanstack/react-query"
3import api from "../api";
3import api from "../api";
4import { Post, Search } from "../types/sphinx";
4import { Post, Search } from "../types/sphinx";
5import { APP_LISTINGS_KEY, CHARGES_KEY, VATS_KEY } from "../keys";
5import { APP_LISTINGS_KEY, CHARGES_KEY, VATS_KEY } from "../keys";
6−import { MAX_DESCRIPTION_BYTES, MAX_TITLE_BYTES, listingImage, truncateBytes } from "../utils";
7
6
8function getAppPost(app: string, pike: Pike, charge: Charge): Post {
7function getAppPost(app: string, pike: Pike, charge: Charge): Post {
9 const ship = pike.sync?.ship || `~${window.ship}`;
8 const ship = pike.sync?.ship || `~${window.ship}`;
10
9
11 return {
10 return {
12 type: 'app',
11 type: 'app',
13− title: truncateBytes(charge.title, MAX_TITLE_BYTES),
12+ title: charge.title,
14− // the desk on the publisher, which may differ from the local name
15− // (|install ~ship %desk, =local %other)
16− link: `/1/desk/${ship}/${pike.sync?.desk || app}`,
13+ link: `/1/desk/${ship}/${app}`, //`/apps/grid/search/${ship}/apps/${ship}/${app}`,
17− description: truncateBytes(charge.info || charge.title, MAX_DESCRIPTION_BYTES),
14+ description: charge.info || charge.title,
18− image: listingImage(charge.image),
15+ image: charge.image || '',
19 color: charge.color,
16 color: charge.color,
20 tags: ['app']
17 tags: ['app']
21 }
18 }
ui/src/state/groups.ts+8−9
@@ -4,7 +4,7 @@ import { PostType, Search } from "../types/sphinx";
4import { ChannelPreview, Groups } from "../types";
4import { ChannelPreview, Groups } from "../types";
5import { useMemo } from "react";
5import { useMemo } from "react";
6import { GROUPS_KEY, GROUP_LISTINGS_KEY } from "../keys";
6import { GROUPS_KEY, GROUP_LISTINGS_KEY } from "../keys";
7−import { MAX_DESCRIPTION_BYTES, MAX_TITLE_BYTES, getColor, getImage, isColor, listingImage, truncateBytes } from "../utils";
7+import { getColor, getImage, isColor } from "../utils";
8
8
9export const usePublicGroups = () => {
9export const usePublicGroups = () => {
10 const { data, ...query } = useQuery(GROUPS_KEY,
10 const { data, ...query } = useQuery(GROUPS_KEY,
@@ -37,11 +37,11 @@ export const useGroups = () => {
37 loading: loading || isLoading,
37 loading: loading || isLoading,
38 groups: groups.map(([k,v]) => ({ key: k, post: {
38 groups: groups.map(([k,v]) => ({ key: k, post: {
39 type: 'group' as PostType,
39 type: 'group' as PostType,
40− title: truncateBytes(v.meta.title, MAX_TITLE_BYTES),
40+ title: v.meta.title,
41 link: `/1/group/${k}`, //`/apps/groups/groups/${k}`,
41 link: `/1/group/${k}`, //`/apps/groups/groups/${k}`,
42− image: listingImage(getImage(v.meta.image) || getImage(v.meta.cover)),
42+ image: getImage(v.meta.image) || getImage(v.meta.cover) || '',
43 color: getColor(v.meta.image) || getColor(v.meta.cover) || '',
43 color: getColor(v.meta.image) || getColor(v.meta.cover) || '',
44− description: truncateBytes(v.meta.description, MAX_DESCRIPTION_BYTES),
44+ description: v.meta.description,
45 tags: ['group']
45 tags: ['group']
46 }}))
46 }}))
47 .filter(({ post }) => !d || d.listings.length === 0 || !d.listings.some(l => l.post.link === post.link || l.post.title === post.title))
47 .filter(({ post }) => !d || d.listings.length === 0 || !d.listings.some(l => l.post.link === post.link || l.post.title === post.title))
@@ -49,17 +49,15 @@ export const useGroups = () => {
49}
49}
50
50
51export function useChannelPreview(nest: string): ChannelPreview | undefined {
51export function useChannelPreview(nest: string): ChannelPreview | undefined {
52− const { groups, isFetched } = usePublicGroups();
52+ const { groups } = usePublicGroups();
53 const group = groups.find(([,g]) => {
53 const group = groups.find(([,g]) => {
54 return !!g.channels[nest];
54 return !!g.channels[nest];
55 });
55 });
56
56
57− // %groups asks the channel's host and answers once (mark channel-preview-1)
58 const { data } = useQuery(['channel-preview', nest],
57 const { data } = useQuery(['channel-preview', nest],
59− () => api.subscribeOnce<ChannelPreview>('groups', `/v1/channels/${nest}/preview`, 5000),
58+ () => api.subscribeOnce<ChannelPreview>('groups', `/chan/${nest}`, 5000),
60 {
59 {
61− // only for channels outside our groups, so wait until those are known
62− enabled: isFetched && !!nest && !group,
60+ enabled: groups && !!nest && !group,
63 refetchOnMount: false,
61 refetchOnMount: false,
64 refetchOnWindowFocus: false,
62 refetchOnWindowFocus: false,
65 },
63 },
@@ -71,6 +69,7 @@ export function useChannelPreview(nest: string): ChannelPreview | undefined {
71 group: {
69 group: {
72 flag: group[0],
70 flag: group[0],
73 meta: group[1].meta,
71 meta: group[1].meta,
72+ secret: group[1].secret
74 }
73 }
75 } : data;
74 } : data;
76}
75}
ui/src/state/posts.ts+38−49
@@ -1,39 +1,35 @@
1+import _ from "lodash";
1import { useQuery } from "@tanstack/react-query"
2import { useQuery } from "@tanstack/react-query"
2import api from "../api"
3import api from "../api"
3import { PostType, Search } from "../types/sphinx";
4import { PostType, Search } from "../types/sphinx";
4import { usePublicGroups } from "./groups";
5import { usePublicGroups } from "./groups";
5import { POST_LISTINGS_KEY, notesKey } from "../keys";
6import { POST_LISTINGS_KEY, notesKey } from "../keys";
6−import { ChannelsResponse, Diary, GroupChannel, NoteContent, PagedChannelPosts, VerseInline, inlineToString } from "../types";
7+import { Diary, DiaryOutlines, GroupChannel, NoteContent, Shelf, VerseInline, inlineToString } from "../types";
7import { useMemo } from "react";
8import { useMemo } from "react";
8import { udToDec } from '@urbit/api';
9import { udToDec } from '@urbit/api';
9−import { MAX_DESCRIPTION_BYTES, MAX_TITLE_BYTES, listingImage, normalizeLink, truncateBytes } from "../utils";
10−
11−// notebooks are %channels channels of kind %diary, keyed by nest
12−// "diary/~ship/name" (the old %diary agent is gone from the %groups desk)
13−const DIARY_PREFIX = 'diary/';
14
10
15export const useNotebooks = () => {
11export const useNotebooks = () => {
16 const { groups, isLoading: groupsLoading } = usePublicGroups();
12 const { groups, isLoading: groupsLoading } = usePublicGroups();
17− const { data, isLoading } = useQuery(notesKey(), () => api.scry<ChannelsResponse>({
13+ const { data, isLoading } = useQuery(notesKey(), () => api.scry<Shelf>({
18− app: 'channels',
14+ app: 'diary',
19− path: '/v1/channels'
15+ path: '/shelf'
20 }));
16 }));
21−
17+
22 const notebooks = useMemo(() => {
18 const notebooks = useMemo(() => {
23 const nbs: Record<string, GroupChannel & Diary> = {};
19 const nbs: Record<string, GroupChannel & Diary> = {};
20+ debugger;
24 if (!data) return nbs;
21 if (!data) return nbs;
25
22
26− Object.entries(data).forEach(([nest, v]) => {
23+ Object.entries(data).forEach(([k,v]) => {
27− if (!nest.startsWith(DIARY_PREFIX)) return;
28− const flag = nest.slice(DIARY_PREFIX.length);
29 const group = groups.find(([g]) => g === v.perms.group);
24 const group = groups.find(([g]) => g === v.perms.group);
30 if (!group) return;
25 if (!group) return;
31
26
27+ debugger;
32− const channel = group[1].channels[nest];
28+ const channel = group[1].channels[`diary/${k}`];
33 if (!channel) return;
29 if (!channel) return;
34
30
35 if (channel.readers.length === 0) {
31 if (channel.readers.length === 0) {
36− nbs[flag] = { ...v, ...channel };
32+ nbs[k] = { ...v, ...channel };
37 }
33 }
38 });
34 });
39
35
@@ -50,38 +46,29 @@ export const useNotebooks = () => {
50export const usePosts = (ship: string, name: string, type: PostType, tags: string[]) => {
46export const usePosts = (ship: string, name: string, type: PostType, tags: string[]) => {
51 const flag = `${ship}/${name}`;
47 const flag = `${ship}/${name}`;
52 const { notebooks, loading } = useNotebooks();
48 const { notebooks, loading } = useNotebooks();
53− const { data, isLoading } = useQuery(notesKey(flag), () => api.scry<PagedChannelPosts>({
49+ const { data, isLoading } = useQuery(notesKey(flag), () => api.scry<DiaryOutlines>({
54− app: 'channels',
50+ app: 'diary',
55− path: `/v1/${DIARY_PREFIX}${flag}/posts/newest/9.999.999/outline`
51+ path: `/diary/${flag}/notes/newest/9.999.999/outline`
56 }));
52 }));
57− // every listing we have, ours or a mutual's, whatever its tags or type
58− // (%sphinx refuses to publish a listing it already has)
59 const { data: d, isLoading: postsLoading } = useQuery(POST_LISTINGS_KEY, () => api.scry<Search>({
53 const { data: d, isLoading: postsLoading } = useQuery(POST_LISTINGS_KEY, () => api.scry<Search>({
60 app: 'sphinx',
54 app: 'sphinx',
61− path: '/lookup/all/0/9.999.999'
55+ path: '/lookup/tag/0/9.999/~~post'
62 }));
56 }));
63−
57+
64− const items = data ? Object.entries(data.posts)
58+ const items = data ? Object.entries(data)
65− .flatMap(([id, post]) => {
59+ .map(([date, outline]) => {
66− if (!post) return []; // deleted post
60+ return { key: date.toString(), post: {
67− const diary = post.essay['kind-data']?.diary;
68− return [{ key: id, post: {
69 type,
61 type,
70− title: truncateBytes(diary?.title || '', MAX_TITLE_BYTES),
62+ title: outline.title,
71− link: getPermalink(flag, id),
63+ link: getPermalink(flag, date),
72− image: listingImage(diary?.image),
64+ image: outline.image,
73 color: '',
65 color: '',
74− description: getSnippet(post.essay.content),
66+ description: getSnippet(outline.content),
75 tags
67 tags
76− }}];
68+ }}
77 }) : [];
69 }) : [];
78
70
79− // listings store refs; older ones may still be web+urbitgraph links
80− const listed = useMemo(
81− () => new Set((d?.listings || []).map(l => normalizeLink(l.post.link))),
82− [d]
83− );
84− const posts = items.filter(p => !listed.has(p.post.link));
71+ const posts = items.filter(p => !d || d.listings.length === 0 || !d.listings.find(l => l.post.link === p.post.link));
85
72
86 return {
73 return {
87 notebook: notebooks[flag],
74 notebook: notebooks[flag],
@@ -90,19 +77,21 @@ export const usePosts = (ship: string, name: string, type: PostType, tags: strin
90 }
77 }
91}
78}
92
79
93−function getPermalink(flag: string, id: string): string {
80+function getPermalink(flag: string, time: string): string {
94− return `/1/chan/${DIARY_PREFIX}${flag}/note/${udToDec(id)}`;
81+ return `/1/chan/diary/${flag}/note/${udToDec(time)}`;
95}
82}
96
83
97−// the first paragraph with any text, as plain text
98function getSnippet(content: NoteContent): string {
84function getSnippet(content: NoteContent): string {
99− const inlines = content.filter(c => 'inline' in c) as VerseInline[];
85+ const inlines = content.filter(c => 'inlines' in c) as VerseInline[];
100− for (const verse of inlines) {
86+ if (inlines.length === 0) return '';
101− const text = verse.inline.map(inlineToString).join('').replace(/\s+/g, ' ').trim();
102− if (text) {
103− return truncateBytes(text, MAX_DESCRIPTION_BYTES);
104− }
105− }
106
87
107− return '';
88+ const text = inlines[0].inlines.map(inlineToString).join(' ');
89+ const end = text.length > 256 ? 253 : text.length;
90+ let start = text.substring(0, end);
91+ const length = new Blob([start]).size;
92+ if (length > end) {
93+ start = start.substring(0, end - (length - end))
94+ }
95+
96+ return start === text ? start : `${start}...`;
108}
97}
ui/src/types/index.ts+34−92
@@ -34,14 +34,11 @@ export interface Groups {
34 [flag: string]: Group;
34 [flag: string]: Group;
35}
35}
36
36
37−// %groups /v1/channels/<kind>/<ship>/<name>/preview (mark channel-preview-1).
38−// Previews built from our own groups only fill in flag and meta.
39export interface GroupPreview {
37export interface GroupPreview {
40 flag: string;
38 flag: string;
41 meta: GroupMeta;
39 meta: GroupMeta;
42 time?: number;
40 time?: number;
43− 'member-count'?: number;
41+ secret: boolean;
44− privacy?: 'public' | 'private' | 'secret';
45}
42}
46
43
47export interface ChannelPreview {
44export interface ChannelPreview {
@@ -103,18 +100,6 @@ export interface Link {
103 };
100 };
104}
101}
105
102
106−export interface Task {
107− task: {
108− checked: boolean;
109− content: Inline[];
110− };
111−}
112−
113−// a role mention
114−export interface Sect {
115− sect: string | null;
116−}
117−
118export type Inline =
103export type Inline =
119 | string
104 | string
120 | Bold
105 | Bold
@@ -127,9 +112,7 @@ export type Inline =
127 | Blockquote
112 | Blockquote
128 | BlockReference
113 | BlockReference
129 | Tag
114 | Tag
130− | Link
115+ | Link;
131− | Task
132− | Sect;
133
116
134export type InlineKey =
117export type InlineKey =
135 | 'italics'
118 | 'italics'
@@ -179,126 +162,85 @@ export function isShip(item: unknown): item is Ship {
179 return typeof item === 'object' && item !== null && 'ship' in item;
162 return typeof item === 'object' && item !== null && 'ship' in item;
180}
163}
181
164
182−export function isTag(item: unknown): item is Tag {
183− return typeof item === 'object' && item !== null && 'tag' in item;
184−}
185−
186−export function isTask(item: unknown): item is Task {
187− return typeof item === 'object' && item !== null && 'task' in item;
188−}
189−
190−// plain text of an inline; text runs carry their own whitespace
191−export function inlineToString(inline: Inline): string {
165+export function inlineToString(inline: Inline): any {
192 if (typeof inline === 'string') {
166 if (typeof inline === 'string') {
193 return inline;
167 return inline;
194 }
168 }
195
169
196 if (isBold(inline)) {
170 if (isBold(inline)) {
197− return inline.bold.map(inlineToString).join('');
171+ return inline.bold.map((i: Inline) => inlineToString(i)).join(' ');
198 }
172 }
199
173
200 if (isItalics(inline)) {
174 if (isItalics(inline)) {
201− return inline.italics.map(inlineToString).join('');
175+ return inline.italics.map((i: Inline) => inlineToString(i));
202 }
176 }
203
177
204 if (isStrikethrough(inline)) {
178 if (isStrikethrough(inline)) {
205− return inline.strike.map(inlineToString).join('');
179+ return inline.strike.map((i: Inline) => inlineToString(i));
206 }
180 }
207
181
208 if (isLink(inline)) {
182 if (isLink(inline)) {
209− return inline.link.content || inline.link.href;
183+ return inline.link.content;
210 }
184 }
211
185
212 if (isBlockquote(inline)) {
186 if (isBlockquote(inline)) {
187+ return Array.isArray(inline.blockquote)
213− return inline.blockquote.map(inlineToString).join('');
188+ ? inline.blockquote.map((i) => inlineToString(i)).join(' ')
189+ : inline.blockquote;
214 }
190 }
215
191
216 if (isInlineCode(inline)) {
192 if (isInlineCode(inline)) {
217− return inline['inline-code'];
193+ return typeof inline['inline-code'] === 'object'
218− }
194+ ? inlineToString(inline['inline-code'])
219−
195+ : inline['inline-code'];
220− if (isBlockCode(inline)) {
221− return inline.code;
222− }
223−
224− if (isTag(inline)) {
225− return inline.tag;
226− }
227−
228− if (isTask(inline)) {
229− return inline.task.content.map(inlineToString).join('');
230 }
196 }
231
197
232 if (isShip(inline)) {
198 if (isShip(inline)) {
233 return inline.ship;
199 return inline.ship;
234 }
200 }
235
201
236− if (isBreak(inline)) {
237− return ' ';
238− }
239−
240 return '';
202 return '';
241}
203}
242
204
243−// story verses (lib/story-json): {inline: [...]} or {block: {...}}
244export interface VerseInline {
205export interface VerseInline {
245− inline: Inline[];
206+ inlines: Inline[];
246}
207}
247
208
248export interface VerseBlock {
209export interface VerseBlock {
249 block: any;
210 block: any;
250−}
211+}
251
212
252export type Verse = VerseInline | VerseBlock;
213export type Verse = VerseInline | VerseBlock;
253
214
254export type NoteContent = Verse[];
215export type NoteContent = Verse[];
255
216
256−export interface DiaryPerm {
217+export interface NoteEssay {
257− writers: string[];
218+ title: string;
258− group: string;
219+ image: string;
259−}
220+ content: NoteContent;
260−
221+ author: string;
261−export interface Diary {
222+ sent: number;
262− perms: DiaryPerm;
263}
223}
264
224
265−// %channels /v1/channels (mark %channels): keyed by nest "<kind>/~ship/name";
266−// posts are always empty here
267−export interface ChannelSummary extends Diary {
225+export interface DiaryOutline extends NoteEssay {
268− posts: Record<string, unknown>;
226+ type: 'outline';
227+ quipCount: number;
269− order: string[];
228+ quippers: string[];
270− view: string;
271− sort: string;
272}
229}
273
230
274−export interface ChannelsResponse {
231+export interface DiaryOutlines {
275− [nest: string]: ChannelSummary;
232+ [time: string]: DiaryOutline;
276}
233}
277
234
278−// %channels /v1/<kind>/~ship/name/posts/newest/<count>/outline
279−// (mark %channel-posts)
280−export interface ChannelEssay {
235+export interface DiaryPerm {
281− content: NoteContent;
282− author: string;
236+ writers: string[];
283− sent: number;
237+ group: string;
284− 'kind-data': {
285− diary?: { title: string; image: string };
286− heap?: string | null;
287− chat?: { notice: null } | null;
288− };
289}
238}
290
239
291−export interface ChannelPost {
240+export interface Diary {
292− seal: { id: string };
241+ perms: DiaryPerm;
293− revision: string;
294− essay: ChannelEssay;
295− type: 'post';
296}
242}
297
243
298−export interface PagedChannelPosts {
244+export interface Shelf {
299− // keyed by @ud post id ("170.141..."); null for a deleted post
245+ [key: string]: Diary;
300− posts: { [id: string]: ChannelPost | null };
301− newer: string | null;
302− older: string | null;
303− total: number;
304}
246}
ui/src/utils.ts+2−113
@@ -1,6 +1,5 @@
1import { stringToTa, uxToHex } from "@urbit/api";
1import { stringToTa, uxToHex } from "@urbit/api";
2import { parseToRgba } from "color2k";
2import { parseToRgba } from "color2k";
3−import { isValidPatp } from "urbit-ob";
4
3
5export function encodeLookup(value: string | undefined) {
4export function encodeLookup(value: string | undefined) {
6 if (!value) {
5 if (!value) {
@@ -57,22 +56,7 @@ export function normalizeLink(link: string): string {
57 return `/1/chan/diary/${parts[4]}/${parts[5]}/note/${parts[6]}`
56 return `/1/chan/diary/${parts[4]}/${parts[5]}/note/${parts[6]}`
58}
57}
59
58
60−// Tlon web routes (tlon-apps packages/app/navigation/linking.ts):
61−// /apps/groups/group/:groupId[/channel/:channelId], ids URI-encoded.
62−export function groupURL(flag: string): string {
63− return `/apps/groups/group/${encodeURIComponent(flag)}`;
64−}
65−
66−export function channelURL(flag: string, nest: string): string {
67− return `${groupURL(flag)}/channel/${encodeURIComponent(nest)}`;
68−}
69−
70export function refToURL(cite: string): string | { nest: string, where: string } {
59export function refToURL(cite: string): string | { nest: string, where: string } {
71− // plain web links (e.g. manual listings) are used as they are
72− if (/^https?:\/\//i.test(cite)) {
73− return cite;
74− }
75−
76 const parts = cite.slice(1).split('/');
60 const parts = cite.slice(1).split('/');
77 if (parts[0] !== '1') {
61 if (parts[0] !== '1') {
78 return '';
62 return '';
@@ -82,13 +66,13 @@ export function refToURL(cite: string): string | { nest: string, where: string }
82 if (type === 'desk') {
66 if (type === 'desk') {
83 const ship = parts[2];
67 const ship = parts[2];
84 const desk = parts[3];
68 const desk = parts[3];
85− return `/apps/landscape/search/${ship}/apps/${ship}/${desk}`;
69+ return `/apps/grid/search/${ship}/apps/${ship}/${desk}`;
86 }
70 }
87
71
88 if (type === 'group') {
72 if (type === 'group') {
89 const ship = parts[2];
73 const ship = parts[2];
90 const name = parts[3];
74 const name = parts[3];
91− return groupURL(`${ship}/${name}`);
75+ return `/apps/groups/find/${ship}/${name}`;
92 }
76 }
93
77
94 const chanType = parts[2];
78 const chanType = parts[2];
@@ -99,98 +83,3 @@ export function refToURL(cite: string): string | { nest: string, where: string }
99 where: parts.slice(5).join('/'),
83 where: parts.slice(5).join('/'),
100 }
84 }
101}
85}
102−
103−// a number as Hoon prints @ud: `slav %ud` reads 1.000 but not 1000
104−// (@urbit/api's decToUd turns 0 into '')
105−export function formatUd(n: number): string {
106− return String(n).replace(/\B(?=(\d{3})+$)/g, '.');
107−}
108−
109−const encoder = new TextEncoder();
110−
111−// lib/sphinx +lengths-valid limits, in UTF-8 bytes
112−export const MAX_TITLE_BYTES = 77;
113−export const MAX_DESCRIPTION_BYTES = 256;
114−export const MAX_URL_BYTES = 1024;
115−export const MAX_TAGS = 8;
116−// the longest tag +catalog indexes
117−export const MAX_TAG_BYTES = 64;
118−
119−export function byteLength(text: string): number {
120− return encoder.encode(text).length;
121−}
122−
123−// lib/validate-url (de-purl:html in zuse) as regular expressions: an optional
124−// scheme://host[:port] (host a DNS name or IPv4, or a ~ship without a port),
125−// then an optional /path and ?key=value&key=value query. No #fragment, no
126−// spaces or non-ASCII, none of | [ ] { } ^ ` \ and no '=' inside a value.
127−// %sphinx silently drops a listing whose image or link fails this.
128−const QUERY_CHAR = "(?:[A-Za-z0-9_.!~*'()+/?:,-]|%[0-9A-Fa-f]{2})";
129−const PATH_CHAR = "(?:[A-Za-z0-9_.!~*'()$&+,;=:@/-]|%[0-9A-Fa-f]{2})";
130−const PAIR = `${QUERY_CHAR}+(?:=${QUERY_CHAR}*)?`;
131−const PATH_QUERY = `(?:/${PATH_CHAR}*)?(?:[?](?:${PAIR}(?:[&;]${PAIR})*)?)?`;
132−const SCHEME = '[A-Za-z][A-Za-z0-9+.-]*://';
133−const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
134−const LAST_LABEL = '[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
135−const OCTET = '(?:0|[1-9][0-9]{0,2})';
136−const HOST = `(?:(?:${LABEL}[.])*${LAST_LABEL}|${OCTET}(?:[.]${OCTET}){3})(?::(?:0|[1-9][0-9]*))?`;
137−// the shape of a @p; isListingLink checks the syllables with isValidPatp
138−const SHIP = '~[a-z]{3}(?:[a-z]{3})?(?:--?[a-z]{6})*';
139−const IMAGE_RE = new RegExp(`^${SCHEME}${HOST}${PATH_QUERY}$`);
140−const LINK_RE = new RegExp(`^(?:${SCHEME}(?:${HOST}|(${SHIP})))?${PATH_QUERY}$`);
141−
142−export function isListingImage(image: string): boolean {
143− return !image || (byteLength(image) <= MAX_URL_BYTES && IMAGE_RE.test(image));
144−}
145−
146−export function isListingLink(link: string): boolean {
147− if (byteLength(link) > MAX_URL_BYTES) {
148− return false;
149− }
150−
151− const match = LINK_RE.exec(link);
152− return !!match && (!match[1] || isValidPatp(match[1]));
153−}
154−
155−// A listing image %sphinx accepts, or '' (so the listing goes out without
156−// one): http(s) URLs are percent-encoded by URL and lose their #fragment;
157−// anything else, data: URIs included (docket images may be one), is dropped.
158−export function listingImage(image: string | null | undefined): string {
159− if (!image) {
160− return '';
161− }
162−
163− try {
164− const url = new URL(image);
165− if (url.protocol !== 'http:' && url.protocol !== 'https:') {
166− return '';
167− }
168−
169− url.hash = '';
170− return isListingImage(url.href) ? url.href : '';
171− } catch (e) {
172− return '';
173− }
174−}
175−
176−// %sphinx measures listing fields in UTF-8 bytes (lib/sphinx +lengths-valid)
177−// and silently drops listings that are too long, so cut text to at most `max`
178−// bytes on a code point boundary, marking the cut with an ellipsis.
179−export function truncateBytes(text: string, max: number): string {
180− if (encoder.encode(text).length <= max) {
181− return text;
182− }
183−
184− const ellipsis = '...';
185− let out = '';
186− let size = 0;
187− for (const char of text) {
188− size += encoder.encode(char).length;
189− if (size > max - ellipsis.length) {
190− break;
191− }
192− out += char;
193− }
194−
195− return `${out.trimEnd()}${ellipsis}`;
196−}
ui/vite.config.ts+1−7
@@ -9,12 +9,6 @@ export default ({ mode }) => {
9 console.log(SHIP_URL);
9 console.log(SHIP_URL);
10
10
11 return defineConfig({
11 return defineConfig({
12− plugins: [urbitPlugin({ base: 'sphinx', target: SHIP_URL, changeOrigin: true, secure: false }), reactRefresh()],
12+ plugins: [urbitPlugin({ base: 'sphinx', target: SHIP_URL, changeOrigin: true, secure: false }), reactRefresh()]
13− // The entry chunk has no import or export, so esbuild 0.14 minifies it as
14− // a sloppy-mode script and hoists the block-level functions in
15− // @radix-ui/react-focus-scope and react-use-body-pointer-events in random
16− // order: rebuilds would give a different bundle and glob hash. Browsers
17− // run the bundle as a module, which is strict anyway.
18− build: { rollupOptions: { output: { banner: '"use strict";' } } }
19 });
13 });
20};
14};