From 09972ccde18a8d87d6e7271545f755865f3bc510 Mon Sep 17 00:00:00 2001 From: monster Date: Sun, 14 Jun 2026 16:08:59 +1000 Subject: [PATCH] TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 10 + Info.plist | 47 + README.md | 176 ++++ Resources/AppIcon.icns | Bin 0 -> 247714 bytes Sources/AppDelegate.swift | 262 ++++++ Sources/Appearance.swift | 147 ++++ Sources/Board.swift | 184 ++++ Sources/BoardContainer.swift | 1348 +++++++++++++++++++++++++++++ Sources/Document.swift | 556 ++++++++++++ Sources/DrawingCanvas.swift | 395 +++++++++ Sources/HTMLScanner.swift | 739 ++++++++++++++++ Sources/LineNumberRuler.swift | 148 ++++ Sources/Markdown.swift | 292 +++++++ Sources/PostgresStore.swift | 428 +++++++++ Sources/ScrapeProfile.swift | 70 ++ Sources/SettingsView.swift | 170 ++++ Sources/SyncEngine.swift | 209 +++++ Sources/TextMarkup.swift | 39 + Sources/WorkspaceController.swift | 564 ++++++++++++ Sources/main.swift | 10 + build.sh | 20 + demo.md | 23 + docs/BOARD_DESIGN.md | 75 ++ 23 files changed, 5912 insertions(+) create mode 100644 .gitignore create mode 100644 Info.plist create mode 100644 README.md create mode 100644 Resources/AppIcon.icns create mode 100644 Sources/AppDelegate.swift create mode 100644 Sources/Appearance.swift create mode 100644 Sources/Board.swift create mode 100644 Sources/BoardContainer.swift create mode 100644 Sources/Document.swift create mode 100644 Sources/DrawingCanvas.swift create mode 100644 Sources/HTMLScanner.swift create mode 100644 Sources/LineNumberRuler.swift create mode 100644 Sources/Markdown.swift create mode 100644 Sources/PostgresStore.swift create mode 100644 Sources/ScrapeProfile.swift create mode 100644 Sources/SettingsView.swift create mode 100644 Sources/SyncEngine.swift create mode 100644 Sources/TextMarkup.swift create mode 100644 Sources/WorkspaceController.swift create mode 100644 Sources/main.swift create mode 100755 build.sh create mode 100644 demo.md create mode 100644 docs/BOARD_DESIGN.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c14e9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Build artifact +TextPlus.app/ + +# Board / canvas sidecars (per-document runtime data) +*.board/ +*.canvas.json +*.canvas.png + +# macOS +.DS_Store diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..cc294e1 --- /dev/null +++ b/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleName + TextPlus + CFBundleDisplayName + TextPlus + CFBundleExecutable + TextPlus + CFBundleIdentifier + local.textplus + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 14.0 + NSPrincipalClass + NSApplication + NSHighResolutionCapable + + CFBundleDocumentTypes + + + CFBundleTypeName + Plain Text Document + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + + LSItemContentTypes + + public.plain-text + + NSDocumentClass + TextPlus.Document + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..71b35c6 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# TextPlus + +A TextEdit-style **plaintext-only** editor for macOS that can automatically +`scp` every file you save to another Mac on your Tailscale network — and that +grew a Markdown preview, themes, an HTML→Postgres scraping pipeline, and a +Freeform-style spatial **board**. + +No rich text, no smart quotes, no autocorrect — just text, plus a lot of hooks. + +## Three view modes + +The toolbar segmented control (top-left) and ⌃⌘1 / ⌃⌘2 / ⌃⌘3 switch between: + +- **Editor** — the plain text editor: line-number gutter, 5 themes, soft wrap, + font controls, find. +- **Split** — editor on the left, a live **Markdown preview** on the right. +- **Board** — a Freeform-style infinite, zoomable canvas. Wheel shortcuts: + **⌃-scroll zooms** (centered on the pointer), **⌥-scroll pans horizontally**, + **⌘-scroll pans vertically**; a plain wheel scrolls normally and pinch zooms. + Your document lives here as a **page card** you can place + anywhere; arrange clean **boxes**, drop **images**, and sketch with the pen. + **Double-click or press Return on the page to focus it** — the board zooms in + and the card becomes a normal text editor bound to the document. Click empty + space or press ⎋ to zoom back out. + +Board objects: + +- **Page card** (the main one) — *is* the `.txt`; editing it edits the document. + Set its size with the **ruler button** (US Letter / A4 / Legal / Square / + Wide 16:9 / Note) and the **P/L** orientation toggle in the palette. +- **Text pages** ("New text page" button) — extra pages created at the current + page size, freely resizable; saved in the sidecar, not the `.txt`. + **Select a text page and switch to the Editor tab to edit it full-screen** — + the Editor follows whichever page is selected on the board (the status bar + shows "Board note" when you're editing one of these instead of the main page). +- **Boxes** (Box tool, drag to draw) — clean translucent rectangles for diagrams, + draggable / resizable / grid-snapped. +- **Images** — the Add Image button, paste (⌘V), or drag a file onto the board. +- **Freehand** — pen / marker / line / arrow / oval / eraser, drawn behind the cards. + +One color drives the **pen, shapes, and boxes** together — click the color well +for the full picker, or the **eyedropper** to grab a color from anywhere on +screen. **Zoom to Fit** frames everything; the board exports to PNG or sends to +your tailnet like a saved file. + +**Right-click anywhere on the board** for a full nested menu — tools, new +page/image, page size & orientation, zoom, color (swatches / screen-pick / +picker), view mode, theme, undo/redo, export/send, plus per-card actions +(edit, bring to front / send to back, duplicate, delete) when you click a card. +Everything is reachable from the mouse. + +The **status bar** is mode-aware: in the editor it shows line/column, selection, +words/chars/lines and reading time; in board mode it shows the active tool, page +size/orientation (or the selected card's dimensions), zoom %, and card count. + +**Settings → Board** (⌘,) sets the default page size, orientation, and grid +snapping for new documents. + +The board layout persists in a sidecar bundle next to the document +(`foo.txt.board/`: `board.json` + image assets + a `preview.png`). The `.txt` +itself stays pure plaintext — open it in any other editor and you just see the +page's prose. Delete the `foo.txt.board/` folder to reset the board. + +## Build + +```sh +./build.sh +``` + +Produces `TextPlus.app` in this folder. Drag it to `/Applications` if you want +it in Launchpad/Spotlight, or just `open TextPlus.app`. + +Requires only the Xcode Command Line Tools (`xcode-select --install`) — no Xcode. + +## Set up sync + +1. Open **TextPlus → Settings…** (⌘,) +2. **Remote**: `user@host` of the target Mac. Tailscale MagicDNS names work + (`mini@studio`), as do raw Tailscale IPs (`mini@100.x.y.z`) and any alias + from your `~/.ssh/config` (then you can leave out the `user@` part). +3. **Folder**: the directory on the target Mac to drop files into, + e.g. `/Users/you/Documents/inbox`. Leave empty for the remote home folder. +4. Hit **Test Connection** — it checks both SSH auth and that the folder exists. +5. Turn on **Copy file to remote Mac on every save**. + +Now every ⌘S also fires an `scp` in the background. The window subtitle shows +`Syncing… → Synced ✓ 10:42` or the error if it failed. **File → Sync Now** +(⌥⌘S) re-pushes the current file any time, even with the toggle off. + +### One-time SSH key setup (if `ssh user@host` still asks for a password) + +On the target Mac: **System Settings → General → Sharing → Remote Login** on. + +On this Mac: + +```sh +ssh-keygen -t ed25519 # skip if ~/.ssh/id_ed25519 already exists +ssh-copy-id user@host # copies your key to the target Mac +ssh user@host # should now log in with no password prompt +``` + +TextPlus runs scp with `BatchMode=yes`, so it will never pop a password +prompt — if key auth isn't working, the sync just fails with the error shown +in the window subtitle. + +## HTML scraping tools + +Paste `` HTML copied from the browser inspector into a document, then: + +- **Tools → List Div & Class Selectors** (⌥⌘L) — opens a new document listing + every unique selector (`tag#id.class`) with a count and a sample of its text, + sorted by count so repeating listing rows float to the top. +- **Tools → Extract Text for Selector…** (⌥⌘E) — type a selector and get every + match's text in a new document, one match per line, table cells tab-separated. + Supports `tag`, `.class`, `#id`, combos (`tr.shortcut_navigable`), and + space-separated descendant chains (`table.table_block td`). No child + combinator (`>`) — use a space instead. + +Selector extras (work in the Extract dialog and in scrape profiles): + +- `@attr` at the end pulls an attribute instead of text, as the first column: + `a.item_description_title@href`, `span.price@data-pricevalue`. +- `[attr]` requires the attribute, `[!attr]` requires its absence, + `[attr=value]` exact match, `[attr^=value]` prefix: + `.item_condition span[!class]`, `a[href^=/seller/]`. + +Workflow: paste → ⌥⌘L → spot the selector with the right count/sample → ⌥⌘E. + +## Postgres ingest (the scraper pipeline) + +This turns the editor into the front end of your Discogs snapshot database. +Copy a marketplace page's `` from the inspector, paste it into a +document with **the page URL on the first line**, and either: + +- **Tools → Send Page to Postgres** (⌥⌘P) — parse and store right now, or +- turn on **auto-ingest** in Settings so every save stores automatically. + +What gets stored (tables auto-created on first connect): + +| Table | Contents | +|---|---| +| `tp_pages` | one row per captured page (url, source, content hash, counts, timestamp) | +| `tp_selectors` | the full selector summary for that page | +| `tp_rows` | every extracted row as JSONB, keyed to the page | +| `discogs_sellers` | `seller_id`, username — upserted by `data-seller-id` | +| `marketplace_snapshots` | one row per listing, upserted by `data-item-id` | + +`marketplace_snapshots` matches the schema from your scraper notes: +`item_id`, `release_id` (your bridge to the local XML dump), `seller_id`, +title, listing URL, media/sleeve condition, currency, `price_value`, +`shipping_value`, and `first_seen` / `last_seen` / `is_active` for tracking +state over time. Re-ingesting a page **upserts**: existing listings get +`last_seen` and price refreshed (`first_seen` preserved); new ones inserted. + +Setup: **Settings → Postgres**. The connection URI defaults to +`postgresql://localhost:5433/scraperrr` (port 5433 because a Tailscale ssh +tunnel already owns 5432 on this Mac). Hit **Test & Create Tables**, then flip +auto-ingest if you want it. Ingest shells out to `psql` +(`brew install postgresql@17`); it does **not** fetch pages — you paste the +DOM, which sidesteps Cloudflare entirely, exactly as your notes planned. + +Recognized pages (the `discogs_marketplace` profile): `sell/list`, +`sell/mywants`, and `seller//profile`. Any other HTML still gets its +page + selector summary stored; only the structured listing tables are +Discogs-specific. + +## Notes + +- Sync fires only on explicit saves (⌘S / Save As), never on autosave — + half-typed files don't get pushed. +- Files are written as UTF-8. Opening files in other encodings works + (encoding is auto-detected); they're converted to UTF-8 on save. +- `StrictHostKeyChecking=accept-new` is used: first connection to a new host + is trusted automatically (fine on a tailnet), but a *changed* host key is + still rejected. +- Settings live in `defaults read local.textplus`. diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..36119cd7676afe3f32567612ba29cc9923d4eccd GIT binary patch literal 247714 zcmeF&V{j%>+bHTMPn?PEWa3ORv28mO+sVYXZQHhO+nU(6tv&BoyY_dg&N;Pz?_Kj_ ztzONl?p|H%?z^sKW?*Fx03FUVGoWVx00{X)ZqDueKe z^+UwQhRO#JQm`?sOX!66_s)ibe!%Adkac|#b;(?W0)DHjrT~aO%)a8gcpzq`AAOHy zwZ87Yz7i?8JBhdeKGY%08;pI#AtBYEfPR;NAWicc00g<5hE;vyUG|fR2}b zwhpH6OX!BTL6Qywi}I=vH3$GglrZ28;G_8cmOYQ#BgD;y29Q(Y$Nk|4k^@7k-LNo#kVGzRar$(Y``AWyn{+c=QDv9U2W*_K2x(%n}@IuP<4QTl~vU}e`Ma6tD zf%V$;zk_3~A!_0paCApO)b|MS-u*Q z6XfW1~e1Syi`&var({&$tdSKvMAh)RMc1`_R}p!OA;33sWW%V|^{njPJH@LKlpE-t+?c+uu`Z~N?A_r&{9L_4 zt9Vf{3FyzY%+ls)^ER?TI1nUmgdc{1YhX)wf?43*7ZuVVc#I_R%J6mZe}0sDG`%*8es3G??tt3eRQP1y9Pj2@A_H(_BR0#u>EO#k0bED` z+$LOkefi#Zh`iojS%~{S5OhG69yl-`iWs0BAC@U%B`ff-2Z9Z#vJK)9`WWnwX#-o} z2Wx|j>Vqr?U)IY$1EBFQ&q2BO0kOf6@}azj;sX7q3tizWeU0|+LkI=8^Zj=Sl76tu zcReu}1)l{%tr4&~Kiu!EBakpbc{v6%w#OvDz!C);<%ky`Hhds*Y)!GArL~4`j|I>+K^?TbK)B@^a45sXtNLpzE?tn7_1QmL+&~}c0Z2*O#v_A zTsa2{uLQ0!z(L5ZC~1zJTw;mxFW%qS57>=?BKcUeeWpqc>}ftxLMA!WIkdUfxh6A` z$Cj{^L3qT0v;Ay(e)MK33Q{OV2#o$}L+^ySggd12MD*l0B%#EkgZ>CrK&u|KVp5e9X!S$Vk?V zUr*%9>PUFUej$0`0EY%A2xI~GB?`^++l3fJ3HvGWTS8pIevEJocZ_Vzb`0$Y6B#_p+r0o8MNyEq^1N?kyaZik2js$}zn&1u=axk(<%XuNSWr6y_!7Hs(hb zR_0t3YUTLP-^^mofzKVxYZbanH49ElREu8;XA5r$@CtrK`lj;x_lAE@&rRGx>W3$W z%%?E`7<@M%-}^=e7LOQDstBuyHV-w=!3bB+P!DO(a;J32j*c0Q7>*_h9A{5zr`D*j zsCcc&tuRpyR$W>-US?W;UkO`zT~=NxU0GcISPoyYuI=8-Ihfgp+NapVJY3#4++o^| z*sVC|n11mO&mK>riY!k&17}Ur@Lfx9PjQcNj}OiqPB_ApWt_&Pg{}rx(_Wcv%5PeG zyn8%)q%j308S&n@$n3eE~nHqtH9NSa^Tdr~s(s*$9juW_}p z%Z$p5{P)vYkK?^#o#W-1!pZ7s(%ICR?a9kA^O@!8_$m4E6AmF<1RM-p7G@o`8zu|3 z74|3&DK;F=UPNW&c%)7$RBAxFLwcW7q?Bwb59=QDHT;X~n3 zqNJj{qO@d8$vKPNihr7D9U&i)VyGpk3v}HSP-OoI-UDO_y?x>x&o)(@e?KhrRo@(uP?&hD`kC`m0Y&0)8Z^*7y zuV3YN`+7HiH3RN^!a*Q?AAKwNQwW=LaC(3Bg7o%(xAz;pffUkynz#pVW#Ei|KPta#ES3to6y8k=A&sRE;F}n4?T*ULBh5 zGJq1I=5>o)XH9NM9Yxpi1LeFT-XQMa%Ak%!R8mCpSb}o)P*xO2r<}3EAIG30sovBL zyRBPMGYXB zxt1B#>0jP;URV0pgMkp`K|3NZLqlXqMeoL{_P_2qY0IEeWQ1Y+dW<&e+>;nXXTUTaQ}_rWLY?vrW2lT;w^X8FZc$u8q$8)&;Kergk?} z{#0jmKDu;1lI>1jD!gjEugw`f9t@$CCT1yhE|xk^T_ybySn{ecv5>RM@T9%|e1(ie zaAw70N#+Ui=6X)JOTD=pq-D^7Xr^vD?1u3CxcTXxZri@o-uE<%_m+7;K;M46QODie zdFi?LaRkW^37H#U(^vfqAmgFCxe4%S6PrN61qfjRwDpx+6GW$9+IDf?Oi?NArL$_=4 zHsXPp6Dydl7)=G!nMIb=nrch>6UmpdS1s3dM>v*rR;deQ!+PY9JnqM_HoaUy-0c%-%5hhF?zSSC-zcIid1}^Q)Mfy^L5I- zMSCWDK6BQ3p9GBsRr;KWO^F8c?(?DPp=$?iCvOCy?T1<7V@6>qfu(h3*uxL=(z2?? z%f_o;dkF_|p8EynQRY?VSsGm?{JMGq@|&fvyIpa^g*Zkbj8X^4Xl*8$Km5z1RFao8 zD?3%1owD|U-kqDm(?JVEr$t$ZE0Q|uBN{K&6pdKgja`Q>8;u*C{q6JJRL>%+&ij+c zS<9QxT>~1WA8Me*khJg*u(FZV8=pKu1Va;=_Fku z-m09*T`pRv@Q^9gY!v$`$*o4Lz{oc)y(S_mt0FI}n!O0Ht}$t`Vlv?}p0iRnQP#~e zL^3`xIzg>5HJ#JZ}(;N9{8g4n_uEx3==8^Tx zew%#jhE;{qgmpvfK)t28w`W7;NBcV<5m;jGA>*JXqh=?|IiB0?P8QVeLsCvFV9X~) z(H2){s7|^hzg%W7RwSM`lPBA0AJ82B-0!AQ{khH&NKr(EM4`lEsc`5`ke}x4cx_*Cghso(7_2j4X=;J{+imv!g2DBhPWdg`JWFx8_7boOUE*gYW6yac5}#s0;4~;T`4Tn<3gMM5~KIsZHN;=tbOB@UCpVVmD~Cc%-$g-uD_B z`Tk@?P{um#S@HJkrFCC&N_llD#oBi*;HrsJgm=NI@X_T73&AG%6Cp6Pmu0&U%A5q! z4%87xD+rp5e)vaB-5z2~^fPIl+O2IE(il$}{;75v{;GD{_gC8>vmq6!H|q0>AdA^^ zN8G)P@^u@0GHpE`B`;zSjoy5LuD)EN**s|d(%n>K_Bde$lLB)?nlbITb|P=BT*X?+ zQpHxmgSJz&#_`j?(RA8@I{0cThj`nL8_%l@1X)m-$nKB@nM=IZJaaw!9dx+%I7>Oi z+3)oErdFrni#=RFyp7EB3^>h%sF0RAf zunWkmK-_@bzER>7V(@&_yaxjdeQ4X>8(V1dr~}d@(nYeRVx}UN0xdS}cx|sdHey!B zkL-bpk@KE-eDhS1<=OmE~8Jckp`?7b7!NB@tD$X)1rRXKD zDy^zFFMMy`eC@+LFMmY?IYm4B-on*!%by>WC@T03liHc>EY6-yAL7IPQ>j&>I8SX? z>$n$Kmw4wmEzHZj9CXaj`lbdhS1cTR^=fVSCmXmA0L5G)C%XHDWD|S14T+G}qjJ=<(wqH2zLoB*K zBIKo?5?JyEdeXexS)R=<)Rmgjg{;FT>=RhGrq zi|!kbKmj5_fpo>U_*`sLL^sKbSHloTZHNlEN8Bji@jmVy(repi5CMFoC{tkz!oe85 z5t)4+2?C{%G;(E?n`3BL95SGNa6m9gpdo&FR3-6XOirLCpkb-?A1tHyKRGa4A7Z@Zt7fOa2pA19Gu9{HffAp0P%*m@jw!fldr zVs88k3Fl8|=`N}7()3cJiYvl{{EOO?@*gEZ&BiIpbxvJV>>D(l;vb~`mc$M8k zA*pe#p@Z_{rsW=IWa;#4O70o{0r{1R9rlUi+p!1vhE{f9tWTQJ+S5;)nmkcmfnHC* zYu`Wo%)L5-mVIqR%f$8hU4zd9Jdv!jn34-J*iy4Hc;AOAV__R79;tKBS8<2d<2+9N zc8P)%7fJmJFN`=$jH!rzoRkHbBcCQrUFcx8tIi!SR=A|r8C{G$FBYA-;>l}?wmrBB zy_s6$!B!w}6QtteVO^yjcP#O&@N9hOUe7MXH?pJe(sQ(yJh?qrK90?#Z4Pxx56hNo zh_AtN;<>|jPa!PRekp>Y_dpi-lGOmf5J4FDz)e97bdlbDjtR|1fH1x=Ips43rWiw@ zjQ)eYG(AOn?lwp*-?$KXz_$3shy@Unk*;EqM<@&tU_$HolyX^SejfwxaUzh5pz3Ax z#pZlSdm;1)V#;DzwvTPFTCScE-!&Lm>oE33M|2ci6e{FJ6njg`dGU%$og?mqelvObHWUIb;@>jx709Vfze^#BpAo`e)O01|IR;+ z$ChQ@`t7;cdt7%idZvLDfQ62&#p-2PXCz`oqn~SXVw`BIr8jThZW>MYx-m$(_7XJC zsgjgmWEC1Peo4zU+QHOm;GyoR^6>)f0LAfR5!e@)@52`f5jqs|j9^E0XVw0>qn>A@ z;0fb=`m*wz6i+j)KPw|`a+G_u;8{Uk^w66ni)E*G^9+?Od zm>vL|A0S3*{Uf=DE(MZP=xSVmMGo~B!~P)0vQbnekk9Qn#dSr@i40UK?$RnFH zQ7OwY{sOt30FrkY`G}o{)ynBInX9p5(?j25ZQ#98Xhu{_I7P%o(C}A-2(|LMfB+vd z(d$>xJyxng9T>Zys~NbAumowqT-B{}q84)tM=L~oQ9_b$6DPf&(&@&Ww?PPF9HSHK z=y&59`$P96T;q$(T<_^X z9!N8=qhRWw(ZD0oj_A5j{E)=Z_6$Og3U+a>R$?XM3#+!f(8!4e>eurm+{|_I7j5u# zVcWrwIKTOGBg+Ffw7bOGMF(a)N0}o%40T#J7CV;CPKQF$gRn&<-?N&>m#IyhMb>$D zP%AZr68M{l9P4hpvu;H%*4wUwgaOzG{|nJ?vSe9K+~ywgHw@PFFO6SuFLld59ye}Z zFP<=1>v;pcY@f?If426wN_IxS{2dCO?z?v(!2!Z{bc-2^2K3E}pKo|CSDf$O<46msfrs*O9OuVZ9@S-g3SP6e_J4b3-;dv z0047<|7#0h4(NZk|FhF5RX6PKX=ma>{J&g4E;OVga7EF)7B?8{+lOS7(*eCyi0C7A z!l=H|uwvuiAxYqe*wu-E2g5xea%_YL$0%Wl$oxA2g+fudAjpt0#4Tzg?u6zYNXSUN zY+(Y|L5LmNbx}*4yc>g0O0u#lPY#Z<)%7>jQfBULZKoez52s2GQk5eGQiv&pbo{!0 zJzuoIQ)Nxw`^a#{-h^%y2PO<*J;efds@>n$ECYA%Q54pV%~(~`U+32%aEGubNr0BG zwQ&R&?fV$hMndFC^H|Elj)?6GuNe+x%x=nEEu=#9e6cCb=aXD}$A>?_?Ga~fu9Ek| zc=faVtgW7&C!4?-XecX5c_<)(3ZeWmIQ(e^Jf|!*_eX1Y#P6vQ!1~wooWBIK3}v;g z$^z)Mfog{?4Cq{End8$yU~$yD8!C$E--|wdx=3{#(h8kXM!m9Oh=2;{DdtVx6cTtp zvMMzx1FutuzibGvi_z(q4t=bAxxz})fY8!~ZNL}DbfzL9P^sD9wCkEKC#BQf3S_xl z_L&P=N6cOYet$ClV7h`s2GN8N{2retW$LhgDnu;d&Yf(~+6HCf__jN!1>gNvOiOg) z$$s8wK)z96uq2pn#_8EYaqFJ!TX<|rs-wb>Qi^__n4dxLu=W8PugB?W!#?fQrmPB| z*}<02;>Qa*Z-e^dY|5b)93N&EmUDr->L;?`D2xcDnq@a#K zQLL0C=`*GP6Amd{7!yi_eElbx-ZmdamCyaxq@59@D?$JqTfd*}j%X^HnPE0AgVd`w zw!;@o2ra;k$a?KwtN>D*B#hPi2U_RRJlwoLWxW%}EjsO9bSz!ZS6E4eo_{SbV`yHA z)jD0vwb;W^h$GRet#L_i)kSb0gErG^##^FIzbJ0q&8%$Kw7gS$p}4ijZ)FMFMYu)S*G zum-^q!T`zV`;q{tU8EnO(|~;Mmy0TovzJ8Q-)XqLuZ+=Nzu>(sRvUPqcGI}LcX$~# z#E1j+L9tb~RS@2@-*qMNKl>_nx}TrmUKSGW$&kgh*v4V@Nwyf3bId?iLRmlNtMHc zr`uTxY@PN3pHCTq1$xXTVtL04P_$2Hqo72037X_h!}0mhK(Pu?1(5Vja9e}pj82E6 zVTutGTf6XhDg0NJCKc{})q7P*VXcL5FxSnw zoSG(j$Dev;shT|cQ$$e$Y)8Wz1Hg0>qNe_H)@U&|mMjLs2UmrlfFMI-yrMz#5uJyJ zv^d&(gf7GTqR8Vbusk&qm6J^xh=Z1wN?EI|#4vH7m7yW8Z7R|~TXTCNFw0wMzET(*mb|3#>xpUCzk@UMnQNj z8On1rvUr#yShFA^nVn5{S_Xu~O3VtA*M%Z*H!UE9lyGFXpdkKZF*z3gV;!xj)md{=$!Gp8F z`+(0DricM+O7-T#?b`SaKP=ERPj`DI1Z|ms*58U%(p~C#A&48|gIscH?*q|q$incq zu?>WFI6<=ln@&mcA4$o#(E{ zfQQ_I)G^$Ux@>ftJ;D~41KK?fi+BWpe1bPshT-YKNG_ED*dW^~1-C`IQ*9LgNdcva zyfWeZqkt!DX}GPFtWFMScX%3uTU5Py*UcWn>S#HyV9)W$AXwNyd));RzWpo|ZhW7N zw$SC~pat0+VT*R!6<*u%zZtxptl|Kn6B^@<%(wM8TK5Iey_>aruR5{m(1LGQQ#ofP z*F149rqQ2KE-hr*%^I&-d5pMQa<9d&{+21v=5T!ATQ;R30z2H*lpBa65q*=vD)^}u zpvbIIfwq=s6NTlb@?mANbAaNUh@U_mM`t+kY4Rt^mH(@xLOi6&K%CpVIHkZ+%#dFY zZ#5v3%Smsb-xW78(k*i@89%l2A-^v48FadeyX51LlD0?hWJ(&)kx86xT4_6cf1fsw z{LrSNw9I}p)?a_Mp?5M6A9)cA~$xyXofW_d@;IJeh5k>L6AOVRG0%oiGn0`^&q^h}$g7o-z&GXMFX@JC!&MyN_a*U!v=juil)ko^Df13rHb^Z747 z(9fec{?8Bm^8^3btCi_N}GV{q`x77s(sn`1oI;BXZzr@8#m$BjOL<me&#fD#}!Mq+T*5hYUv7f)(AWKB9ng1^CIk#pl z^|F62TJla>`d<9R)#l{voZT_?&DQ6vsiV)#Ghd{u4R=5tRP2{gy>*cg86FeZKN^U zk$GgF$sSVGeknb_5b35wrEJrePPWg4=y*^f03x=P$--JDWYU`3Ytki2)$kNx_#Rm3 zdMjSd)X`gQAaEw)$ug8@DuDpcjeiZA{^=`-9JJ#vG$jQh2%F~_Ma{YpA9i75k*NJU@mtp3k zP=!cG$;8aSb^`~&^M(&wZ(a*%dZj%&G;P${#y4w~h0kR}>AW8XqO6Q<-=`e|XFLH? z#s2mf*|6R|YOSAnRiRA+(Sy6(55c$#Slf10_y{O~BJ(ixmXchTM}(;1paC}owft6u zQPV&CZSQSquJPF1t6rpH^cD(0HarQR(R|dV&I&V@Kb3!aADMt|neOE4a~z_bAYe!H zG4Tej5EL3}r;C8ogI@XP57rt;nzUm%HA3t+g*nx^;89xCG>NkL#8JESrZjKhswuF| z)>Xl+wb{Qyefb*qM=V^L#0HEl8#HbO%H`3L1o4$i;Vq1_$~1PD6z4Wg-j)S!DGPa5 zk84)WL~N0AR&yMeN$YLx^z`j_c#WlE1YnKtea8VayEUoO)!95rUC04oEZ*FdF>k-g zgShr*#auaTvMGuc96j|ANI}kUi-TQ$Hyzb5U7K!vN|4rK1r%R2HFf@ARQRWs?$kOZ zeo6c|+q+shz|GjK)GH{ufA(P%*S81VjxxBW6Lbawu~J5aiLtXHZ`^16O+QDyWk0hY zeU&W2QqLT6xh3o|%WqiCACFJ}((2=J7!U-)BKC4L=WhwL8s$~92h_|u=kM&$OtJxe zf4bA6v|zey_0t||a$uRW~)5G^DsSm zMKOK1e*kJS_@`JVBv%3gW}nhjVO7rGmHvpmr$HABAVw1A#Tao|EIiHkX+~{)gpmbG z_(>Y-k!_Dl*F zU;)`92;cjo=Ui~@2;xkFJrX8J;M$VF4k7POTP~aC}CM$iU+3%&gY_ zOHBbJ%7=c1*Lvnk)>@}owc>r>Cc5nkHyc&9%Sq(d{cV-Ex8T#hs{d*(9I<>e*{JWR zxifGQnarS5X+yf6H&~Dnc^rf(1}l<~dhqexUEmp-1^656HL{$SAAY9%77q1Y|+x-&k0_c@h=9b_7_Q=OEWpGju9p&e@)B*JT?AF=|= zrq&frgi(*wFYAbMC(!5HOfZ%9HbAqrZT7@oJAbRMLW}1|lVwXo@uLYR527e9HVW|s zsTu-*I?wL~<&PV}&oWmVlzA34A5R8(1|uUZRL*s4SOX zjslqmaTw_(iAfQOSdNi&wGe{3z$nG()3phBgcOm#6`8YE+GQS+S2Y*Q!zR(wUkiGs zSG&ap++qa@9|o!LddQhXG2K4oZ^-|dD#*`s6nHRIFqG(xJ^@s=`-T9($zV4}z+7*W zC{KkC(|E;GnqfBxO}#ooMPobL2Ag$#oJ!E z%3~<`#M*kIc9qOOm_3WE`EEWFtPrMDh(KelWU7ceL8T@S&F#HJvUJGTrD$$lGF1pH z{+0$hcA8nF2P0c6`DEp;EGlCJFE}z!zW-Vq$ogRqk4U}N*t~WpHJiWD(`h)NK<^gu z_*nlVw5Hp3uFqC{$Nq08d%Q=@sFXv%77f};V9n_k{2^9SW)a8In=h%}7eburZ=-GM{(^7<%;R3hLIfdJcN%v(X<3 zIHs414xSwLRxK23K}y^=MpcDXS#$cSc)g&tXA_z;J;w&s3|l}97Fyn?#NI4aW|J(h znOangqY1qD5c9H(f2<4Gaz*l4hky{jBNPyL;Yb&L!R5<=l53I5L{`r-BBiCA#vVJj zDk!EGk!P=shy;qDr}TQr#wn(UdBuU^g^Ro4#|p88#5dpxI%tE<@Cp)@PN1@s`lL26 z3bi!~v*U@7{Yo#QXFt^Q=3t=N{L*>%#$qw8Y<`{lels}@Q7!&Ed3X9N0}+xp1^EE% zto>=TdNx{dbw4teUL9j*ClHVY>fj%q3k`ldu@(#eX2mDe8OUXn*6|UGyB*kn<2}O6 zKi+et$=zKIdd}gY{r+ivG$8iGP!mmUnwK3o0aH&Wr9bup4+wZLC58)|05SeG_wZa3 z6Ru87LhhP{2wI74*II%cDWA3h`3CrjwaZ11a=yz44HePN&NB1vV*lmMIKTnzS4KO@2*-hQZK;Hj-^M+5jC&{s+*3ZtqRoDoQe zc&TTp#z&2^VPp_ytVu-^92{WP=EEA_O^VGuDQI+RwxZS(22dBja0E9mUlG2h79z8Y zl|xcTn>e#AHlv;jiw2NrQb3{m*{22F7KY7mz~{&C*bYUOBYaOUV+-8ufI{R#P=?*U z7!mR{=c99V?}qJ8mlV>88_-xfq86mJ)=$yBD{a;}B?v;TG$VQ6kBK2SN%e7rTJCM3 z;0v;nERsvb0D%>Zcm9^p?-~~y$dW>FNSP!$e~2*6Ja{&IW0IPA;&0Ex4w-mB$wxl^ z%|t4T3z87-A(OR|QWx^+8@o2K`P(L7p0^^qv^&Q(+j>43%KdY-*Ny4qkJRzoNA;(P z#QTA2HE7SXpo0Pb*69VYwv{j?5y3o&dYNf8W0muFgq{GTn7AX5#gNB)F_tEcx55)U zV1H6IJ*PI4474p(gE-~+h}&U zB7Z5brhYoMA+2i(+NyLf+i#H5YW(p`V4ozfR^1PiRHEAQ^wCEG3hMdU}AjkbX5hUd9y9-FcR2nU0 zC;y3oMQ=>-5g<)IoQk1fB@0r0VonyctMge3k}zm{^vImH^b9EqaE@5VinK{tvcvA9 zea3+h0@_^T#`vXAx6cls?;~9n20-kxJIpre&NVvum>|G3o+6ie&nE=YD{3!QA^ z(`@DJ;XOd-f-hsAYoLj1i2yh5T!=cv`KzWq=!Dtu}@DRHLs2J_*zs5u#k>(sO&+pc}B_Sn6b&eL?& zW;&}ZC3Fs5n#z(iKzwUFOT2LYxVk6|LyUZV6?CRq87!QN0_eIN5A0%m7<(pHq-~Ai zc-Kv?4K`nJj$C}#;DMxj!gs~+t2$_EW zq4Mc=4y}gQ*`LipfZ95(iFh4!2(5&(<#qfR=p6T=aMf3v?3=kN&aBSa`qB}T?Am|# z=PmxGTK(kwYATM9ZyCn>_xp=$fWt)O5(1vfo-YJ=^wSwO$YetEWkba{Q?kG*THA0qi_q11IJC*E9YNO0M-ecm}IY1?7J2)IzIb)3+!1-+q>!ILC1=>@RqF zciWGCj_^?auy+IyFQIjFOTuqk7T%q8j@1-nGq3g)aDyJff(X%Z2yp)_JYnI)KXVHN zxIJG#p`X)X9wuV!0z{svyjZ)UXgJcvT5*o2v6K_ai0^fB5>QWIO=jSIkwchE*uf5q zl4M%6Dx-B_M3TkaMwD@LDPE9EKkj#~qVx?aV}6x@Vn*`ZMPR4$@-&5A5^GBH-nYj^ zbVeYmNjwmvK3-Q~#iaQ|^Ohyw6T)%#l%jI?lSj{f1K&i0tG!x3KJMLX9_b^O{QKXQ zo3v5X5m|nJjbIs9u&egFAvTK3?m9X@lI0`rGFY@3OZMt9|7h%gn?v~T48y?pYj2^d zRv2r=tGEATLjIc)(lY`8f&>3=O8B2C86bfFqJ)}uCC2|K;Xg|Fj}rc)g#RevKT7zI z68@uv|0v-U6)qlEt`;Xg|Fj}rc)g#RevKT7z2A0-3<{5K{1 zPhuPZ5F%o;2LPz8|0gB%{5C^6th71N`Q%-pY%VDwPFAargHmfD6rep4g$ETXrNFYB z6e%5Mx4R5=TOvR&&mL2NSSUaOPN@&HDPspZv9&Nr zRc$IvRBnI!`tp>TTH-b-D*k#)N#(RZw3U^7d-9myD4u?Ky-&?Lf&)sA`7q?bQh@%S zu#jU1)jL4uq`q$7dWJ|@*Sli&W z?=986LLqq9aK~dQRaj)5Ypr&`r;qL9n_n+G3}mrC-8;;Md)O^GWk)uwiU07_xs~VTZ1!6d zV`SMX-x1F4y8LrlIzBs6Q2iANWJ{Jy7ek0KEdgwEvmeN$Z5S|sGJWNOFz<`4P@_Zi zu8JOUNIqjd)7S$Oo?@7{ub%S5n~v-qyzX?~0nkDo29e!y^;nLd57SBTt_|s9zIE4g zGfUm^9md$YsJHVs-RepqmdvmprL+?VA=`Y1IZfrO@+bftoC9&2K>C$1?fhzX!kYvZ zf*>0Q+!HL zuNO*^Gwr^iVmf2UKIP>bR zlh0;|{@LjH)(uC8C|UV-75=vRy*bvw{_o1*4q=Fsmk34i*&p$1)o-dxmgf!tmqmB{ zHb0v44<@Y&Z(^F`d)M7&%gi~)VI5I#FkpRM#-tZH3?#4Z7Om#pF~@D1#o8#~`ZYak z-IdJa(O5nG<5{=nVRyxFOSAbZj$TQ|bVNY^t&sk;YA&m3+&}{|e?ylAhZh5EKD*P@ zZRgGF>v8-s*F72eP0GZ<>mi!Q!LQ@uQMbjDTPr8D`p{X#Q>noOc3WUaw;?cm;tA}n zSK9lQpgMOgctboB2C&q5)YUwX^!35d)&?f}F)8NJp=XX(Y>sE6KuQo3I51DdTcj~@ zFV(w$vSRp7{M7xW)NORo@I|Y!YA2n4-RAwcEej9PuW|h2d`2_YvUR9z^N`+$Q&u%j zr2fS6>1(%FX)D!T0_VvC=JiqdI;XnHr`Pig=#%_6`Tdog;9cYF#iTCFW z^7l{wd_K~#m&sY0(`w)VbUY4!9xv~2k3ja%PHC9zDc`BO0UGoOuyRZwo1AWhc7{dP zjy4~7zc#kmHk7GOe2<-qmOi%$@NfulUusg{QLYYt@>)<%!ROu1(h=NWv%gS#VDP}| zO#N8E-*1Vvwv%q~eQkxPX)>EuZs!|COqOUtuhd}sXn zA81spj~@1D|=0A=PHNv_*mExX%$gBV;Rb(6&MB zyc%)-EO6jVSKrnjReWkVqd7PhS5IYw%@7UD*i^c)yOXU zyf|DeVMPn-q90TB&13htzF21~NF_|e7S4z4D|!yiJM;bGL1VFk2*Sq4zqH4A*LH6yYt^<4}N}QP2J$pNFTXa@<-P6O!YZ%Y(NE*OZQV z^@X{(>q+`P;tOZ)?lTQj7E1Yr2!B(uYsEQ+n7*lmf~6SPf5ED0|LVRqfSfjh^9}G^ zBFYA3O~0kekc5&u@iYERPdk==1t;=ETWRPMm(7vRCNoB|U2@x@#^P;rFM)eleyQG* z@2Mm2I%(-^H~yZ+ma()JaNT%uw8esZp~>C<^tK&d%R7gmied0$JfS*t@+zzi)dP3~o7Mzd_cf!<#e+RwYY+FCDtu{<$Zm?cg~8Zv(C6)m+Er z(=zA^;*(GM#^I%sym^|l)0x(@?hX4cthxnaHJ4!q9%2F`xcChrle_6HExuV>Cc8FC zI{W`u{-bo2ssozuKV@8w0KI>x~3FbqG`APQyBiZ)avx;V>WMw}1 zFn8FL86HVjWtmiVL(o3`OG{yE!EVgFx75wPS=s4IN%!&)Wyb z{i_>hX~ScSbsB;bK~Yw;K3et9R`X*HY(qSVRR8gWu9MBf$CdlySJHyK2RI#`MQ31F zQeM1{T|#4Z5+mZyNl$+L&LiAhSQvy)^OvzucMs;eu6K&!>aPn~EK^$0`|t8?f{2}i z6G;5b!k|4SSMK|4oJLG#%hcAALx2aiZN2%#5JGysm4tH&iP0uGxO!wAnCUd2m& zCfnNjNrME+VZuCkI}W(z&5d4MkT{7v5TB^erM+QJO9tinqC?l`9P>e}&9tEpg65RV z0Oz4`JN$fM$9KCSTN!&$dqep%j>m9tAF9o_)O^Fu#P?bRcuQiSjXw{1zh(Bk4xxWj7uPWRP-t-$!+$plqjhU%&cxxq!@;D7V?$MK;JjX|swVN(jhcJuJIxDLWgtce@&!a1$ zmc~wD6^^r7p~4@acqRbG{;Aj%XiCKaWq^Z~Jv&cCN)~4o!|cq?uODHVMQx?94uK#Z zT&VWK6rq)XWAl(?JnaSA`Z(Gco6NkFHM1&MhfolXnbcOI{Q4Of0IdLzTWKrk_Ot;O zXCsDD4hn1vI|CF=Aq5{};inD;x*ABw?jPCnnYKe@O~69I-@r{0vj;cSPjVtdO`)=k zC>CJDZ_`U7TyNk8Qd6d<$nI&>GZ9P2W_%>|p&XV06xzEsMHLa9@Pm*Q*m28x@mG@R zNX6O14aLdrWYR96wa6PNQ)}QcW=~TY0lY}<1|zP~NKJ#{u?yG-p>ita&e{uyg85{A z*tm1Rh>q^}kkAwUp(HQT%TrFdZ}MbJQEJ}Ww^wR@wQ)t>MOKtd?$?wMcmKv2ZNP{< zGEqXh7iTfho<4MsqThe=X`$hV8LQ~yZ^J@##O&D{dV?^!G3DP1Zzn@tewK2vT(FMk zz3Fsu0BHWE^~@yzNk;#Y*Rn*KA7M zwAAcLis$}g6IBC*&$@Nw5T%6ejMejq*t{}t5OxF{uyurfRzNz|W6Vg%rXnN*@@U2u zY%Dg)Q2$Q0U4bnfyE~y4ek-WGBNI^c(a&b~UaYL8%ft~QZbt=J-h(e`zeuK>vMcmp znuZ+mQOxW*8$aYRANzYV%FgCkJZ}4|s^)ZsQA%D;m)kV;oWS6hn{ig?bVyq~FL;CO z$=~$Tw{$&aYDA-zfL*oe_-qXJunW5J8$B$Y^=us;f_nS5Ua+^e$XsrB5{>vli{{A< z1`Y4d+m=RJ7ODmXk`^Fg=V(p8XvS{?Q?y&HAlJO>t}27&oz?BTL8q#*52y5iRNky& zB|RG}9{dTQ3Y-A=0RxB+4;X%`4hW=?lN$KO%2dhP4q_FWd@^k5E#zd9X=GgMzaV^ZM_i5W0Fn~W|n-oIcVRQ>6!l_ci_hf4IgJD-S z2SuBjh>Qq7WwN1=Y!S5%3gQmPmN|x4L-m^1zS=O2TW^=8#Jy&d*RppPJ+9fr5B!P& zbfKDY+K@%l_{E#z?a1VzPe*;4p=(b7928vlKZ8S|xUM+iE3ABgXVsw6FpUVZx)juX zmBU%rNpTiHjOAmzE`4ForEM7$i(}?v=U`2&f_`>jm;pYU#?bUS!@1^mut`d)Eg;6T)ZsFOjchDw53a`F3{}d+25QBnyNx$EiJA4p`N)5#=g3JqH`D6Ueir@w zOrBs=A7pz5shlSrMiPI1z38%&*D zL~hI_d;J|@ALwI^OYI0(iS0kh75bVvcSZksfimoO)%C8Lg`Qb?H-a$@PM)0y(Cwc7 zqsIB`<`grg6Yh93+L4_1ycGNB0P16|n?4hG&;5PI>^D2;B`%Vj5(|I?;>LnDt=`Aw z4+GPzzmcBF?5&X87prF4t97M%iyug@^bVPcuNkDoR=~99sf`WAE5PCp5NG}0Gm0kjYw3Fv|{^y+g>aKOx zd3EmP#Z2bAlFaOx?8)rCXU}KHzM|ceNZ4^lkhEcT8*Xm8I$p&hj2ZQU=;VJW03fd? zaO(D?*ZchTxl8glgf8j(zwxSLLTV4HIz4fHft<>ymp(+do^S+q^~CWFB|7U_AzWpp zxc|~8X5thk<9Y7GBseV7PzoI8PrA9*AAdhV zV-EVaRV`x9z${~xy>}X1AD`$CW2qk&2V}CF#M8aU>INgA?S*Q^GBZmG>XTp&PuN=Z zSH1ja$)fu!^wFC8Jtv_hFYB9Yw3BHnD zHu41cpt^&u$`{fj7fY$hHMPpjObRRl|-;YVpnkcVl!No zCwm&MJOYEnA1n^Mke_)euLH*i>?29* zPUY%9PTrR5`Lw@Ge$yIg(hV!+SHt(Spl#27G(eTD6(fUkIr`3j6p;%kwkW9_ zWQoWQgx5DCMH*=$KV9<*8FMI>ks5$wVuATed<7VymsCWhySdu5X)p&h1^!ES=n!tn z*ND%D%QmHId2{0{KCacXxx_)t>tpqPQLdVza#*J5uH8wbpl@cRw!V2>HoC%enHpC& zIv5t-mZmXnjrxUcn>6QaI_DXZBR0nvW>XTfP=P8L!wxBuEKt``&VDSU)T%hBe(|#A zV9}vhb&>7RMtc&ej94(g_zSlBR_Y@HKQ}d(P#xJ}su%F=5>=u|u_g+S96<0C~r#*{+z3Bdea{QUzM{{HO-!QtmEmD$)J_S z2Yxc^rV*RxJ)IKp`1Pe}pGwBkL#IjW%7BT)PAbhO&3kjj72Y?PxbbysmQD^jeZ^gB zVv_I*zHZi^6>PXhkZsJOp!es^2Fzr+kHc70*$3&Lskf;irRtFtM_xiN!su+b6eH1} zhq#{$k&g)HBe{&I$m#KC<8=>PVxpy%nlhOt_9HsTI;Hz> z6uhnc#5Rt6{ql|0T&Xoq?vtA`Pcx$@7d5tWqU)jf-_{+W{I?iQDS%&t++Q>xT7O8Fgv|@VAgyL(+0N03DAz#vqx_PvMcH>7a2eEcwm8`al zY3FkN8#7MsJmxfJQ>C9{8CN9d9M-u@I-YS^cTon{XUMto5+KEHToOF*jMgY$K70X> z)xAN1Fy+RbOLi2LMt0Jk`P|+r+_LR{sT|s+PqAIs!!_XzQj)J%b^XBoA(3KQS&;lF z*3;qQHzEl^qkXLthw=3Ss=zlo56oijvH<*^*?4eorYe1-!1N;;WO@gBz%0mjYeQB; z_UnquP6;9c-~!;SMvO#TMeBVRsHs|!yJ&gp&L9fzK#}mlqqcT@?3|Lb;Vhtg{)vs% zB!Ka|oq(SqKD&2~a3qu3)m2ly<2QdJ43eSZR2Uk+A(9c})JU_VlWLOmUE@q9w=;~d zD-__Q`A=Y@?NNh_!`pMqvm>{Xs9wXCn|2*{JgiJ;?zFLK>ZSxg&Do=TF5)#E?{!#B zRmdwE)7{8!34`6TJYb)}?)>e9!pND|dAy=%N)y!MKc_^JM4rfI=*m|+BcM?(5HLo7u;u<$+ov!HDHdRyMC$n98+T9 zl>7G0ETeC!is6Es`b!h(ox`XdSMgkQQ+W4fANI|}u=MYHhlwYBGEu&LM;3C?Iv$q& zr`?TjNxvp)cR!q4Ut)v@=R6W|sEpUavzb%8Vi-`qA>l|nDceYy{dpv6@HfY8dsT+@ zib1_Cuq`Pgy5QrORHTMC&iJ9mbd}VgAtjF2Q_l7-pn@$pdZimsli*qNBQXQ5GMk~e z?n_~+Kj+-@oyp|3k^c*8NAFu8X@({bu^&TtBAz%K;aT;};lUDHut7jUO|=1Mf}gFf z)Yv!3gmK=rs42BOzxfq@UzMt%lmRLJvb2s4F?E#TS!9?5d|UhHA1>cFb9VpSiH)Lh zWjX&KKup!}e3dsCO#SjKi-Dm*{FkbKP!s*@f`H2SPRiCdES6WORA;NBdkt)MhDCe9 z9wh;_N*T0mXR2o$+1P?_>ELQxD$Uf;N6Dn=s@0#vt>6+{4=3g$&;3!3#P=w_Nu47_F~cJi zX-Tdvhgpqd`xDUob-IBUBbS=Sk<7_2xnAmE0Tzm=OE+^KZ;GD`Sb2J`mjZlwD1UTH z#zNau6^3T*vQ0*QOIS_A!l1kb3Dpl6uBn=to$V@MF4Nrc3^3EZBX{_6QH2pMWF!X2 zvlkpj`z2_o&%J1Ws|0)&{jJ8b9&sXZ-;3@&Z|Vy%(eLv=x1Ci5ec2GudTIs8Fg(W! zA^JM)055858@TA>#A!W{F1M&UdpI+msxa>A&F*FTaU0M>6P&;t`vWCJYqS2UIGRO` zMu4C3Ko&<-sro0*=;v_KB(Ggo9~N-HTGkOyOo*D{hZiddmhh4M(O9UYYJw{tFdmQcNMaU^~5HR)mg{l~@6yL(~ERCcX*DCm3&qdp6oECHpPQG75yGFjSp^?8?(Ntd5H z`|UC#*8`)cd@;Y*&*z4|%#|6l*(nnS$EfI!`c4$0cZTM$+~s!T>*TUQhqSIu{DGc% zc-2o_xJ9EYA|*UBLH`+_&M8Y-`PasQNqDwLr37G7%FhqJ-<0INlV#lU4%M(@+8_rhz+<(8LNiAUY=H~);`Ppr7@=rmq81p%| z$X=iVaNW=ncAdul#O!k)58v?h#n{4|UezaM{I^}x3#$iQsb<{L;e~1^QpG}?fJax9 z621j721`6{S#J3d|1o(b=*QakaHCj3tIx;}`Wz2wW>-fF#=R}-7L&AG_=G2o8oO(w zCMUo7t2M?4IW=;s7F0a_+6r$twyt;XwSGdq9c2Wce1torE(Y?o#K`3)cMbkcFDrq{+ALa-U@O$-r`qO_=NnKI%S-YYMRtoOdBO* zW%b(IB0oV0#F60q=r}V}&X#&vYWY*KoDs@_uAemgsWwUIZb&`KR{8gbR|c^!txJuq zrO36+t);28ddHzJUpl>hw%EH!(es$8&O6f=T_=w+5U!zAaNG9{L)S8c)z;rv#(`;# zx9GGVnGStXRw})kH8B47H@OtjvLqsTsQj8E-SA$P7*(?s`BL3keef0IX_tCB)Dd;V z<*b1dQg?U@=}4nKTh|1I;(RRJvQ?9QZ~p!aCA4|}u7w!4BERo$Hd6b5{he_8vxVy8 z^&)RYxE{@b<{>8V!cS z(x^k+K!%%#%RR7Lm~o!RKU_NMO_yF^KI0WsE9F}7}g=%*9)y4Ds?BBY&CI-?nDAx-gj zlGzt+e4COiaN&kPTXCLQ*p7c&T%*4Kw>@~-jz8=4i|=Ij});B zZ~z!(UvFKvPte=qQLyV`dTMtZ(5rnzuv}6fPQA?oY*A0+_TVf@TjOB2k~XkSas>D& zS9R<5SU=|3;!IW?-u=-{`9~b`qC(j?VeWEpgW^5s4%+G<0nsn#oiCo6%$-lsChfN5 zTA3a*j!D2hMV(VsZ7BgH$zZNWzO2Ra1(3?$`cOGy#5bt>ixg%P&{}KZ7i687!Ioc3 zk2rvpmGRxy{!?cY)Z^mR1$Bo9XPSgB&JD=lmhqNV{X>-0Rurvo>Z9}h{em8xV4v4s zSr=@3Ix{V=7Z?la@VNG(;bXrzATguFx!|!w%Z+PYFf3RiyBz3x?Kq4vqb2UErc55d<=De7Kg*-uz;cJ-_)Gv+3m zrRKu74%QbLFf>O4$+F>Z*owA~W))0ZQ$D^RXS;{@;ENyMoHoDaczftA855iESpqn# zXSth&E29jJz0dE;DpF($nPv+vabD$g(2?*dso9V-V1}s~FFK!Zyj=*BrWcJ?R9=7I zVo?+7Sz{oO&H(r&wOXmWbQs-dYd!Tsu-J+zMZrav^s4XHIdTrR|D8(c+M!b0Z-1yLA%mzE(5sg8f^v@6AL+O)U{J8f-uOs*g}G(0_HJO)Bornt&WXZIBpz_E8yZD z@DBQ0C{k4Y6abvOjJuKGaG&F@=m@}K$Esb)o`J#2^HcV_SR_F6U~#G1p|CPH> zK0l{AdGdm5EiIm!0>y8=<|_I`F*$Z4Q2gO%;Ef+I#nMDM0K+$U>`D^FAFUDIIsLea zL4T;ARp3?H$?A;eKIp#X;AW5O%9_k2V>2x6}7ZE|&NEiT5g z#!=dOC41_`Teh#l8p!Pj2xw zbi13K-!UN9ec@%dPPeQj_h%llh8y*BA)1&ZuLFFv^F!U`vt|@ep1lcLmv|rhhsNEx z81*n`rhDYurnRWjnznpbc17I+LU0n$-n^74#yG?mU|VZG`@G@KusUryHGQaOhTQev zvDCpv9N9DZMLut+f;&IF1LoKX$QvD2=g4791g`@$NV~?{$>IidR6%PGlgT|qByB7# zzO9TMWWRp(^9LpQ`cVDt^B!-|s?LoEiX;W&7ZF9-wu~7^zw0Q$q3%QF^B&J;@%yMq_@5Q36cg?b}$SDGHb*t5Ip}#q4m(FyHQVps`qs`4AeFP04BU zWy6M5FT8)$B_+h=PH>j37jXY;%83<)g#xEKyBn*Bx(HJg9S{_JbH{%dW*+<@+z1A=^q$a13%Dc6ec$&1y2_ps*OQ<3 z{e`!P`@;4~$j&WUKetu!!4B<{uvC}HkQ(8i?U4q4ayt%E_v!36!|SVjMFqnZf()*1 zo^+O}dHUASg7ZH5+?vcisGxDyXVR28*(d%DOsmxfLD$(`ZNC>PygkpfE|57dU z_nmGjq=9tvzEEgk3sf$LWU8LE=t+^LK^|913K^mHqYKj*VLY9lR;4Nn+_IFh6X{Q1 zPj6J36|~J>{Z%;k?&Dvh+uc*Gc4=VlvCDo63xR$fn_w(`w4?CdO3L5@I7F+?pqAQI z$oH!O_1EWb*k4*R279Yjms5?3F#a@ya9F!79aivvLsl^NdY)a?T@De zh*c>1?SlGGhM-pEU4@iVjzyo9RyEHa)dz#UqM1Lrt)Tb4u|Xw4`CM{SMb|g*SiT`( zp#d#4#)ZM0SMLFowZJ{=MaY1az}Iy`zS#LYd4?8rG!Hn81I=yl^A$xfjaHA-iNR*e zhWt@(F!0N#Ru}k`3zZ$;E4a4|ZL2ewwTfvjp<+$E% z3ld>=5$*h?-~|PsQ;lfPF|WY4H!nM-Jhaq%T2f=J?L9x|N4wZJVzbAD^_lJ2w|^#a z+;|Lu`K;g>5+G4gfedCc(cbNlTgGNx1BWA*jUAe=XW&;le4E|=9;>!^9f36_6~gDa z#&69hpbo~Zm)vk2UgO7sc67YdZwkKL%4d|cwJ6t1h6U~V=5Mw!tGT1Q3e#V@6qS1y z0{W@wsobl27m&27+77;s)@;Qk^>?nyX|Cf}s`lp<44`7OGXTKqRcdqWxhD*yqU5Vx z*Q_I(H~#k@TfMA7z?oPOn+5~Qc`FGX`JE^7MS%UXi3S#LK$|~u`&cf&!ps~Lre+xTInOeV){`{u} zRZ4wjV}wMWh0ufHYe2-?2OWHRh0Mc4SYkhVPwk|l)Mz9Y5PZ+Ci;lRrJ2=th%3!yo zw`i-S})PQI4wR5=mg{#j~W%-li?j@ z_Q?(m;omx=I)5|Hr+u10n2aR4bD~0bBb%EU0K4V9B zBfb&#Do$_3$H(jWX3P&lqRf?)el_a3dW0>tomg-ud}(8H|I?FLqx5OhPMNkQ)U5_M z5OGaCzY5|eGh28G+P1Kc;yYQUG*{NIPW`{8$7~+spTSlV9dD8_>b`%d!M|OQ|Ciw* zqjX_-+?S*P&}s{W;Q+x#Hx8A*s0T#_xJd!>ir%Xe-c#gV7P0!3mv>=!To@h~hR22B zabb8|7#VR&2^9v6nkh2e2wcw87B7ly}$;c;PjTo@h~hR22Babb8|7#VR&2^9v6nkh2e2wcw87B7ly}$;c;PjTo@h~hR22Babb8| z7#VR&2^9v6nkh2e2wcw87B7ly}$;c;PjTo@h~hR22Babb8|7# zVR&2^9v6nkh2e2wcw87B|9>_-u7|RdCjceS&-KtP%JrW-!2k-% zvPcq5872A6E%L(FbpU|;JBhqKq>5|FN0S|JrmCfSd^BP!;k7pj4KZ z50#^omsbt}P{_!K%Tmb81OsT5rKP2A-JDk~{T-ja~KEh$8fZwdP& zI>Uq&<;8B@`ZpF8;}3}E1BSQ>h>89i3kmc1#j*ttBE5h@|HguX9D#}Kf-!zVHw6EU zfdbryCfq!VclZQ=7__n(0;?&3g}o=ou~E^X)h)qwD~pv8 zQL*GWEnVJci4w-y2Gc`gE=H@q!F#XNuHAvb8>PTdQ5qGazairDf9?HN&+}o%aSv2{cg0OzV?;N+?N3W zz?H|3?mq(n$T^lg(bJM&j{W|elXGjQgO--zV=XNKLl0Lw2Pazq;8l3)E4t^;2R`nq zW?DvMdk!kElV^fjgUUir!+^pJQ36jNynkOreeHvoz^%5hBKljDlrasc(2MCBM-I;p=j_qh0iKZej32Wu z-J4e#06~HSDY-?1c6jI{WW&IWbVMShim5jXssVx}qo(HN+J&N`%BTPxeqj;Xlz_b( zhxOKiTCimr!EcvDxgT|cp;mA(XUJLfJj^-`d`IEs%mqRNvvp!#)e`!}guHyES}Eo9 z@*mKO_I(rJNxU#L8-{zsTlyK)c7f1bAT$>U%>_bpfzVtaG#3cX1wwOy&|Dxi7YNM- zLUV!8Tp%U%>_d9|2>3;5|Bp)pe8qkQ~dXaaR5L}CLI9)XaXMJ zzxUjaV!IhK?5*>X!)MSx_Y|_1quS3X*8zCOdG_Rs^;J$gi`K+GV2mc$^()U;6RBxv z+5lRAGBVz}+!ANG9bO^Rqg_t<{fUTSh42eaU0pk?4~b_2kFqm5pIsOC*6cpZtv`jJ z)|Fo)#tE~{KJW#c8ZdCLs&VLVAgZcivdp(^9Pz8c7k-qTIM5v{x5%hOH5h#Ha&ZX$ zFUphfib3c`^Rs;q++zbpV|d;C4#=_*1#7o!48%YYupOT&h)-jEK=r%} zaQ8GTGD|>@X~$Mf!8wP$WN?Cynemmr6XYFKM&YtG+ncN_&MN$zuR);MpVZC>SQE;H zzwZr1(($;UcpihS#r1(_x64S=&_=>OF#xyJH_(Lxg62tMzAzF8^uvhHCgTd%Zd^76 zm2}g7`z{iOkpS&j)edJa9RO2f3g1hcC>nimMlB zu-rExIuQD?hnp%?KV|ahD3qtsApb6XQtx^CW;hI2C~f|jU~E%>@U5CB@tvL4+6ZC0 z=nTLoFw!3|aqhIXJ#;9)A5htkv`>!$ne zfnD%#B6iREBkrQLS}d*hQSMjV50-A?7u#z1-Wo0ir~v^FC3#WX_%jE_a|L8CPAOzF%oA=3N4xmcv1 z1I45<-HK*;hvi~4@6|HWf=?B0s}hMn9ohvg*)I^)%SiNvo0U?ucz~gC)1O&IViMSo z;BtyR?KQ%c&2QH{gNcn7`;DdN&}UtXd1%bPe+LnNJdG9z%}&AAQ?%R_=HHi?G>(_^ zwYtvNU*_F(f*sg|1$k?l->8GeD01$igrwQNdF4A zg_J)=&kP=`i(vlG7v88dQKg@+zu($IV*Qq8SBBz2L73$(aC#ySG|srrRGKR4TRNNE zz6cEK+Pr(dw7Qp?xP!mpFE_~HBrUa?uWo?hf=xR|{NcasWPqgN@m-3XvkV)>{8|)2 zWeR14qHWNIrJ2Arlp%htV>P<>=CnrdGEC^uhX&r{lGY& zM+8(d4fSiOiDJdGvL)Z;0MCtN0j&&pe4Lz)%$ABCyD9vHzpub;D$ST?bK; z&%Ck}>0?j|+qZ8y)gfLpl-{xm=Bkn^8=T5gg!ZXw*alXR0_qQ7V_jVxU<9hKiXhSS zSz7vpmRgdz08O|O6bI*}f~O%S+=t)?n6Uu-dIntP7L0#tv6<31k@T6Dy2Tatiy*Ow z&sA*Zm|nBxV@wf5Fbxg%$4Z}1z#-vH{iG|0vRSV4W@_h*5F?=Z-*m-f=hZ-sBTBXf z`!#GBKG<&V^u`msLKIT?aBi9}~r4r#_9OHaKoq zx?7PYKv|aRk%P3Ttpx%yX0kvSpWPaOl{eM}g+lFsiqL~-oF0ep_ac5pqGy-wavH~B z-VJJy_Wmxd0da*pj;@(6*1P$dLY(zGGYZ(~aHxn4UFUDEMHQoD2D$?}acjS?e@ze9 zgW*YMTX5PHp>SNW=-$F!4XIak;XDG!FwHLQQK3Xd=NRP9JUF$O-n7aG_ie7n8dbi= zOB~kE%oEQa*M(T-vx&51Fz{TuOf|ka#LDc%c;v~Uw$5(trKZ#D&3klv;M)0TJXHi` z_T)R&HIct!>k*N!3z0#@OP79mcGfO6J}I?@;T#(W#$os|SIC(3N|Rq87wz_pS@#!{ z>7Y%-krCVta}bGwHqR0(Vq>+-ngY&0I(?bCJQ@-E4Jv2kue}EjXB1^==xo%)LC~&G zOB%z*$it<(>k-zwK7%z(lF|uiw9803II(~HbUE0KE!tSzaO(WgVvb_ps@-!?hD4%}ktKP5H&*-UF9xtCkSMkDM`{C$l z3b)8RoqMc)N}4I?^y=^IYL^oqXT3`hrM)ikJBcvj`(P?b ze-P$V51m_7DqH*d85dm%##w!PGdohbNM8qZJXGvZ@RcvUSp8z%QHPe*@5E~N^dmIX z&tKPZ-r9qcWj&H{P87CZ9A=_S?^K%GLipP|w)Y{C@jQ^~(YEe0DBWmZHe=J~Eb2_p z7&njl0NnN`z9CK^kd1xE(-OJ1;p?xff%?{djCYYp>?Or7tpLo3cyp&;mfCw<_`$@22qeCRx?b>WhJb3ZcJ40ZHc zxg!Z=0L?vJBCe7$gC2alDce#7MvW&94di*je|_{(W9?bDF8GT*#20mOCSNE0L@ya~ zA2NHgF==WU+Mi9U3}S(Uh|?T;8t8f52jo!C!7#9;j*9x5>6p9VNCMp;dCOoy;Rh|u zt2lmF(BU5OJqcfaD2oY2P|DoRzq7zY{OL=GO;jkuR?M&FyN(WFwwO;AxxSb9NP4ic zjV27_dEmuB!cI|${JKgwR+}YDCMNnpk#?T#T)*;)eV!oi6I^z-2$M<-uvWJ7wc6-p zp9qplBBIhAYEzb?&JtJ{BtE04K7&|BmKT0}lRhTf`qR1L+ECC}Y4Tqf;vXnI8doz* z2Zus1qc?a8m@{V06C$s0WId2o6>_sz`)Jm%@YgBlphCsOl?JJn0n0X$;xnLopUvZ( ze!auTE6Bo9`y(PSA11KkoZBxrfwrk8MVMSP#TxwGh@PjC1ei5Xw3k`)4a- zKwF#WYK>+4-U1)p#l@0N#}){pzO(Z%u{5a_wB!-@@kkDZeJhue9PNH`$FtIvpYKT{ zu2^kXtQ_b(-vsq`FOv!is1pqtA`xrVk4B6&PIC_XAgDMV5NO$V$dcee{<0fwc1Ror zp(qdEzBoRPM&Re@K#jQ6;s|MDy<|XkPnI4iOZIC!Rx1YrQZju+@S0qydm2 zZgQvjziN5pe1-S;y{d)p-)C;dw$Q;=I7r2)!;v$mU+R@Wz%D14aJCRj2NF;um37%0 z6V_nbf`mK&Nk9ZAJv>L_{sU?F)el;&I-tttfb&TFdN{LqLv%NY){iBvQe$ZicF^H* znJ}}jPr~*Cdw&bDfzGYVL=rSQziWXe_wLwC&>D+q?5Mb`Tq!*-vVJ{#v2u^}kGzYB}2M*ygv+;+_UK2dZ zx4!MiLK`~MIzkD3A?T2jdr-mjjh)+6yKmBCBe=8bCX&7&T>N~0Ys+GQG@ znH$c;lv4L56CVgvsBp1RB8{Xk&ZbP=qy5vD8GiO|yLT$<_FoK|@xEQ6?=gSYz>ze5 zR1S!1#UXrs4;Hs#;0JV%8_Koc#`|GEP5%QzdUx=xX)CeM?8YJQOMw&hL_1g14zku= zzaALQ$&i?(<39Z@OZR6@^HxNqSmpdMcE{`EyFhgK;XZTB5(t07fsYSq&gOQ&N&tjb zUTvIlEcl#NoFevAYtxt35_#@d^f&-Bx}5n6=gm!MH-GW%9l5s}bf?p{bqew`oma^E zX_)VwdmTxG;cobi$G3P9`g2E#2gy+jfrNQEkVtsI!LV=@F-Iu(B{wtaq0oz_>-(HA zCTv3@WU+p)(|jJ;e77?g2#Cn&JqAKZp0H;wL{!{;hQ5&nz=!us;5x=v?H$rKe8}Y9 zLyRMGOyR`|?6R6W97{8~0#jCdknaR*_9N|6?oq+^Iq}m9UNw+izd`K5U zd9o_ga!Jd1$8Scp7xkI6bhW(M?|5LU7*|&t!CBt8VF<69gpHUioM8^@llRiWhmEc4 z_BVpwnQG#?vmU*l1~= z#kL2kxz(Ck<|B2CdeXJZ#(P#8Ea4xys7RbML1B$0cQIPDJ)1X{xosEYQ6~wg+pr_j zQ-9ZmzJmuKf&QB|-}V1SoRjN)uNXk4)N!`_z~$jtjbnsQ_EGps_Mr+cTS!CR{;#;_ zU{PTd64?jC z^pV(;Qdhpjvtk4|aa*DBy&Yb8317dpYM&{yQNjPmKdCnMHtxkHG(z_u;2f* z^`dO$;oU{XN$|Cx^HGCd4jM=*?CFQ=Ui*t>0A54E7><)hLRToL79yYsJ4$Y;ySsg+ zOlbE;y5T1vzL2@+HyW0)SFo3y#9b>Xcn^K46Ky!k(i8LCo2oi1B(`qe61;v-Bc;Z( z3$sS0PmA4L`pvq@FnaLl$?uyJ&zTN7^l41#g!vyP9#~rEqpk(3^9CxP9EbVEGb)MO zwD%}N_t!~_C(DOCr`X3}Rc|B?V0NPBU<~iLigrs zH^`P>cBTH27|DI|7yg%*y6e0Tv>D5>hUyrSfvtT46>YDwU%4mUDr?%Xv#R)}hJ(4~ zT3|!{OmPy0PFb=bW7EOyJ#5p?ZW~GX(#Z?~V>A*hC>ijq%UX1na{dz1n>Sy+1wEL! zPWSSjKjhvXJ~;enIr1o6vWs*TnMJueo&}x-gU)jHg0`}t6zWaXv^{x8HyDFuuU|UgpQt=8_!uc)W@XLOgXPELlVnb%sxNjOZSNBgwy} zC`WeTYS^pND#$eQuM>gSeQ^5sby0E&YWFvDS-!4N1ott|8EY-xZFnr5Seq`zAJipH z^e16jTjTfJWBLZx+Qvjl#NES8RD?86S@NX2Ju72p)vw`<=|C%CH#YRNyxfRd<|Yzx zWzaFA6nv>v`tcZtDx#=SPM>yXswf@%3&JPLbMFl?j|+`F*u>u0OS!@A5VTRdltpRl z3KI@g3#{AT%dj6n@*psL2y5Jao562fQ0{zBI^pfT-IcAW%9XmKDlh0%c9r*9!T9vK znVY$_t%EL-g=%o<$tm^gqH_XZHgT^E2n!DawUR!)_i4EcqtUcVOedbEbHIEW8-rdh zsD7%vftstRJ4jruQ7YwWTyS4se+88}6*$6IO|jNtM1VXy?CyOI4$y=`-B<77a}Nhe z@$Q>`w^7Rtjeb6edTiCy(b)aL+BG^rG$FLOc(2ZHe%n~k zC~EaZ;55#i`H?;xyi>vq0Fv5`A*n$+fA*B|J;3WXhDx$(rV~F}SLEp2G%K~AJ!l1- z^{hh9dXbHfc_7{A<6-M~e^r@Q>{^haLH)zIHE=H?&8@rbi7Kd=j)S6+34HZ9(2ZF@ zYh7&U1h9Y3v2b*_wMBrZ83V$oT5?maM}C4no1<>cuWNHn-) z0Orz#`Fp=D;{zS5U}4Glhj7nkjRCcx%OVeehxo^jij(Qk4A!3*pz=wy|INodz8*p~d$?y<+ z&tCD<%t5oAyd9fIBOImq`O>NkX^4Y4j&TU&{&LM#iWfbDXMB2PV&n0E-%L77XpIj? zP-ELdVjy6bhIeOf(GwN+7Pbjp&hs5U1Ti` zQg`Wx)B?9b1X7uKhJ&{Z6o+2&v4o)o`}K}hG)$WOJBjV1{>z(eIfr({M>!WJTCyY<_8EZkiiGRPl zq&#B7Cu`kPQi+{Hrz6@)X5=ey-QujN-Vi>^H|0V6ba+M>Hqp19-YA|Os& z-RfYiD1$R1ZIIgxw|mC!kuED)ci7i_S{E?h1{Cwr*=F<);{%kw4QLr=Bd)h?xkfoY zj5h_{9z2UK96=DV-pfi(1wimPSLPW-79brJn4}m2>!zv~4O3Gp(|$a1bj#JRuTjIh zc9xip|L0F(BoKFrCtHM)XMxu_TfDq>gIx6g=v=Xh9XJ~)`4wb`pQoK{5t^(`aOCek z+cFgq#b{HY1(hV!2L0WLik)rN+uRe%X9c4N`S@U^;c%_s(VIJ70)VTSm1Cy5I6*jl zT?z+fF8&ZRPK;e3Oi1HJhjVn48;t=z9F4A18h#I5i65mc-M?IC_;C|{v%}kztBwcw zaEiSE{SV!lrewlCm0uhpYnk(A%hXua(3T1E?w&6325q)#c~yt)pbs$$7F6;0h-!+n zlwb{Xi?k}6RPZ_+axa(dPy8r+KS^Q%Khh0a?jLJhY*_Cp2F&)^Yw(@YG6w2D@K;N} zgjh;!+4?m*u*-LtihzgMlXOc=KPH+Eoj4yffo3=v!m|K>eS(l}JvTsqt{kBKnjAvu z#r>gAwTV*^lB=kCv@*;o8C25?JoDhyIRU?|>*iQiUIZ5+ zs!^th&3V`o1MK!5>K(4coMOsF8U`dT2!XDQi_lX#CVvR2iS}e%zx#4#>*vBm=HkE2 zfRW+Zp6x!-K%QdH_9Ry^ts`K2#|wuJ8&zO&%Fx8iVkLi`Pv8U-eJA2U0MaOe4=sw( z#R1N0IRE}JT3|=2Q=HPy2^~6`xWES}76(uVW7yAd*UqixP4#-h@J!gY(C52ho==&V zc#Ff1-WkyBH(p90YT-S2k!{azt%TpX@%f>80UAQv z!(a>CS(h$v0?yX2DAnigXqjFOo}5H+?y_x<)Tm`qwT|yjlZ#!zAmXp41#JFM)OMBH`3mD3Duel4_>U#3Mxc z+!{5%LJuFd4vrZZKrd=O(|KODk zEJ>0juC-p%{`b6knytay&+O2CaO3DKhkl(WM?Ri`IDg4t*2-6gsrdfdPfi;#HBR!= zLpZPfJsgZ$u|j$f5f%yPZ6(ZYminr>$?<@q9QsjKK}_yJ$0krIKM6Q3mP&Kz^Vih* zeJ2npk+|*JFsF8&IpA^{(;RdV$}Xv?$CxyAMfam_Y~S1J2bZYc8QmUFL^^mK^{*?^ z4a)wk#=lYDS`Xfe_8Xo zXY@HQvyGedp!J6%IV%1ZE-2~qJh4ELGzZ2mlg92B_Wpg{MCn%Vk1cZssy$JJg;na@ zk%VqDz5dK(@cmV-S&?dtPFiKz-F%=tJL6+BReke_HAT8o>xaZAXt;!nb#No6qTW8m zHh?3105qU;ob3423A%-O3TET~c6|+eh+0m{bAeWX8aD8*QOFz2XNi-l{G3WX1|7^+ zTeEkgjo@t6 zrc^XGKpo8?fzX3l#5xAZ^>^}p%b77?WsS5p(ADKS2R8e4RT>`p-|w}y#xZW68fP`n z$sI4q=I7jHoX1mWJ|g}lH!<{&HKo+juFcc^SG^x2D**GMKaCqOE*q+MP=9w0*pC1m zoPxz+?RYa>GvXNJJcE7vpWAoUeSj8m_R|DKe6(+K<^1d4djj(6^;^tMuZg)wuLIB0 z3PU}%=b00Lh7(3gAaxZc?!h4n5%ut+v3zvi5}wy^)TqConaCI%h}=DI%L?@2ag>Ju zrd*|3obbsRACBqX+6NK$O#2Z%KoZ?5(L<=aL^ij)SR<9u>{w^hs0fyYQPXNQ}TnZ!2MKA zz5CRcD>Kz6e4s0!#he0Gpw)-M?#<8v;1R(wXDdK^t)eTy$RWJlsdXq@9qS-A`A5@ zQd_R+ZhwFL=y^K`nab*aK-<)bJ;sLU?;05`CLL*z?C=o>`^vC>HZ{7zr_5)fJ5*3D zlxN#rggo1CJR>?ublw{V_Zg|G~|Cv^N+Au9;zo~jHcD}7IRZp zlIb0rR6CJAm3w~ly-J{^ZoW{Ce6D34tZGEzl@{0A8?v-$H;ua0#H~;@rq#kO7T-n5 z&a#)hidRxvNPiZmG!o=s5v9IO2z{!oXmB61l5U{l8wW_lM3YieyD<{n*G!FJ*GZ-W zoGBxXb*!4rp)*g7z5(m@=TvX6Xv9$8e>NCj7+>jxa(AgF?~zkp78A2j_|HOl=$0RR z=>zExCQ)mce|bgJR4yS>FDGAg@cVZ@q?#l!60dB|0;-FA5In%3wNki$kv?^{=$yuLvlrw1+}^3lKmGsp2&avNzHxIcAEXtD|d=0#gD zCK!Ko>{{B&AZaOKQqJ&2gzLMITRhT*!os>9nmJ~dDWl4Z2qUO;aq+HkXPxetn^-&wDbfm`i zGB4UP`}4tMT!&)H4H8wrKWaDE@?fdU8Hs7{Nv$z{e^5N>rxZ@^($jk|9k(w(kvEwE zvjn0JrZ&gAME;)D;YK|8?{K){Mklq0S~-o&U)3bUI+@3M^8J^!_|EEU9i zEZw2C*dbK9si4;{Az`%0Sy_8CK7P=CgLa$haO(;vqEHa_YnfjDNiR$<7{GBr|6R-s z#$#IP+&HUv_ONx02{44%uhE%4BZ@d(o4JliaXmTGhoUIHTVwGhP$6r^Nh@SjT5@D6b7PgN$FNR1#f|x@pWsaUTUYnodUO@ z3Bz6S09T6iE2-q|013m6pciRvg^Tl9`64&}Sil3(+O$!=3CB{C{Vd4TtLydxiE1D< zP*}*xWF)`C@|s^AJJx<-*pgpuPUi%EefRcA>cjhF2qIlTIsxe@O+Y|;OF(+>HS``LkU$_`{O`TT zH})9k+?;chv2Ma#E0ZB}EJ-#ALOsbMX~;Pig%l?Bzr#2)&5$Tm?-kWf|Hsa`Zw@Fgrjt9_e^U;N9#w z-PHxRsaj{Pe@}CKdAvMJtazEj^J%Wy(f@gdm!Mtxo{=oQs&YhRh1>(NtU{y)tWDrd zPYz}en}?ZYC#r4yh6G(ND}M#+`xxSa_YS=9d*}32${P@OaF3`-_m;q6N5#YvU#*|X zP-~fl6j>4b_{hdL{&*1j-s~34>cuM)PGa9-3~f3b!=Ec3{{SUd@>j^d2gQyb9%sZh zuA27N^(E`R?Foxuc_ZDm_9p1UOW^eDFLeh>KtRi2VR&h;qZzG~1!c`boIt0Ob|Mng z@i5E})DeX zCCDMhu%BP1vuF00Xw?Re z>agA?h~B*da!fi3VO>pwc6#(dP_t+OQRRw^#b6TrgkNo_WW6>EVN@n6WgqZuQ99k{M8#fuPh6AZByt{(U|I*@Pr(Z}OW}(ghvlIfdN7&? z0l_c``fs>Twz9$bahnF1-NC&)Rx)Z3M1H`J=!esS{QMqYz73is>=xJ(9H>n_0;!>H zv0L|?vPUL_0Ld)n^tEt<(6j@=t&X3G#xf;eyUdxqLvNpV=4%YM%(zakdU8X>Ws7~( zUXNe6cKSFH443QhmW*PUEo0O6Hv-9KCXy?|$HLP;aZ4xC11@`Yp<37%AZ1R;%bb#F zGmJ1~yG=50xoy?^sD-Vu_L2sh&XgQzQyW;LZ&&ch6Hxd2H;leEwe*4heCJL62PVd? zc9nyX$-Ce0`V6_*g6z*WCP8RQ%r2^SaIq9~BZ(;_AV8r(GWo%OfowbWu=z3aCC72-@4f zZ5eg9dEu1L_TAN1^BdO!Cn7o?&&`Dq+%pKfkr*w)91Sq}IgHk*oUCmDacFY+gI4V< z3hy11E0+l6wsn+%33SDyD<1pTUyx3I2barw_%$P$Rl+*5i~jW24V8PeEj26M&;JFR z->ls302gtGf@)8|lq0u&+zsyM>`cFgJ0*I{G61NUN#7S0rb1ckud^08!735BugDhW z0W!^i&y$gSqi=MJZAiP$_3#YU(?k3F_%`Ft*+6ti{WGiK%TBPSx%xceCnk1fge;Jv z93lx@#N&^p{{Zu%nIM3x+!LpD^F|6E^yXf_*l4eb^riz}eCxZ&&2rlV*N5emztxVm zlxI5XkmcR|Id@gf^z6^?rYza0F25f~if@mPQ{*;u_PaUW?}1mk>ZkmkOJ9o-dl7~N z7NJq35aX-;MNSAysAk>PT|PoVnoz6g$rYR2Xz{lSJb~Zteb$2#eFiZ@Wmo|9QK>7>s7gPlo$A%8Zz=2xuHw6VET($Ar?jaLR zc$uGB{i)(?(!%30r+Uh17A2`Y{Q1Q)&fWK3>$K_&T9w}<>=gS&kE6FsJ!K4A+|lXC z{gh}e)Q^@2VzQxrf9O9kZwtD7NN=7e8{YiO#ShKkoa~6$+VL@(k58D!2M3E?YSayX z1k&emJxAAian{@7qw9-57=gwhgspf!Xm zYB+KP`rO#sD6;XR$X6V@}e{bqFq@BTbc7r(TA`SZ%tZ$s@%~Fk8HV@ zK@5hIDRR+==h2?&32_&Umo_aB9tB&3j}WX|$KO>yoxGpC#|2=G+wYyp`P9?ePgjz# zB+98VtoS%ShC`eclAD$Ya^|syo+4wt=;mSvXI!BkeefS4-1M;i)pew~L*M1Lt7`}R zljlA}>{((y##YxUd3W-2!r&L;YDU7q*|rH{Yw8W!9kJ68TvA@`4;Z=@iL**KWD(~_ zeE~bQQ%{rzZgZM`MF05UJm-v-0h=J52otZzeXO?%sF?fx3}{}Urxh)e zJR(~}5nQM@qel%cfvEKBqjLZeZ4stfle`| zepi~BUQ*7-e#1Bp>ZyDPN-)>vq+dmQ%f8N|T_*D+ye6~?|B1VJ>pI`FVzrwA_I`Hp zEvWnBcoi|wHA>9u;vIA#u)C#ZNd=P+k4eTJP(e;qtn?WkCKk{G{NOe>jxALknUrf( zIT_6a@f+aj`1ER3xtf=9*I^dgzyTt6-A}vngHeumF_dYJKMk_pwgQcY2o9%e&*V_vE-m8dEf3T@+wt>>U zvi$vXg2IeKCppYUM4aUKahSEfpCSGn?$ ziKaL-^r@`CbK-V8`9=peogCPgk7~7C5xE^a--*3_g--q5Le#S%Y^7Es0&dkikmPvC zKfdvq!|fAs`M40JLI}Ii0N-_T557G0j6jHIsk${LGNSNp?|z+UuYq<2GA#2``hqPr z2SRU=5*#kT4N?-uTAavWP-MZI+!`shBBvfS9?SCesS9j$ne-!}K^+YbL?^vv`RSN! zLQk0SejKJZx)IZU-k zz#1aSXP3O~=jM!AN4DRGv{2l$L8eIDUnLV9&yNqPTCot!bFHTCP@p`sdFR;&Wz2@c zaN+?hgHelUAN{IE?sG6qiSCO^4yt}>K2K)3PI1aZevMK>z_85t5+Je$!uDcju^~+} z&5Jdi<@2UDITCXSO)LXicT#LuDogbMtiFz$KTx9PH}Bv+-M(M7T#+0rf|NbK8NT_m znmGeYF?>(mEl!qL?8+5jMVMVWOd^az`~!Eu>+BolZ#p&CqwF>?9PO3Q?>3i`D%yf} z9!UI^wH#7;;6uCom|)u?$eHjNua~hHT1WA#GW>)d&>fz#EOoms)lLzdB~@8L5?xnJ z2xhlqFc9XLr}x6lu3uXoql}3?p@Rm7iMf$hDk$S~L*bSUSn!$bYCn)jQz3fp!~`;8 zQX%d|+TyD^WW^20OgWvv1|RYGkXp&2-GbD+)0TVKO)4~>1v#Tt6(SS=>62)sOUqWSa4q2pPbs!03M7}BmU1(XB(Jn|y7|^i@&A6)7on(hIUwFx%9)51w{vw^@!0>zd z_!3z*(^Xz=KkrNPSUy+&yQS`SN@22+Ih2Mmr})aopyCX~>`L%nr!VZ^7H~*Sh`Kxf z{2D%kVKN>_l1buqz!RXQy%zN^X@qvCw|rVAG;(RNp1vH?!#hRWycm{z400=7m!3ls#l#4?B`)#_Hzf>&im@_vN1%yr8Tx;^#cMQedTP?0=A{ zf7DYK>mhlo#p{OK{}bHI6lfoL1G*PmJd`{yDH0k(&@y)~eql#9?z+f8{@3MqyA78e z{bgH5@Af{;ig={11^QcM9$-0!v|R7m^g z=4E~HQ{NUWF8u#?!E{ks%Xc`EX5~%^xM~-hJq_2dc0^oJQ(Zlg$V7`-M-B2o&i@KZ zcG#^PP=JxDY`+z=H-cyDkq@;ef%sJRWYO3dh>&ms8yEbF(Zc0o6HNrcVrx5QF%lGQ z%3FheJ@bPcvJ}AH$dQbe<{@;CFYsv)w6ZM?-V=?({^%6j)4(iJ9RA`yQj*y}&OBR$}}(hC0sFpd{;WJ)9D zoJEviglLX9ceVN_Z%>2FUs8ITyccX{k$!WyGz)-{m1AFw)U!X zpSQnhVM8#{*t9s2fM6bcdm4PfxOBm6Ze!}wG))d*pdhP?uq5}P;k2^|RGKBFlg_@v z{VwzwUPs_pI=*FKH|qDcmt;XfmGDqN{;$QYSMY1JoG8N{ClEn&>fHNn@0vpFS={bGgLpHi|peD|mBaC_$^Y zHv~|_Y!|-K=!omCZXB>7u8&4{DDJIFSGV7-sE5{!O_Y<>;v9m)3stOW6UYzdEq>Z6 z-zkwmSJUZM1JkPyz$w>dJ#5Jp0nYFR**?36`X4LRC0=Tij95B~G*e3V0;Yuz8>LS7~Paz%AV2kQuB)QPt=5ud0M7Z>8adBf5*^u0Z^4En5CI8YxA=EyWsLhO; z@M2Z`Q9iYdoJ7x%%7h>xy_Eu_Gy;K>4ih3;!zyPm4Yb@pz7l{ZBw$Q{icBVacOj^F zl*)Z;xzW{LKIJppaF{KYY4CU6n`zYYn@z59oDwC321QX3bx8fnv9mA26;Uy+C%$Hkc63gO* zO`K3`5=!{eJkX2KdJzTsu{gfW-~5z#zbna4MQRfC=Ci}LIDGW1HSyr%U1<)XXfD8t zM+J`xfzPO@6D~&PN~6y(xf%*2KjA`^yo&=>zDL7biDWU{ha%2@TbF;{8cXizx-Pi< z^82>NPq>7qwJ~9Dwng5N$hT)t**Y=ZK_Z=`e70mwObZ6MO*wDxSZN<*Sh+w#EL)2-+yu5%h%w7Y+s2e07q~wp;1Ou0`%I-LQrXl$n+%dLM+GzOvxf z^3^GD@vSBc=KOu|^}9AGDd@H!>Tu+2#WOAjR0iJ=5OaM18Dhkv;Q0!lSyzp~1lu3g zr!;^op}ZAcvtOgpgunj&-&HA6uwL$K@}EeA&QgFV<3x|zVnfG;gPnY>9<-rf5AcXb ze;ohkSG*e`2w|D1f7aOSOo4k~>qyOM46@YahYBsv$A`JnWc+NHZ8y3 zyTh+gnT`bq9n}mr4g_Uh3Ez{S{sAxSAa_|szTczG+mjrul}9;VLbY5^8o&DenQrt2pOBLgci!&SsaRVvb8r{0z%Ud3YA) zX3>k^pC4JymlTZ-xUW1*AHfavLaRnGmvf*te z!q!bSgM7XyLNdsJ@|!}I@eCJa)DviBHe?4l=DPW6-@0j@ZwA5_uK)<)IG7iK^Z)c%M>{<5|w|AVN)zVh_OzTwi;s^Tew6eJQO6X7y^enWJS&CA~7F z&EkD2-j{*M@bEp1rmpCBbCbPVH9G3O6Tu8`)T`%M`#R0d@9`8UI4M5AgRIwH!Fke6lRkU&=Ti9N+kmlA+f zk^T`3CNpHOElb=bMIOaCwd7;*7oTmC{ZqbfnUyr9E7%->Q#V0jMG8pZ`Qz;cOJ;+4 zOjO*DR1z#G=U#YbO_CgipQUpaRW-fMn0~bSPlBoYkxGVlPl1Bq4*l4Y*usG^-7A9Q z8nUyK7!~bIM4|@jC}cNh@rCK?-x2VI{bQ2KXZF*zS0z0i^x@ar4A{%{OI;lqz6E9R zb4~{G?wjdYFukbDkkC>N<#8gl+QZUh_b&455tMEg`22wr&kV<_GKrAgB=lGUN*DdN zkZ*d4EU5PB1ARiWlnl>u%Z>JYsJ=o4h*;FUs>nR>bmEm8;?(GsYH;|Na@fa?;B|N( z1i82l^4oKXo()mkIy-Z9O~yU<93ycg5sd4%+?;7(hXsjZFnmE$4o)*V+_wfhH9^K> zZHlvfZYwJG$VvcNJEgfsBqx4LDD3Q@yTdT14MSiwmCHUh0;#RBLX9$_oD@T9(gyR6 z9XV`)dq{tG`PI`y65%c8vizPzLUnY=>PRCyP3smgDelmZ^ceC$R1%W+h{auMY3O-v zDpWzUvx(ig=EV%DZE`1#TI>T1H?sO6BqL+4++G!xJO}s>Gn$8}Q8V9ZRxFUy3MKTG!t! zpX4}lbh7KHBwzKLw6lOo4L4McRiAH-1%;n4Xd^xxm#y=a7<0dpCq!W_@5Jw5$EjRuiYd(en+}wl1Q+vmcVRwmox>*yej*>rq=ME?t(pJL( ztxe{RXhj)e`c7$jW?c=$pJ+`pWx|m~1KwrvIJlokfCuuvplIm^^3oVBFGfTN-2mO{ z1VNe>0(xu|YVl~(%jXjz2HT6+lT)V!{zmy5F%%nvqI8(58!;^^Ekv|lIwhD@D+Tn_ zB9Jit!!%YWIJh2LW%iT*jXxz|q38yVFrZ`Xov;$asq%}dY<&}f2|m#YMI$laL9pMx z2jN+tC`tU4cG5Oq=AInr@(U+GaV@Dg?@*+L*t1T=p+uayjV%;=`$V#Sb+WqWb-sUI zJH5;MRqlcuVbm)u)q)fI?(8T=-Y7SQxijNMcuU|!PeOa7S0|KUTx*p0dqh_S-SKsi zuuSssp0GdS4I5uV?N+QPByScagk1|}F7{*n0wSS_8p{;KDT>X`m{ z&ik`d=Q-u8o*GjA?Iw-{;`8ghF1*P#Ae|=W*!p5-V%uQ~# zb1Lh4GStAM2Z!p{+i)pwP zGvzgj{w=&@exs|na4Zx|Hh`y_E(f5rOn@1|iuoMQRj=A>9B4bod^~dbh8KhYU7MoQ z*Q%+7cE@5OiK!SxPnm!{ic6(=NH(alJG-Kqy}(WlE2d z0v~$A$LvGni?U2mXLMz5xkmRK|LkhC(^>n;4D?I`GF?D)EayP=lae>rdb}Qlm~>=9 zbta!l=#HFBC$49Lrt*NjtEv^ieu$wW9Z zkzdhGnqb?p_CYZSkY6f(CB+zpipYuuaW)-FQn?4Rv`6Y#xw5i7#P*A%I0X`3y6Ifm z_D6N7IcQtJ>2sa90HHY{p3`TtGN<_d%KOd|>-E?cBp!s_lENrVoTfZPq1b+WLWKDD z;`&OIIL;ZBRvQyv^JUV-qrXOXoXz0c?B&--mmlmG1}l)Ds^Y5L$&Y3ieT8nfUie}<~12Uh)ivT845v#I=2*a)X1;b103l)wEhr}Ts{A|I0aZ2ogTU0yZqaQVG8c;q?_z# zJ^+Ho$Vxbe7xv7&C&3L&d2!}O{U3#Nbsz;73Z@#w@i0{dxwc+^D zk?>kaD#1Au>~eM};hRDz2MVCQ=tn0LlB!{LeCA_`?)*71h69e7oCe0lCL_!5wgpK* zyh{lBx0HGaWl-syE32mZRiOvE@&l2e8Q|>wz1qlx*}&Db$XG0b+Z1;5j`uD>naekf z{n8MUrGI@V1QV=iB{->->C;{Hs8_~5xKPw>Ww6Z2?5mp5vd@fv;WDbMg`s71<*>eV zgp@J#3Wq9Ix3^o0hrX9#G5HCi=bEsnGv`U&q?3-!t4yyn1~*zbfo)pp#7 z*iS=N-%e%Nw{?8A$0E`ly9cKj+Kwe2a_2q*Up~PZ^(cwo@a}ue`zFzzJ%yy@sJXj@ zw&*Uo=Z_U5opwkv*f-v2f=kxRXXG?r8J9D!BNaXTu%Bc-hcUvi5CWCmZ!*yWjc4GH z54b1i+oofTW%S8{!5Wzwjp=p@ns2aG_c_JVsy08+?c5s79@@o4cu7L?w7I!-jDo(U zA2AjyE+wZotSF1~)+2H5%d%c7K&_T5LtWIVx1X}ym$+Zljut%J4FCKk5G5%^TFxf> z;fPhPjG-|?%-w@gqNJp2&&VRPbDbcxm2#*AE2FS^u^zYj=}{acJ!-5?ku)M=8mKwCRwhvPF?L-W=Kujv!-%>`c!1z63a=itkX8C^RPr04QhebK_ z3F%hf#w!&(2fo_t1wc%5!D9P(pjM0Lg3pu{kgZ1sQ9|b*K5>=?``(P>bn^t~!Vop+ zK$k+7(@RpyqJ9YJi)B7cYLKq3yO5arpjF;;3O zGOdHA|iy6NY+!5$nqnEUA2^|eIy7%rsE%->F$$!T`EIHlcdjQ? zrrT~!7IU2>&TT|~ls|l0RYjf2vOcNxLNmunid_!g%Ltv6}{Uw*Mus1aA z_TA9jeVxa~0hT6lkhlWHAo+V-lg~7fKtZk*Z$Hkm0}eT|M_z!bk4-eGNV*_U>&Jmx zBvT7r4C85S%u2>$F?9Y#dF7}#zfHMEy268}4XX#O(SHsz8s%X%%k?c))obgc>V)sT zi>yPBFAGu7G27BdCm&RA%Tq>V9z*5t@;a2IfuQ>-@M(|z$p@@LMSfD82gA^zlyn4n zi0=JPeEr2K21V{=4?{K?D$HDxnH}GDr6l$8v(+hL<^xJH66FH5X>nvr8Q=EYJ^zQE zzC%traR{&Xd-ndzj7n6J{HU4Lck-ofKB?`}5h5)X%|*fKk`7vLb3A$myNyR4x$OD$ z?~pemevkEm>I_4OmSXbkXiFJamd2)9GAU00SU&T4+)bMcJ(@c7@ew17rQi=s4%BN_ z$j#QdKhk+6hYW&ClRsiZgm=1FrVCQNqlot)yN+@Omz=eIp}!Av=hTHv4L7qvy4R$F zEH9#s;Q|4(Q{jO0^LOM{VNbZIL#_@3uYIF$V%o$XkY>mp2eyY`2BD0gI5A}GVZldn zsSPI4@iid(FvJ17>ht#{gxlb#JjkK8+j0XUz;`y|TE|`3VbCW)=Td#0zAcrsNp!1^ z-$}1iP9Dns@oAD$J2dmaoi(iZ8Os4l09l2KMWwCrj2HF}h3}Qd(=}dAGc*D%1!K=M zA&|z~73WnWFWqn9q?SPaCr3c_Etd_5a9YZLlae`wn&NC0t}_7-kb;W1FxjxzsklDq$@e z7?0^7&JTT+m?&i#TfQ>Eb+EYut|ah2f_mNO^eRUuFz@K~9~YRAvC=#NJJ9C@TyEiR zkUOj#q==}v*VQWSkMW(4j zIZli@W#bV^4183h9ZjjD&XyMNqjZ9Fd{oB63$>YQAo#*|^DoXP^XK5s)ML6M zBQvgNo;0cM3Fo5MxRE?N^&jnK_#o!cXTa|3Q{;vM|F1N6zhoZj!O7>E@Dmh&mDWjc8NmwKg2bLxx7szk4TSyDfcRwkO zeEn02kNevIreeu{Rp1G98ujW@H2Ls;+x^4MxPp2`E4&DJf(km%iKdT`CR%daPJFgJ zt773(`|;vS)fM=5B>HxtbBE6At#7^0O+*5YLhe5`N*v17{OBEGmmeL@Z%Q^a)OoE? zWZo;%C0#qS3T>gmxiy{qdUg3~Qjmt<2n6NQHMpX2BWI<|VxVK@9xH1Ky$EUiz`CsF4yD@A zR{qR-uu&j%N<7ONM$quW%VPzM*}`rtf;Xw-M^k)4q^rrYmYpF@;Tg1%zx< zut2Ag1mTK`0RxKmoeG(_{nL1X1E&B>I03L6 z2B1h5s^BD4VxFHQluIrL>kK?_a=U#c!^PO=P~oX{LE3{IvxuE^BmQ5Ul&9Vs0-{kn zJ$6Okm8S<~5RAaLhJK`=YR1?)!S*Xr)UN6-g$#?fDOfST#%WK^Bt{3JR#E+$dT{^z zB*AIZsn3zC$-Z@2;9^}qN5#D1N=6w4N&wz>myLE+wP{`eX0Isv`ybacO=_4sgH@GI zYG2QsGX^2q%-Ly}JHSA0XNLLW8+|dEh(qCZ~=X1L1;M7qr z2Im5;hL7d#nJ=U@FH==2n=8IlR&(3~2NWzO9GU=Nbav(G@jy+7dmP>MdN(@fVCxuy+&N3m<&6;Rl&oMN9& z9z9cLo~U))4@0#gL-q}APu=sw)Ig2P#jCZGl_u;7Q$s1bD_%cz$CA^Caa<$|0s5js zBMxnN7{QQKINeWlYP#}*CP<6h(8iQByi3xfNyGo9*CBr`oek3D9O<{V1_fQG`haAA zDcz_F%e}koEH}^cU1toGzeqUjFkx$9iMx}ZHWU*qzl&^Hw~IYUX6~O5CaJaz)X*2dd}cV0~)h#90dSPI3sqO+zpTW&p9EcRCyA68zd z+_z*Hp$&%jcYU~sl~4}Xslsg^_AJhD2`zsF@N4H5Kh zlAl z+)i14wb^!3;VCvNpe*Aa|68!Fz*uP!MK+mg&8Km(x^(|?gU@Pi4dBuCXD`z@3i)Op z&;GYx-~3UCtc8C2w2LrxdbNg~)8laQ)x``jiu)&=krU<~m`#LX^_|^XE~>KT30sR7 z?`Vy*f=^i_nmV>52aO$Jho;0a`?{c9knffsmhy7v)cJA&64+Qj_L>T#m0%{9yc@qW zZNfgd_i##h!O*rvdwPK^dp5vHsqVIJ_-f4>Q(cYS&S7L^By8eChYpFeEo?VeL5^H| z9+REk@Pz2;S2QZMQ`jkXDt0d2<}^2w;ngmYb?iq?8x%2v^SS4Jv45FwZXF<2m?CXd z`@Mstton7RiTqW2dz2BiH|=^h9FwsX6>$H9C|AG9O-*|ogXg10i&ZZu5< zrZwBkGO}=VpU&(4v&rLH9(rB6Bnna?vAN0bMAE-iMk+L;zZb`2P=}k=Q=z#b6A;vM zn%v0-Q@*t;lJobvV(Lu`8h@sL`D;G+yV39_eTgg)j0h%lS7luXwB{`h*H9UOdBDKC=Pn0uB)X$;spvjMxRMe zvio1lcFn46tMydv-*%iQ4WO-cPE57~FH#J@uG6Qh_?@}aa7y`(3QxCOGa={-k*Fm_ zia3@jek`HF_tl?R-KBc1pH9bo^HE^C-07M3%P|J$iQkC%ycN3cb3dW_<)NVF`*3h@ zs$|lkM0dN^1!TfyD2Uv_D!U<=D{^NBBl*QJM@Neb@OxD%_}&)n99JkQeYo%=y>o$k z1WrHsQtLfzOF4Y>=UQ%NS}>tCV37c>-HFYN)aF{8opIR)WG4*XNt6US;|kmBT}ej@ z_bZu=aB6MXl*zl1OEIE-hm1(C&2t-Q`DBOEsaEmuS`J|~ic^5`&f4FxnRm83fb1&j z$Lusr4D3^Rbuw?0k&7&@32#?4-dQ&`t8!+<;33!OO@2@D%{(x!n!GtUP>qzT-qd?j zx2C(DkMnfT%ruY|6(VPN7F|9a`=JRIx(BQAoBIKAo5aHH8%yP^9a^0Gm~N!<$w=8; zs42W}Ts5g{)V1IB3wZ6x6x-aEB5y4hn>k~sA+F#S*?W@2;UD52y{VvEF2@}!|4<~T^YGH+a zR_b@Gs$_X|#W3y07m*|QX&;BDvyc0VsDxBAdK5!0oGjGM)Bi05*G{?4vh7Ql4722F zHOJAKj+=UlbKBA`;A1`~|6%*|SQ@iu5u5s0$L{Fvy)~#gr`NLS-|g1R6)>&TzwtCt zbHAR4!6fY6E~8$;Y7x`gs)aKLxu4$&tkRf5w#dV$%5Lfe6nK7n;`XYlYMbJ#MJqB* z%1LoD__Rs>FWfJ~sD?M4z^F7;rF=zHEZb5Y9K+-s42s}IBt59RuYpfsU2Dr8JeYx& z#fnN>sRf{_G(&!06ZF!R5N{Vp*mny#%^}g zW;SEdXmS?nP$qpU5VTKyTb$=>+02`(qQ|7quU%V($HjU!;bcUr)3VCI>9%dhCS@bI zW#J>rf%o)wyPho=%e~j1IFk1?{dPKFnl+0(hHu2oKJ*hLOBP6z!5;ds-~Jl0#lJcF zNv~CAFDGS<{oRO^{!s7d;-at^Z|5#`M({erhH1v)le!$A$~Q%r%1ieTFZDPUpxwT4 zJ@hv|Uf4OT2n)Sey29no%(&UkiAHQ3+;_!Et%52)9;$~ZZhDadn6P<6R74LsPw-2& zYhP^c$fugAs&YUHoCjGws@c>=0yo$Ht>9y&Pi0b8v?KWJEFSHw!W?ejG46eX8uh$L zT`F7qcrC?C11riCJi!|+_OZ2v75;G|EacN|;@BiZOk|#BgmfJ}sn5M>sQajv???BwZK)VA#YrvLrM;sX#@U)hfzR(TyBM{{>2m~8u zdzf2ZgQ{eHQ5^f!;XS}t?jkiQ-c{&RRJ1gBi$$Wj*?!(igMsn{+ zZWg4&M6za&eI9lB^C+2;CB0wgVk9!%1s88hci=tbTXw#z-i*XmnPN#)27KfD+6}Xf zN70K1=bPi}u|(2zd6tQm#KK(e(ll{&r+*y;-~4mDYV9>&mY<(x&M91OsUe&f&X)@# zOzPx+ab&Tgs<14GDVxTgV?le7o~kYH%Z_BJSdu`nz;sf;j($b2GLN|O`*Cc4EA$of zh#xXJ$=3Gq^aPoig9#bc@V2g<=dS^if9?tYc|l?KVM`rfx@HVJ2-x(B0if;8z?P$>4th5RhVmhGj+M`MkrPfK6gpVhb!h*^Ap3=4N2Gq z>yuvLD`QTn*9*1enrr682V_EQCjPa!4?-+CdmqlYv&`f!CRP}hBzyciNOL`V+GDhq zQCw&w;T#y&qRS%JTslgRR{#HN2<})<30$dnv2}MB z#5YSdu6`#y;s(ig2BsVEiNypG{$M(^Y!$Ls&J4n`HBWx(*mxDi8Y#$&62Yj@dT|0B zNe&Vh%>>b}UYdpo@{JU_Zjw~|>>hs--HreDv1nM~bl5x-%b08pZ`98$twNAs;2j^k zvJ9-z$c$AcgOtYcmw^+7&*}69Gt)oeb4_&GF+Ox8{|Gn8WDM5dv6SdO`B31OcEkVO zmnhIl{uAP>ro5^^myppy3M-PDscK4YpPTly7kWGI8ccOP?qk5n`U<|IP!ifqd2$e$ zkeP#V`HX^5UXlPF%>Jx^L_&g;Sj)3F&^g8d(+lW1n(X&=$Vr?rtzY0DQZ*|@HOEw} zGA4QSvhopql_g1#E@;f12dr8yo3)TE#=SBVvTJYO38yRcFUs~iC&|lsX_hG5_Wzk{?SXCI^zytdJ4b9 zXDXMFUwP!vQ}}B+dx&gXMy1O4{U_kQm}yYgwJ2xrsFQWQ+gbcpcOPjP*Kbo@xAgj* z60dN1gTtdE1Z}pm#1ZkCpj6xnfb(TsL{yfl_WO9Zi6LLf(L45=l8}bZrOou98v@7_ z&o$lVR&u{>$3T)}PkYSQ4}qJ8t)5O%eJ(UcQg69bL9|Z6bJN^fK3@Xw?Llz7djpWj z97Tbc)Yxc97`aMt?2&7QVzz2AwVr9nYyw0zE9r&87 zui51QJipD!xI2*#{wz@R$HaK2Pt4%WyI-r%eZ6N*eBo}Kexh}m-H`^xP2dZw;wcBn zoeaxw77l9sVX&FFY{%IKbd_7X9;vg*9ITUgxWGe4T8@QxJEU5z&Y$c0S2SA2P!58w zYJmK9_%B zuO*gwsw9iKHabEiX7Yf}Fa~*!B-#J=e^}LE9P@!bfk4 z+T8#LgvX2wFk}CX7UJ9t$Of)($iQVt^aXZ@pTM&ihNnY6j1WoH6F+{cPU3@{Uz^3H zGxK>kv?hQK`SH&c-Usv=N&~kACxZ+Xw^;ZH>EwD7yhXtZ`i5n#{6vGxRLf2thqfN> zKpWK&Vwx-NkN(g>Juh~4Mw2rPm%HclxyK3DrmjKwSTwVVIIT1FA5|!dh zBSsktP$xsLughaTuq*p>Y zKwmtb!9P$hJulvxVf6mrcUSdvQ!i4+?H8fuOJRgg?|a*7g>?{cfqV$E}^Z;zIsQ{9MPiJjR8xT<+BU<8Hs6|J+WVS^>g3t7&l zVKtg^NT*8p^r2aE1d0@1(v-whEIw5;ze!_)lG|M6V&S)62_Fy-(&$c+n0q~P!dP5I zD25boJciAa>*zj=g|iry-xQEpkun77qUDwNO!KXG7eNQnjhc6n5Y`<@ni4)&RSE4D z8;0l_^svikaw7GU=?)3ne!)XaOQyau7;8yDQyGc7o0c^SgZ~R}AS6phpFs=;F*|^kzUJ)y>(OPPYu}&=5Wbh; z@?pchuiTW>B+;4{#vo!{HV~G!aweov_K5+Vqw0Zoqm)7Ny!iEB6mw@*JAeOEpyPNk zQ}mC&@yDTXBB}R`Eze7-Mer7+Bu9o6iOa7Qg4}yXGD;t!9&&^qZ{zI#gSXQo+Boy( zDmlW?l!?boh3hD=yCYWAxXDkKf>cF6-|gi=A?pP}d50j#99 zM2OauN^gigDz$Dbo$(U&7X%lNnCoi%iYf$A$In%jL*GLC2dwm{k;6Z@lVw~3negRQ z8}iB?{IEWY*kQ@bm-ccyRgbb5m=`$DHhh*o#`*@(5H_3gOXb!J>JP}McDRQV)0{2t zGKf?|UEr;^$|Cx)y7?+Gv+7afGU*vK(qq;uBlAmxx zG#jls_j@_xVngr-WHFCEnJamoF&z1Pm*L5sJ=U;PAw2x}f7pA^s3zX<3ovw$swf=- zqJWBYA=HSVpj1Hxq!$6D_f9~RB2okasiF7YdzapOZ$WwsB_yP6e*fLGG;eWd!-l1i-~S^wkQDI@0>@r(J?f zgeInCZsRI?8JQ9;)FR{uzdg;yFtRtLhAVj6Px-l zw{BeK;Zf3m%&V3MCR^c%M;8h+y5*cupAvG~HXEE$Yb-xO5@E2G05nlaj?t9=w+c6D zyfA<4(15xXL~bdt@$)y#DqGNQqhIccfR@E82U-k`Mu+vfXn`eey>^@d+|JR-e<&Flshvdj6r+82xfnu5eQZK~P1q9TL~Nef|I`n$r}2=+uDX2iWT%^&W?B zZa2REIfX=MzgW4k4)DN;cqRhR=IUrq6UPk>`k1yBpXE)V7-{vs8!F^Q`*4o%k5Oln zb7=0kiRk_*y(OgWYRK<2UA3{61xeU+VN0(>fBqQG>3=!RdfldAP~1L8AfPvu;j15< zzIb>u=+d%94ZBv5GU?uAh$OfkY$kyoy(9}%UA=z?aoed+{eS{D`z^Id0P<_yF)(4P z?ay9set;QL^vn2%hz^t8KQgbRP*;-pH@Nqq%SOubrVzDFi{>gPV#h0o3w{>Yq&{`YVs|o6=ZZoZ4+bDcpCC+g8UVn{@A(R@J!XHq2kHYjn z|H5;hFKK}@{OIf98_cOst85}g+nTskH0ni~m{%AqEgbOh_Xu~`pZ?ZO8IkX7A z5hQz{ES{BO|1of4(52GZFVOpS1enwL8zKKRo z_q_B-u>Fmhp!iQlm2mEs-d_bSIr#WrPBGeWJ@WS(8T--{=%{K!VL7&J<4^wrCco3I z-J3fouqgXNfW?x-u4i8=UxKOQBm70-Nkw-Q_Zp2LgazcZ+sE;rEyMhn*7|a@Apx89 z%^J}BqWTy&9Vot;PH*l1&aZy5D9CI6!;jQQ_jaY^+vS@V5ui2|Ui&xJ%aACLrl{fKqL7WR-dWCY;93-%C{TL%h z=Ltx}n-MZhwYC-&Hr*vHn*E6emWsRg6;sDM#ChXU|5AD1mP5D+avz`592(O z4absg)MoDl{dLfFz|j}C_ujI|B2+L$pliJcNnWe4a&DTk?&|Fr@pJA8I{|@YY5W`Z z5|7ghV^A~KmtGQ0vzmMp=OP~Qf24t;rt=`RhaynC@`J?#%YDCi5&c@Hd7pg?F>-$X}8^-K9&-7=yy{ZNFW_%SDA;)#b9y-gwQ>wNhV&J{W@2gX==a}r#4E5v8XG}EHuT`Zn zjy-xjs=Lu;?M&uz_fTaNfm4oZp#lfDxyvKSPfm?u_LMU2CDr#8F6$Qbgn%7Z#g=av0fAJ{ zU6pb3U$2!|NJIDs|xt%5|i0er&zNYF|7~I3k|PZhB4)1M0aC|XDMwLJ= z9rEMq4nz%Bs{&p4k6+9g)^YX=m(5969pPjq;3=dSU!Hzz{<>cRA=FuKGH$kQ`9ehc zXR{$xT`)3h%+4`H92HtFyy;_F`_^`yFy)i?3MdHX9(IlQ9n}j7Sm*^C7Q7?`t-7tt z$U6!(1Opo{p~g{BS(~}}&}k?S{!3S~+hxNH`G@YJ)Vusy?zuz+nFzP?XMqpuH2uSi zgcACnfkvJNWQ~>W7iRQ3eUxk+*P*4o5W(fjZMR=vlm2+!a()FU8#RkY%gep+gAj+i zajI#$yZD11ecZ6|_iRwW=FisHfaCrP;_Hja)S4f;dV_ugqenRxLHSKhuZbuvkGj>;dKP3>JcD7NkC@>In3GbQ5hJuv>?q87y^r zR!+risRq>gs zmU|noiqv~-u1DtSiZcU`8{Mf~5@r&mWbQf$N1@)ff8XQ51t0PR5e;vNKUyn`wthUy z2|(UXyPNfg!8iWiQ^xE=5&mcbX@236@)yQ9hpi&RUDI8DEOiek1GzlN+}5Gjet`|c zBUE5d70x1T%HVSka~H+FrGiwidkz~?RS3{F86Ds!^~11vk-z#^TkNu>OktK{kez}x zh6Qk zgCj?HVa|{0!<0wW)?To^o0@Ky8tUpD|M^Isx_h+eWc>W1faJMdIWqb^l|`N-M_Th;vxAT1wHd>zG~GxPSgg`o9L5+D}OwRz=~5(zN%| zM+b(Lf?jQ$?fky2aN;K)%2jiU{EVy;t@6nKN`R3G%I(c5R(*DoyesPfQiVAi9siM7 zkKznp%TuE8-d&e&J1Ig{-DveF4e~rYV5#m74n)VKWAp~2Pe4C=n51o1qqR66+`6g% zmV}&+=nHe}cHoL78G{}hB{~!4K}_ua$MroZ?RoaLOBJZ{;n_;4%!g|wf>e>4$vC$8 z4fw^TC2>8>6=al+UF)CCrv49!C6%GC`sYM>D)iW>B9)dxUdgC{Yj<3(t^{TTSA7k- zyhmuT@Zjg0&ox7-$(~tLaK*p@<|2z!%+*r%QQg066Te$>KyA*Vo2)jH=gc|f_o*3J z@8b8JWbRw5ZB-^Mo?^mdVZoH=FJ2$W1ZKv%>d6_cb|}yY$n**@$+j1h+TEM~+~4H3 z_WKW7dUJ*O2-AYhxpN(*CMfUm z;77tqle$o?@KGNbrn>0vH_!ZV<5I$DeUC5H;fPQii`cd7;o8-!ZnI7cTy9kCm)493 zq4=EzCy}9#t~yS$Pxt(f&G(}Q_@JVp8Q;<77Wr#i=G0Gb{OIUBFy59gl+;vt@(!@Y!yX=oyQR1=vs12B0|1Jvo*8R7+p~pYHBEko+gw<^14cvTEM{ESz zxI0miX&V1@FYAS?GE4w7(y`+gE-6#HsK!gxPH{LqexpY+K!3jA{eeg7AE%08UrsgX zE{vmQ`~uE%z)?eg_@`9ov>G2goPWK(T;2lbG67gI(n9C!N2T{ycSvCMi(ZAkW4XaG zm-cl`&?QvOYuww0iW^*+@i=>hT~oXdwuJ&<9h5E!C%zMqO+R~8QPK>IkLh6o`Deta zT-nMD#bs5*!?O-N6qNZU4%{odr8D?p6VC1i*RYD{@1MUBq%s}CG5+J(MRMmpGYJKu z+P=A)C_jat(Au%UOM#shPP__@W3_>-`uf`(MAXTknh_nUIJ z*4bEC#g4e~Y(g|?bPq*)XwUp6j6bWN>uRSC0wnUL5EUl_X64NMaVTp(W#2+n%(P#2 zzo3A3B|Bhpfl)Enik6^VXu$lY)X5+Aj%kJjO-iN@{;&mqk&b|Lh3Fxzk19Z!z{^u& zZYkBv#IqDx%{%<|pk1pgiPWbW*mx5sU`j(o%%$pvt$#IbnM21saaGL+21UWPh>h1l$&%shW}?sGDI z{7F9+*?NHDG_eV=1$u~$-XtS@?7v^BjgQ8| z!Ggdv;G@2{+4HdJsnHEkihC@U%t&}lIADMI~)Z` zPQ46?$1D|Wff=42#;wJ?>ktCi+AR>=gSylQ z=-JYhn~@Xz)s@0Ha~P$XSv=kbCb$SrrDIy?3udf-)7>;MdZ65RSH7gO3P(FF&M{YF zk4eD2zm&o82tVoYl|N)aja^mn$^^k|4S|}s4;OUw;@f> z(o4(qpy+%8Hded-UVdY&aW2Z~p09UO_Y!Ge^9@CbO*>c7%vsT-izuKak5XK|3~7_( zfmAvB6$*cv+i*tp?}={$BmwHK{RC646k9klnl2Nt;yW`MPWx*V4~SN`pcZ*&djEtJXD_&~7I>M;phPi*O*Gtvc8Ne>mr;a~^IZfwWEZ8ae?6U?lDFpkxR4Iz1ttLIL zc_T0RcdiEKCAT+E_8f5=oKBQqoD#{#`H53z$x6080HJuZ<=;6eZk6JnOm4`bK2Oko znFyE(mqI9I`UhfZSj3<_7p(u!tAGJiL~Q$HE-!R9Q8f? z=y|al?D_Yb`g$;K2);(x(u14!7-ksCb+3=bPL^#l=<`(A-Y<);_S(E%t+D3#WRT&b zkULi27V6FH0X6{>`Z*O(*K5F>rm(0>stoGx=`G}VMnhr`-jQZ_zHF}38`M&F{ z61a&mV8K`&MFwbZJLYGHxijte?1WHJLj^EAn&$b-?l(HRD4~6qnZJF#aG+;kWk&wF z@$@EZEid}y_-R0Zzb!@RBv(}clKq19!|`ALP?gy#{gD-}9^YmT!~wCH+z$dz8~$f_z31au>^cSoA|wS&Czd$o=vH7?I$DoPC>kAyL> zMRk%07XsobRr>nw_s?A%upK02Y;ws#N3LDNC~vnad*wRqu=0FO5~)!7COig-DK+$p zo17CWIZ|3E@d8Xu9_fg@*^zSLshJ-^QgQHMBJ&ONqJ>6gW2|csMf`j0Xnw*#{gggp zM25)HFBMETJK)N&6?G;W0$o0j1R6-dPg@iO&|VWAi~o+!dHGgvn#JVP+k2bQ42VNw zZq_!C&2P2>fpp%sZV!q~*&Z-fF#kPOAKzR5K;$_|bx4Wq+ExUgBb4g%AMY#qX56?=BUX(xk3l ztoGlBl+6lBo$)LPrX$e+i-{8IptAJAVZ<)}?_{H1&p~|0XTkg2#P=r@i5p`Co4gMW zp01Kq7R|NDH0}5tEB-l2fb0eH?)AUlzfrD9tbP;0CU5<( z0@Rite)%&9S-J*xwJC|HMOlyT_m90fiW#!H97^7|TbK1TW`CzxfqNHtc9JT*w1d}a z43S!E=SZK?`p}n`%Fae}{yaQnzx{#=R>vIOAXIu@lu`3xm1b(Gefo314xVlz1a1zI zuuz%BiFy&q%T`tShk2a$GaU5s%iru(BRiSpb%7LkN%^#89bBcax1O}mepd@b{~FNQ z&hGQgra5;!`p8EEE+X7Dp8kFCi?q2*$UmP_1NzbWTH#g7(g>}yh!I?#WDTOF)RNKx zTX6uIx4d>40}>J?r3q1C?4JM*SW(HZTK+E=Ddyb=A17j5(Ml>&RNUfsXCiI3+i4_p!6D}Af9K;T z0>B4LNhbP*(e&ru9IviFv8Yh4z28xqln1+(Rhu=R$~cU{?`tS1jQ1b9yMB2roSu7# z;tHkz@>RZA^3g|7J(q&xALFxU2%fbT>LB-4Y;J(58yGJr-VoO1wQyrhuWCj*a@!(|jkWPhFKjzXm#&|gsyfeG9}^YW%H}pqeqJYi>pG39i_tvDU)I9f zT~B6Rd@gXi`>cu`P*q?bQ-6%B4&>Bmv9eJ?MH4KNbq_IaIq(l?7RgT;5?3K8nvxuI z1*QE@#M|AKrA+gWrb?_VQjAzWj9dKgTZixi-#7U#UU!?EugI=(AQHc5$)=yIl6BgQ z^Xb+M^zgQm04PdiSf_C5t?@IHG4+VN$nj!qkCF4q2F6TZJ{U!lmqZmy-BbOs6#RcW ze$V>@7hxaJvSIO451G{#YJh%E2FY0+QUmay8lta^8qeN*ev|9rP8wjg&TwvKbTfEP z&~YEte3rp{4;aeM!|8c|C2!J%h1*tfThn$53u=`G&X2Di9sMKg^!|-mU$hzzZ0ZW_ zA7m}v(Q6p_`DD7Lp6#%}(VWTS_F>wkZMDbwp#ObI4#q}w1Hu`xC?W4&mIsNF8Ezy> z=mS4Uw;X*D_CQl(pXl`TaUn)T05D(Z20E37mVMdG7i>T6!{{>(g`(&ZgWTsT~Q=AY>? zZq^I!bdJQNDW$a!NjHaUKYiEDUtO=K{eXj6=>|MK>uf874dN(Q`j;3^EcLN->o z{cR2);XZn3Uu|RVBn&v}i>io1R2Ze;KbwJQ_(b_#XR}o%_CvCYQ_lU5KE1v^zG<&) zz5GhRCsrQjha7)AD49n6F>}v5lQ|GTca~BV3GeioxR)=mA@oSoLqV zTK|HV$($blDkXDGgEAj`1*V-ZcWeG6G7eS4lckYeTD3+20ls(qUeA3z{kmoX@ZYrr zd;e{3h9~@D6=g{!R|%);V!bmpFl_I4M<&J5&h#b@#iH$mmu`>T6R-+vV4oi7=?<}E z^tc?orVrTeh;^bG4`b{8w5M?qbLEuc*ds)!=bWb9-RG7YmO9oBB{$~az|sN%AR>DE z$%0x?WsC}8pv_9lT+Y$^?vTWaZhf$lTGK7V-yVxU} zCjEKN$-wyw?$zMI?8vh!w~r`~=kz(FeILID$?b?=@(aGEU&l7Ik@$sIv^&8~OaVq2 zYnZD^_w(b)52F=avb&pqEe0E>+&GkW<3rAF?*bKMW4u&>%>gY6xRqW1TqWFYOM1Un zBc2LDvn<=q73Wn9`9(O4oQKwVaq^KGQm|mZbKB|S+5Xn_w(LN~s+>`<(B3)3rt(cK z^+m_jKb>l7oZf-+Ll+Bd3`8RE(X!Ixwgau|d1gSDI&Ax5(-PqCSfKPjG5k5{66J!) zFU_MI%VpUm&BKs6G<6Vs?eZ{Z@$#3|;qS#^npDH5CJe~=*?s$3Tkz zGB)?JdbjWT^DU>8U6L|Q8xxvA=q{fR>;73=2yz0PihRuO~p*x|sXOi;q>6rrYcezN+$nGV}b?9SMyDzX*hJy&r;xN4xYNQ{g1nJILjKKkf_O z{Ef2yFxXO5IAB$=yFOF?$Ey4ugaSbkkGs!u)z7GQi$=idDX;j3eFPu^nHH}-l(ZD z{Q)%U*)`gOff>LYc*5it+jBHLSRX)R#(3ZU?hYvVl$4rR_;0B~xno`3+!lB=( z)~V7a#vKe6H?6yu&4TCgIJ#YQd=GLyDJ37LHxC=&76O=o4PD0yYKor9x^&UA2h3Kw z82@P60WBu~HJF0sQ{3OGsls(dwN65K-W@Ry>3a&cSgaO1IhfwSk!iI7Nizy7K`QAG z9i>JZd4FeXzi%G+){2&Kc$X2N)f4;FltTl72endf2;S=7?AU0~RQk=<4Lc~L_dxc{ z$6$mG_N4lPbIS1pruAc<`oc;(;#La(Z&uqE(A~9sCCh+z!C^1se$Y~1!>#mVpA-9j z@kF}E7YLplNd=INf$tva8c)Uv-euv*i&uD4ayOzg{1{)-G#t6+tdy~sz*x)Hz3p85 zMPa&s(tskpeo0f$BRZ!DUo!EQ|M1h^GWaK5YPP*Dif505w}~ezM4ja{J6fUP3Ubf4 zZtM$sYFM%eru2$tGcp_vsY*opa>yf_+MXox!ThT9kiKXR;0PJuCn(hs@7UadcK06A zwSEtZH{Fgso*tfhOHV%+6MLn`pZ=8~Og#TuPm^AjZGy90rS#D*sbBX`D!5^nD^`m|4vbLL9wA7(M;*>tJ7-}DL7hT`dZYzE?P$7)jAR^L_o=oQU& zj!002GLEk-eO9E6%Q`-&u(Fkx`-i#Hk&uk5N}MNf89x*m?tG65o{Tsg!+$}~i^{84 zjeto^=kd~?L%Bpa@v zW5z?WEjdeN>$eHm7x_vhtqUD`x3`JdDJDauo|1b$FhU>62i zVC#JM)WbZa6xjJf*|787AMj3-J>~O;oIyvBpB3hTI!@;u9tT}E;`k`U(;J|q`fi8u zL{CV(7;YA4S=v7t(hoa^EHhS0eJc{pOzJWQbk@tpx1p`kdKrDo3ZU+68%B0Uxw~zd%j)+KhV1s?l98dJ|1KT!Oi6J2JMLO5 z#*+t+f^+7==oOF1(DnSvXu3m>SY`k{7UM9!9G|Da3Z)4z+TvGBlX2z{)OC5mnLGX2 zIndZ=|5qp9`JiO`L{~FTQAHu9I6&%c47J7OdJ1mQxfKGVK|aNA-uF(BS+XmET(~c^ zZ-}pV5Rlh~QlJ~+IG-!oZ(T4(_#gwj={`b#M6^KN?%+rKhW?mkn{Z9E2J~El^Ws;X z2u!J_QBe&{mjl!%Y|_Qi(C46i>`Pj?>3sW;O8a~3j9GJ3m0-6_`|706{zt)I?WY4} z?7$2C;%L*$ebu&c8H3(mOqZa*{fi6w--&}1SI^)Du7ay`fqu#usY>R|G9IpwHbA2@ zVs751W_$jqC0M+LX2d}&?1$Qe>R&x8_dR1z#s#-Ls%~x~#q*d24=jdZx~7b-uu0Q4 z7E93&I|(ZCciYZtEieysyh$ulGn4$@Y2IrSvN&p>kG$=qSQtf1yD^T_!HL38R z$}Im0snV;OL7Wp!z(rUKJs}D+Xr8Jon46NJz_4R+RdNbCM(M~fIgkWyGl=o8{_R2c zl~sQh`Lvkk>gJMvvU$r3N)QLZZQTPr-$5-ea{j8~)YnmfPQrDd=0cU;T`Ey7MP;k8 zN1$c9BLU~-h~sR)yAPA?I#~d(^XuoJ3gRfzNIK~mvLFL|7X2!s&o%`ElSntx?7JP& z(ewaB$&r8;Tq+EUk14eIbbDrQ{oX354graGU${%rBbadwMbStpkIRk(ijMTheG-A1 zzgJYmE&BW6}~w_ zGxxiIp!!!g%xPqZx)u0yf!pIZE`{hgw{p$reR zI!W@+SskSEOw(w!m3`mhH_784(dB3bG^lSPH@=A7mqm;aEI@DEz-J>tzu2Y!)=(R& zg2}-ldRIMn)>V*qnex!ndO!{%-RBt zd!;%TJ_=l4ihu9dL|1LvZq+=Num6at(snTQec;RhNqdMlYR3=Zm#|Lv{6}K?Wo^z9 zI`D0Hg5BfcplK`ie(GC&2HTI1BgM%%{Llr$xPdv%o4iSH^iu?Fu!Z^F#eFJ2ryJyf ze|7mdu9YtxMH9f=3WB-0P;--S)_5{>uvA0kD@!#qCaGLuFRh5ci(e#U3$*^jqB zKXMoLS%+O4;cX{lEi!_H0CG_&NX}hT4Et->gPMA@sU6+>w6`veF9s~8BB}GGL5VyH zTZCM50K5%Uxr%|;w^1dxE2v$Akqb?RIGfe;oL1FL+A5Y$q$eQ!`8eMoqRsPl)3cP( z-k1L?*2qZpjiM>4;B&KAg9Gl`!Gc+;NUNF3naEDg^CTbrs;14fXa4nhs?ND9j;&b8 zBq^+W#Affp-$=%`x2L9XShmK?p5| z?3KCG0t1&$)_<_O5Y}~kvrn8Eq5cqGZ^Lziv~~Tb+gKuDLfX0CdLcH(aBt@C;W?&h zN|Be#L-63-S@MGQMh@sSokeRv!O!I&=&ZT!5nUA#c8O}*+8vWYE$qHT)&u6O4Rd-v zYScWkf2duje>kYb4B5a;+l>8m#Kj=4I$Ny$T0%*H?>^8mlnsu7FB1dR709LV-+9Kl zCTNAYe)(C+f0#6CKb1i)vV`ER<({(ApV!Tpd9ce$ru4!6MI%N}?ZQ0#$Geo2=5g-D3bziju# z)42hX7!>ERDeh?2sTmGbKsLmy=@YvPzIqNM66|{frlzaMFJ1EHU{}Xs#p1Y^vB%2h zmP1Swn1?@J2SMaoqrbnq*+d0>GCM&25=CysIx;YAGX^y=W5`}*(4=!<2>;DIKjI5a zaoV5q(9*n=@!CsO?z14sGCd}(c}Nn!I)2)L=vJvkO)E0dmz39e4+cX~_3MG~o3cTK z(^5Z?&GbP(QC?Ib`R_~0yg~GbQHrvhsC)$Tq>H9$%=-&H?4(-?#);KG<>1wD{}on4 z0Nl3&@s(I!4=`(%NA1Of10uOD-M(EFj%q|}uU7tOMK@%$SUdU7c1WqTK1pAYC)zwd zG#8z){SQAkD!t z_85q66Bd11BL7)>yW^7tE%W2q9f;hBFU5v8p=krfRGI8rBl5}M++2r9rs4jKyr?tl zRSDgHJF$R*t?byLChF7sm!=AyqgOy+x<)>UitEwlA@RlnW#p|jc(R^Cnqv&!SZuAG z599fq=dV8U2Q_%3UQR{d%>1tul6a9UAo5kWIeqeGv!X1r-IL8N41-ID*?bxI~YIK+kY>KCf&o{ z>k;OI@3zrDUc77u-Y1aNuryEHP`9f7%Z0G?N)=->03_M%N*1&3(Su*e7+sS}ISTan zd0KGZ4iNm~d~#qaGSJ7v$w0TGWJ2^&@J{x2gHXDsmESBvHphm95-SyV_n&(BoDLF4bz(ApCt zIwE6$V3=i5V%WGbes6y<5?EP47PxdmUD-l>Bt_coY~S4cWebMlu!-<Y6x@~RlkEierBkoHEx3Jgw^MVwjX{fB7K}uHPie)d9>DR4S%zW zKnHg?Ld{q2WN@$)n&lK`U;8L%{v>2*VTEoZ3Z<6!+qj)s6g+kbcN!b&|I23Wv#xmT zk=?BD5*w-{X3ZlVd&>^vRs_|;zJr2O#AKav}dy$I%0YwgiF+U&mOF0KF7~(dqJN9u&4SeVh_stcJb-`YFAt3r<>CBq-iMpmyBHeKc6x5xjE^X@Ll6ud zY8u*w9+&WEQU}e73)P+R?wX?ElXH78gh(V%tpJ@fsc!n|DUr?#!W9G}?K4D}L3Qg< zrvU*HoYF=Lg`v|Dy;|g-RMKtj8gbck;iI4Rh1e@%1^Jg)O^G*fC5{mp2t~ycOGw5#peR} zFQUzAv!G_OTa-cJ)lxHC7N4PKMH!kQ0?&s&7R9QdZq$mFeA^PsSo_X(n}rZ z!}TaIXCQ1T!!I3Z{=;gReEt*IAvBl$u3&dw+jiL78cyOkE;u_Jdx# znoLm88xHLS3@^D=pHj1ner!xSE9Qw3Gx507Q{N-IweL*`g74k2EEe$`h>rSQP~fK* z=u=&H9Z&e`E9mqN)Nrr&{yi;FRbb)f&(8E+XorK$8p^=$PM`eX*nUIwQP}sb7;1B- z9wT!}8+fTU<$oNR-4Fc@n%C3T#xf40h{ObId78IiI&j|~M$HP*gTU-w4TbsjN&e@-~Q|~$kl-sIR#q&+(56V-Ti=5o zn2T#tfHgu1Sz{r&nu>qd`Sy*2>vGvMp#qKsA%(LTPjJwWe=UPC(G^mrMxCZfRYcFO z0tBus(==#0nfo8Rm#{nWd@n#osniUf=rGE z7%;Df`$aoPxIzq&a=_D8h3DvLYaSyjjK1=jB;K1n<|N?R>uGSU*JH@x!IKIIyvq-_ zm8TQ%#{L1(9|M(s%GQgHR?l5~F&x^nDZo=g^F+P=SdYS`qH_eds6y6NxJbJ53K%*d z_kiVp@-AP@#W;Z?mI3dgwP(?Zhg=%dLy!$WArrnE7C^eiP=dA@Q~x@}q_P1EtSnu} z&Vd_Gh&3lCIf>27y$V_miuj#(N%re%R`no)oar~Ro*|uR6&U;&8T~XvC_IwLFCsO` zKw5CcW3HYI7>PBzq7mqe;SD4c>ciSOU6G@AaU$;WZeKe|;P!O?{6jNg{G$$GT4(p# zBjmgNCH=bB&hZvvOZZdRw3I)#dr>*&codUqr?{FpQ72m1@oIx&tivC+e+-Y|+ebQm zJg->nXyBK%Ickv-N3RQ!o(-7yIw)2+x-PN-L5|H_G6p_yY=x?eI$~*O@3HBb^bf53 zMf15yg@3I{+>7kPzCXaX*RJ+G{nkzp?LU?aqyBz+LdzJR74q|0iDWG9#0sIZF6}k_ zWR?AouhGMadZ7>F@0)r5s|@%xomP=MUW=!n@rB38(;9lRwa-$|+MX>oEpatRMFNZ% zHdcv@?w*yOfExX=qr9F!Uc3L6%TT$+X#nbOvYW9isRDW&tHqGgRnw2TTJzTHthrpZ z>M$3TOb_NE0u*48iB2*xC>n$R14|8jwxY@jSq&q!`VKGfOF}q|diTx!bXqi}Hmwee z$4*`*oXTUih1beWMM~J}V4uqmIf0?g>#sJ9J1&Ma;w74R(|@kZvlT%8T4P#{Ld#BT zM)BxAS-s`F_NN++0?!#<^i~;t|EV)O5TzNSmpi25b-8ew-O;0-RL1;ti5~5_3Qn5A zpLs%OKbe0W=2Slyu87@~j(8S?tgb?!Z_Z&|l5>5cLf-@HQ9{4Ic^S6qg7 zt(|xrujwc*s&k2NGX%%;GI)AQ0(Wg?nk<|yM?D?pJ8aLNc$(wmM&UM3PmACmT)TE~ z4O;1Ifje4UEe9<*F^fr29yC7ydgqF?A444Y&XnkLrHtxY&^uTwUtO_OV;kx|4^PBX zgyl=AaqInO2(O;#0NYASh*nztApot)V?wLHR1VydV(>QTl&kFnaX3!9U(%}R;?d<{ zT{glRh)rpwnf(?sZ7Ce%ti#gc^yT%mOS9ebb;gzR_}V|ep0xqL!De)}1EwMm2DdAi zDoYZEwqLdtjICiFI}~aEZ!(V505RbuF@Tbs@O0@tL)eL__p_3c_G=|2 zZf#d5>(BO|008~(3HlW8-i^kc$R?YIrn!wt?-Ew`mByrb-+lu=pbqDL`||78LQ(>$ zn)`9rw?fLt#Kb>{UOqS~)be+_PFt<<7)!@FYIt~f%paE*lL!6!%D~1(nFHV!0Ew-< zF^%l$nv5X9(q#hfm<2PK@p&tM`|?r$58yubGd{jufqY^dL72ZZ#$Vv^2?FwM_vHxP zyuue&#k+aK!z&1`!rtFPRfqwnJm*c8_mv=9WcTx__}Nr@O#>~UrYymzh*gMXqN$kV zhb3h}A^^Dx_ct^kNSEhaI$Pd9+}BwMkXiUu9{ZIj^VUO)y39-lEdWb7!za>kF=ehE z!P`$7@u^%;{KS4>Vf^zm>E4dr^GFeyz}s2D-x~3|u9)?^cUi7%XacuFE@-7H@7K%q zdiXIiSonvBtd5T0&A{WP=N*+!zk`o~N4K+%FDX<=KeFSxGDtaLO7g+he2~fp=vvlK z)7-34;L*J-J1j+5m_BFpA!m6Rih{8lzr=9Q%x~cON=$ABcxq>fr%*vJCUY(^nK@we zUZG=wT%SBZ%oSzK&Oc^0^9V2hrd?>X}xfG(33^IYVN zSTX3i|1?kg%OGtARsYDy%O6n~Y0=}@iAHBIa9x#7W9#H%ON0i&z_=M37aK>iv1@|E z7WvoT)T!_^4|cRtZmsFyq}PT!NWhj#(-svO<5$kOb`9~naLRg$>a=+3z-?ZiQxNT28p^J$Of&pj4;LJ43!2M4stl%jSFQ>5GyHep?FiuuU@wkq<0Db&h<% z^f%TXdovX?g-Q{9IYG`uiYFnnT|!+lW+atV3%m=&_O$h66wW#z&NN1$TY5ouJ)@hN z&Q9JSIjRg=;qUWZh8wGAZ;>QOVt-B?XYdPcUJyez<)?2%-AtRolu8eHTp9j_lPgnL zu)qOT;S0(~yl_>fToE0r&*ARRZ@4XESqF5iIX4;~C-~>eA@mo2HzW5vQ#YtD$g{ym-GO&BPMP5P46Hcvw2IMbNO+zVxOvtJFq6 zOn-iHZ$V-Kv-o52X5qtP(co_VT z5;~mtB&sCgkW4DkFnBq&<&R&iUwl}4|L`pn-hBw)04gZdh09E*MzUSFGP3@cuccyWxY24Q<+u?b$p4}UkKqyFLOEvEcI!QCSF_xmB$g|0!a@#jBobr9?qeG=x2 zjfzQUKhBapqkAS*$5uz?q3Q9}H~r=bts|{glyB504Mh#es9MTt-Gkchx~01I&rd${ zf4M&F^tkk>_Bc7z*}OfjaFl5>W%9RF_tb9nD0Qu51u57wYRN+hik;A%f)P{wGsABNE49i7AN4=z zx7Wqh$`JbdYB&VUG!NdCL?vppkBS=-Eek9w%DT!jx4dd`LdYOqBB<%M=pyJCqt&AW zqm4Cf`DF`y3!d5-@7~#c$ZU{Alhm(*jOpcK_4?#W>wYZ%Ch)iOWeWS$=vvvJLz04!c3`~Swv}_>He@rax7sk{wW_sTv2vQz z5e%urHvlezB8e!15y54Of4Cbm*}L9#5q0(O*z;8JOlNgxv1U=5zcK$`*n9J6sKfv7 ze=Ly_C9)eOB(kLJj8bG*DrFCm?E5xm$d)W)%aCD`kZd9QGWJn+vM+KDH!7bY9M?t8uT>cs`)xH&Ge2(d( z)~Al9!k-+P@;#|2qANNcxjyn?q(z_>G@m{2DJO+;5=n_&Wvz_gixnu)Ex`<~gb+FR{GrP+-IIu)cO}djMu_QaAKs@F}zsvTL{36~$B> zy{Jv>?iNcgAUVG9d3vxR3fDJ<`$-2>zpwdIGg6(?L|^v6DrjPL{D|-!H`eBIZo<6m zz#24TG?z0+=hx}y>VM)d?W^o>h>>lOn=GB3gP3lub_osSe6XILCuz9a1Y_Dt2^JRQ zT=FJ^217YBj0UIo<_T7ERR%*l10A+%=C^Yn_YYgxf45i1>R}akZp^MDlCu8RNru#t zbGDj~PQ8y+#a6@{3pfiK3ZE66I{tBD68fuFM;_W+>=dce8HKq42Q|EcDrGm78~5+* zt)uJz)&mEh zBd?EEy19xz<~(a2D+(CcO&`BM0V#2EHE~CT%Ir~2m=o9o6a^LF8qrWSLfSzl?x0KN zwhdFQRLv@iDU^)66_VxExY*c!JS=#cy#kbL{5w~v-qySmiY2cx-(zOZjr43Ue+n=H z+s@AezzvW3E(_3o&Y=fvl2j)s+-@68TzWr!fIP?(0J`yQUjy(M?^deaN0 zCpQI{V8U9drcZ{RR6dS1DIU%kJ{js7UAz5MzD9P=wxxeR9xtNG8>3XnWyTYrU@Yn( zi7}*H3YTbmnH#kDFj;i?Sq1wNQWWy&>Q}GZcPjh5E%(9a-hH^H_vQDOo9*ApFTPdGs+K#mxwanW6 zMc!QN`f`}ZBF9qE<<*bVV(~#Mq$d4hm7p)Gbml|jM-7c%I8HiFQ?Y3)DWS_>T@qc& zTyiX0obK7ay8n2-=wzuS1)b0T`n|lt3I|t%Q})e>;zTp}gk@>7S*?H0GCj0;{^4Ns zpYDx0~UhC?X)xf#xx#o_>QIsS>`$fQV|KFU+c|uF4MG^TWLm{&a z8=loTL28bo5e-~x5>d|$m{`ogkO{x~irUvlGDq1z0I#V1h9D@{y%bKI_YlYO6Kno5 z25Wvc-=%t_KcF}u{Zty(0efrnHul`y%`w&eyCZ&wOkjH|`6wo#zYrJ#`UqvCenE9L z;E9`r+Uf1d-Br~l)kct`KmSq6ksDhi8{2ydH&cfR%P{rnp4_!gWmNhpaosabSF1XDVcJ6Z6j1ZEEcR6`|I31_;cJUATYTQ90KYw-k~J=w z$*~xlKDRfn0&jzdN!z!B&f664bB!t#7F$oym&eqi{jd8M_`RN2n#zVAJT-Rq2(rPK zo=|GSDQ%fFPl!BoC-1&JAJ#vBUhK)v6^u(AMn1@6W9o3E?#zrVKr0KxH>|sejsDZEnzL!vReyeB}F*)?jq)P0? zIv7E;^5wqQhfBXTz6)(we-fIuZn%2l746(@W^gLCRTAwwyyYi=MHJ6^3W-_2(s%|D zp|fbqyWi5Ddu{j&i(S!DCWms$!zWH(T^yu;TcriC}!Gxn>AWf?~mTb3cM4X z%icm=l znLN%fczahn7Oys(7mIk}ifFp%nAk+&odSigR!Y{X){rksB8u{4WM@gwR%@z|a$^0J zASR;unT@DV519(3;$UfnbZvx@bgzofcam#YR25uO^}G8s*o0b{RoN*-2pXPez4`?b zVQOz;>h05(Ki%tgi(GoyOz3C7b#|jdKxhqF7u_+CS@w(nxM6ozV4HVGaErh0?j%S> z_AbHhjeX#*!qknKjLEtU!Bm#DM#!<<4iMRwbu(KUseJl*X)#kL9~Ot?>|537|3ri5 zZ3m6SMG`0B73W#vipq^zq(e+mzU1wp{Xf4+8`F*3e#=Z_AxG?AvNnKjpr}x3lDFID z@Px-qJmzD7CFJ_f^KJY#inLC}1W1Ee9+ntwuG9Uv}^CHmQ7rK=A=Ao-_SJJB};IsFi#Gjkt z{<5(0i_!bUM8;f?au6y`EEVZWicT3=JoMZSv%e(FI*23O&3nnsOWdA$)_r zS_*$7LB5}P+E+!pLGm#A(1T}$r=HhC{><&s+w4Cge|Y~G_>pZ3cd}Xews2?VU0p+6 zM^I-*O29PO&tw7!UUxppacfga7KNTuzkKmVAUO1!RmE5NYD z;HsgV!7tOPhh6u^tot96zeU$NW<0O-Zy8XYlWx`}i$=JK{FF;zX)Jo5nNlCy^|<#< zF?drfOK#>_2qGdfuhg{3=VR|e@=BhAyEmiau_erU@Oa)*Be5k4a!kJ$K7Q|Rn~j!R zd&9L!k*d6wE<&e9f}$~x{vRVggJC1VtKFr^tkr!-Qn_2x0^PGI;Ei`n*P>0wMBn9a zzgBT_l)QFx{d=?v$41(~whd3?i;=%$C4rfhueW~_#tJrr1Rq=JG~k0DoWAi8WG!J= z2WAQg^6qB-ZJN-S(wHOL?hXG*tyaFWB&SmU?KqfFdh~nf(|mWcA=cnb>6;k%-znT*zCYS7r2YQE>VwdDWsVR<>syQFUp$|L zo(tF@VoB?8d71=m#8LS zDJ&~yT79V_eXFkQKmG((Ex`CWGqxujljdTOdKpUUo` z;hs~9kc3z(L4$LNv^r#D`jpwNaU<8mPy$64*IQ8@fx&?gN1v!GP|j95^H!ty>*rbl zO78$k2D4;%t850d>Vw_h`wAwUPnqzVak8ui^s4~<@Vo7fwvC>Ux(g(wCc%~uwmM3} zA19G?EvmU=#W|Gp;2 zwG(ReA#bLAC8M%dkn8ArjczM}~PRFeI*@8rHHBC?dGly) z|781^Td@ih1@R($Z@yLEQLo<&BhpO4@gZu7`3&Hpx9k|g)LyRElyWLa+*Kt(iw!9u z=OGW_$V2%C(%aZ%CK5O0Q{XdK3aEpY)>eA`Lf6sI5O9;$J`13WaRxBbT6DC9kG9aV z-=FFK*BW}jXNLd3{qM&cvTzgtpb7Xlt22;pt2uPYPyac$|3FaAap-QgY9Fh72jB@0 zQSXcWMIJ}nR#-3agC;M>g{Lbp#xrNy09t=CGTyk~l(>8=szSC$=Lda}o~TL1^=Fy} z299<~Frx4yL`LTm4hcWaZemXTaVTa@`6X(MJk#utoIh0qhU``~4sL{Csv0KB1Ioux z)eQm20|czUJMKRUH#9Muh5zF0{lBmOcP9QXyBRbcY&Q**7}CQP!PrJ?;-2@ZuCbyS zvTkl0^r{g9ZFg)8!9!5cZU3rJ|Hk^@pL6cOo#U*SEMY^o?bkOH+_JBf4E#4eD)cQx zR^gmImwDC&Hx(hCmmttgDWe+-+Jtcz>NO9QayiT|p2MSSPkU`FGE^o~GXbb3j0d%C z)$Sl?4sv{4UUp2~E4DtqOnr28=H-#~Kx}xL+}fQ@`K(P3@~RD~QydOF<#sS8%=Y7t z^jCOE9#_t3Jaj)ng1v}+QS-Aa66*OHmmgSAoXLHJ{dEpm80;=Yyg2dxm2N6OSv$4$)l={bpGJ9Ox9x<5L2dHe-w`T<()lxe1 zCR{;8#oglxk9kWQYT2CoF@HW~a9c?2Ky&)G#q4Z39;tRmAq1I7doX7+=mgkmRFxUF z9xVC)n6U(f;$r_&P@DaD%*P|@4LmXkPcfqm7(l-nF&U6!I%*&cTI$kZr3S2Cw&ihxLZdRg6$Aded zMW=a+dO7uM;d-Ss6A55q*7Rpak&=vLRvb7A&pw-V>4UbJ z(B5rS!r>HFI0BJIsHbbWdtGQxa>6V@KERGcu&>;&>4?z34h8#ZT3@Y$d{E@s!HCFk zk$Uhe9n_s{3_fW0UUy(o4BeKm^-Wt%jhy4kIH$4}aAPw;QE}o_X2wbbESj^jTaNs2 z+{F9p+F6O|w10}>;`4fzX}+cVF+mlc^J#xf|96GJ|Fbpb>TC?~leKr7n`lDd;>_}3 z0tk#>>au~u_#yFTb(S&=?+f5u@_V9Cf=Bbt$>PfHN7y#$YLNT@x2ufwN}jqgo)5vP0BKXs~8i~YR5|KFR?Ad{jUyC{K4t= z4zn~6b!Y}X388(mEBp4F^Vn*abODb=ZttyvNTW!9vhDE&?@ zp9(h_FKp!7q`)Mg$Nr7fHstA!Ot<3YU}ZUmN6s?huWeDGqZaezv6;<&=#R!aa0J8= zs0hKuo*HsrFZw2=Nb%`|;trZ<~WGF(^ zfw}WH?}CbH>Um}yvxI%%*A2`4wJ>BdaTCe3EE08EEWSIxTSNV&I)4%kJU?|s#=Am^ zf!PJ@#Xc~(0B>3mL~N^0-7>GS2g}oMRYUALL|rWj*lW zR$IGb@2jTY>?d$=tN+qTE{P$U9x?G&byalZ##(gD%R)4m!opJR(^O!HZwfw1as4uRZX`Oc z03z@7N6sYI%p^x--s?^)3@aD2)5CrcN7UAsYLUr-1+cmG4PWtmq*0oKKWJU)e+P#x zkA=ZC>YC>%#5gA#QXF)1kUY+w$^h?`l}(T74fJ$;-a@zqIv_!)dxJf_uRNqf5$tpo zSyG!{f8m^obmi$-1ZEru-tliqKmT`Fd-3jCwEd3%KnU5LxLwPFeLwP%~Bm<-L|}@Yi?(3W=@y7bSTZ_oh8+pM&`jKCl&m}ab!9;@9g$h z!|K*t?BomY=T9Ijr0ZY%kTe2}XUy%+UCuxy%``0hXVDe4b1>bkw~69R9Fj%pCXEv% z=P|zq6MMO{LM>amNsZS2_)6*@l=Y;xTZ>9%Yi}Rxf(O|wt9N<RI0F;EYXx+}OYN zBH-+e`h~iK4lTRiu%BM=11uyk$iQXJ-kaz0S`6!~ICQT#(n9&HYgtYUdE?jUZW4_3 zB!uD7mcbJU^GGj(wP}3@Lo_ryox>ynw}L3G_ z(h_klVh+w&D4Ibsy3z~cP@blKIiwIQF|DXRjaowgC`>VjkIJ={ zx;0!H1b>yGy~0rUA!o6tH8adeNGN{fDs4)C#*B4h%mwbOhjOYSo=$2hRt@tsFc+>u z#lqtZS}g;LFqIZGW`2j@cY||u`1^uwU+JXKaMYLH<~5or1xT{K2T8ZLU}6`o z=@r+ENm>iRXSI;mmeC>7)Gtj_yY-ue`yJ}`TSBWrNF{VO%5>Ej#NWKaibxBe+X*L~ zI~j%jro+!*aNHO|gt%Y9Rjj>*fz~J@;HJ&N-P1Vg@#s8RJYZ%HdLvzW87p_9ZnS!A}xgNHK6i%D9o`k>0@;FB2A zS`@oPLu@yQDe!W7rN-ha6xZQ>jy%0*L?!eAfBhEW0-e~GizaGx7HNUT1p^@IeeA1m zoaB3Hww7VQc==TuX~;Oclkq1hPRO`v`Ql#n>2?>py>Vi*(bXa=tqhYXT35u|-~-*JBBn+1DukcQ6mjtFvZI5xcG9^@K4b&*FVXIfUwemDW6 zGQgr^-Kch@dBl51X{2LWr(B~m^RF8vt<0;*!ao!uQn*kknNBs5;8JGn(J2jJM-ty| z{hG|WwSmX7K5&Eu9178@yJbit^1##9Q&d0zZejBS6349D@I&WKLLlMO)O`@zuS0NE zN9oG+PCWX7v<=KqymMI%m$l~c6=yOlOJ$dd|FlsK58_PsQ^cg%Wq&vGA{mjoK+L3} zUTgd!h;+nFN(gU8@HrDC0V2yU{t~(8{fQOFs9n`s_~I&z|3O8MGccpeO|Wpz+Jb4l zT5$KktJSz0&eYaA23Y7q{cflA9J={#XBZF=o$>1s7)telK5?gD;vbyv z9i9gyy<4=YV|~%yA>$y3PW`oycR`OTJUfD(Q}aR+&P*&rmDL{Rxk8%*seAOh4A4Cu z(v*U4P3TVGfX=)$u7jkBq4_L&E3z#tT5dFXdDUN-T{gcI~b+yqvKN|m< zAgd;z!xr;I{C<7vF5G6nv2~4fkB{S06r<7b>I8&i8&M*ZA72Y;=UK>L6(g1uZ4h1Lo*tO zD1XF3)G}gUJamR^C2H$(UD?vlzB$?O?b z@Y@U&@vz%6-@9Z?++=_ds$=b@I|cCLbKSR}_F$US_Ia;wFl>6JiFkl0WEh)P=T1`x zRD;~bE`017*H=OR(o3NMpPkrgO5qY-tJonz^d zd8Tn$`+1;jX*D)nfFuGpbT)=?ROTTFBNn4 zS5sAIh2*B#&1)R@G}3B(y6~$EMocs@;@_O>=SOgl^nQztKV`#p7@e_XzAmH4I*bfVU{#WEXzx*k?5$B3j+XZMj|sXqoNE0J!-h!-H(*Nzpz>WSY#_Sz zF~m-aiKzPSfXS)6(5v~UXXs`iqSEN#2Gwh#npEwp?lI>NX(n*5Vmbz8p{t)j-?mn+ zT(~FGDrecSy`pGd!_D4uDWsu(x;U9mzdZFCYZLC)E}?0Ar;U1@y+V}5=wDD>VgDJ<$>Ddr$bs*8FNokhPgmSrJ7pBsr$8gm@6%-6C^1lV;Mt);l{@%w%=4)-yT ze05nArayGFZOW``6GOY2;@pVBmB<%mRiRU88Yol1Yhda(hd9lH+WF01o@XEu&3DL8 zCA*P1tMy(A&S-HY!e~?CfV^qGe8%rH;DWvof|<0vm{I zIIYB;xQOE)KTH{A#n7k=11`~JHY{Z_x})5xsBew(Moin2-{6GmP(gA2d*+nSyjV1D zop5zG?JA!$_;2lE7X51v==Bh_kh-ni45xlHKMF61vOnExGyZ)F&YA0hli%FiS>Bwi zT&_E)@`X$ys{B^-$EHrKJgx0tI~$-cGYkys9W%cCc0vZsz;?@l&?qpdmHO$Of6HCy z8BIGFoN^53hWa-)f}hW;eyY5RnXRb9!B%RN%J>`Sz1G%VKxB`F4@gy$oOO6nApiCi zuU=iSmbU^5e=@`%%}#Nw9fyI`QHqU)8gwJ7zR-Y;Rk? zn)z!3$}eAjs=~&GOAr>{N`odi|wDTLw6wYdfQ6z>B7nO(W61ps_QhNqc;f`t~%Drd1ks(}<0hHM8hc7MIlb z@mG_RJpz9L&+%&N?32GB<|Y}LZU%^CXvsu;#mcdiztUVHgoe~1%kcZ-+CXn^r^G9Zc2Ak^_Ibx~pgOo^L zfIq*0S%TFrA((j2)nuP=&q?vqG_Kk4jw6?MBNC&T%d#R%9pq+@XB`B2Jzw>pu_LER ztdB2@|JCgeoQ7YHsPX3pH@3~gLI68w1h!|FUkU-1MTE;!70IC%$3f79hU*W~vj2tx z>##0MI-$I=gWH~mJLp3KfmXtXl>G`B!GC?31${|bt|)@OLAqG8b#)JbU{ z30hj`%HAiP2BM(>@t^pecA^Oa)8$+f*^?!Ps-V>yCuyqzmn~0v0$O?`=`hhD^rhLU zYP}4tdE~K)UNbF7H)W&9J?#+}DOKy9l1khp7LIDCTG4W9DuH!y#<(d$_0kL3f3zfF zQ!HW1w!82SW!uwTSc0;$*}+*+j$}nUptsK7>KVO9J*Q;f;Z*Z!P1tM;P%Oy&I-_rh z6s%OxuVsQjakOoEym!$~ums&2AjTFBqbLNwB_-ECXp@+0LO$RD~3b68S2F& z)s)J0bcYXadIa`1YWUU8P!Od1e+pxOr!4#kQF{J)0XKxikJ`U9*FVLrVx7=W94@H_ zJCf#@CR#)$Y7Pch`V&l?kExqpIWe zfIsCu6kMTuz%a>EMz#lf#@LllDERIVdi{{=gQZ-5kSsY*8tw)y^^G=i zGz5>CSVN2+2C2bWP>ZmZ&FY!{9l`yNC}g-3)u6;O1!g&T7(~fY!ln>^=&%g`P06*d zo4vgJEUXaq6Jv>5pMx%*hu+%7ygeBA^RnqG#z{Qj@}KVtrWG?mnO1 zES(?ET=b`HOwplRa)|9RS-qo;i0or~-@A2FITlD+Td?vPq<|dMUVI zv}v>;Rvd3|3Lw_-Y|wxXSJ3Ko$4s*#`z{vl$bPzoehhAukc;%_+-zP~uO|;pM{bGa z-i`Em%)Tg49C=`V=%secp*OR1?L29gAi|x70MTf@!bOcNi4-l8w*b2B$<5`c+gEe7 z)$_5TOg-mc1Gm>?el!7R>X()3bGEfCFNRG_V0d=8wuWoevKU&&cBW`<7Z6OTZaRc< zXbkx9Sc<*J5}yO>-o_w1Cbd#mYmJ)X^vu>zr-~YXZ|{{UQj}Dl3&>QCRw&T2G|4qn zM2To|LC;3buaJH1)_$6Ia!@X$cDkZFPLW8}J-MJli^?;atc8x##PQrQ09Edp%z{UHFu7ZuSlJ>AuLfx9~A!Dc-chnSsz5>aRb%#6rquo z`nRPZTWl|L*^G-`)LMO0{h*&-S$;PUc;^bMu9d2hb@ZwtbD6z1MGuRVbhi&{KyZb$NhLYVW|Jv^}Mje9OrU`HU6W;&q1+++9IZjk0#RfE2&b|D-D||=2ev`fFB_-$JWe737 zFv5Fljy(}*GH$8_QdeQ)8yKV$RgXFt&BK0PBng;|nD+ItQ&_`7&^sq>St0)XE_Xr! zlOED7Pax~g-1E*h?*&qND7}p@O42+NRiO&Z={>-#m~jPs+TMzSQYf%t9@iRn&|&a* zjl0@(KSD0EgGeC7?YP#Fcr|wfPy5HrMttKcrx;p22xN~CZORT+HF%JcDV1a({NN+L z-fQy9h3TJ1f*=~)I6I#cXqQyjy&lmI+{9RU#zzHJe0E2J0tA}kaae*H>Bd?z0RjE# z)Q#^CJKf$5wB8jx>m8C$+qjFy`WQ;rx`XJ-C?~21zxyT5kBpF~*EgtodmHS5E;Ow8 z*mB8Wt4R0J({>R0BWDnfsi~82NC-FDF*RLCKG2{#lA>{Y%FsS8HRgfG>_qWx28b5M zr|mB44%crID>hkt&#Y7N<-TZZUWQ$Hy zqlcK^7^fq%8JZl)D5qAj_=Pj7=vucab}omHMxB+MvKyk}=Vk^61SQB!7-VWCpVyvk*bVE);T~^3pQ3KEzYt>1+Z}wq;Dl zL8VD(SXaqaR^waDGl-4AaUv~Y2e;~Jz8*h4Z(qe6w(WSVx>B1hdMk<5^5Ca{oy?BG=DZZ0sUYrGUF4s!D?2$7Ii|P)I#-kzUm|Y$z1OX;@ zk5DU1`-^!aRe@q>D;fPbuQSGBlVRh;eP=a*8n7&~Bw zOwq^JX4{wCxo5ZkH^F9OC`$2fC!@F8k2CHA;lNDn8`3Rl{7%|g_a)TMN%i8AG+5wJ zrbBC?L!@k7!LUzK(sY5RvQ{i1VZiAx(-y=2CT$9M;WcRW(%CzDzo3R;0B+pbq8nCF ze#=U?#u-JTcIzq|U=X!eqd!HYh`L^y6T5UdEKbW_W`WNW@7xJOi<+OuX{joJf|czR z6EZ->ooy2tQ02rLQio;3-Q16#O|1pY61xd>#^PZYGwPWCRz9)Fx zWJe;{gARV-Bds4GdB_FyEWNF8VJ<6ARP2u}G6bu`^gbZ*P8GWz4iH78-18b}Qk z8GbYoBjmia8d!IQ;50wOetLXTSR@;Od6z^e*b-5aLY z3%f?^O7LhWJ5z7DQJD=1!Cepk<){?W^%|}1)xhIBx-!dtX(qQ7zL^@o1nm%G3AelB zB}K6ztU4f0YaJHBazD~4DNOQ8uc+Vife0+%^D$?A?F*M%e`u7f9w#a&B!u{vZk{3O zie<~*8v$?!p@hKKY{c`MR*VcLdn7AZVwd# z07<(ys0J6FllYy_a&jmCe38vjYyYuZW=SsUj#E%Fe$eZ+?G=cF$rEbuDN~=Pcc3-B-#s^9lpK@zJI?bwox|6nu{LQq zAwc|yf%C?HY+#w$BL{22dB6=XyVgcSiy>(+jf0oM-jiTY3`e}0H@P3KQl6i)pQZ$E zNC@?HT10H&JB0<=q2~L=~`V$M4rGIyMlAlf$W7VVQpUTJ-Bf~RW zSMXh%IXepFBgX()x&37)D7wROha@XP4|M&wAHlQy$*a@14}_kSJyUxBssdk4V&rrmX6lN^$^y%$P3jhk3XbfMW3e(5Opon-VPYYe*EeK z;o#2ybYMzDWGCWMgQcHzsGm8$GHZWDt}ZFL#+t4Ue56d|Y|Yz~2#ezJmH+Q@EjM}L z0P42;@n3K8fblb`0#t#$5nSs4 z^r6!HrBWz{f3G}J-wbk23LFjrVGm;hKi_|Oif_|b;&bsShuw`I1TyT3A^V}Mz~JC} zl;>eHq^*KCBo`JN-%u7WZ`k?`x6dQvk^ne&Ia@9CW#p$_iS>@kG%Mxr>0Pfl10t`U zcIN93qozHlmi>ghZmSdp>O37Idv*r8k}MYMi1wG|aO+k!?a##Fw$^u-hVjx(cuZwNV0sAQ4rdrCmgDg&Xs8eld^$X{MJAQz= zh6V^*ZDy&|i@DBAVp7&tta1mEty%n2Ml;dPa1 z^EijJuum>1ItKj2i-&FRT(yrEX_-G3ePhnp`s(7D(DB%gd$Y6ABp(E6D-LH!nmxx3 z*M+cJmeaM(BloN+C9FCp@x*|zJk>NWp*ODb5b>^L?2mgP^<>(cpN2B1hn}%iUM8(} zcd;GUT-3V3imG1f(fwy^8hC_!03Nqp3V!+lCS8TzCS4TL<>Raw?v(E>Ljcf;6G0|F zY$X2+vZ|yNOGxiVaShNJfIm#ciH<%qEpnn=b-`OF=wH8B8}L^zZ=MV!MAScW7^ZY8 z8NAY)BUR#HOCxllOy!`v5DbxsSNzTXHGvZZU=$iZuA8$|e`hxP)MTx_IxgUX68TFX zIWNb1Td6+!Yx#4d1ba2k{SKAuTa~knIyNW28=7-e;=6)N_BEd4@foUX#ydUyueX(! zy6S1LOvN*aawgH;?BB3xnu!TAN>PtNfo+P>a8MCe@Ckrw=i70yO# z0E0GbNXulmc{^7c5_%0B81fQwGR}73<8C=`krtz-`0`mJZ40mGwIwirhIaobg&IPi z6%@z_)+TTGM2t5Rl`C`lGjH=MN{=NTo8A9p_dWBMSbmYRd(Vxh#;wMK4&|3fn?=D1 zW7v&SKV=J)4>qfNCnLcSU4oL5Q;7`z&Gw#aL&D=-R?8gS@ZUp0F)xI`L`UrUW}xL< za_ST@JY0^VUpE}Z&h}MM7hCHuQ2&OQP+#=UQmM>WIPGZaeo*M!i6K;{jUGGEQ(FQ> z4!-T_0`V=yy(Ri+4(xUYR{qg0tv05M{Pu|BYgF%;IN!719Rf;y@d=>!=m*~5Fx1_> z2*f?GeNr%yfqJu}%PRX^f&KY?MMw_ovk>g4`&ztDC>yTooF(EjbdQ<(;tvSD+XY)m zZF(*}6LKa@yfs2FemHK#OV`T#os=V4fOzkAd_VAoVO?J#9HWQ>8CBFlmS+7jUVwP0 zS0?P=S8STa#G!5=sNqmL4MDb;udF9#l0pSzr8FS%op8ID5t3u;nEB6#6F1@8f&iYR zo!;r(_rF^EFMLm3kQLA$*0`6P$bXv$l=mqO=q~K&b=(c}zc33Moc8qc?Sqy?2(dx> zmsh)Ax%5#sJUu(0@BMZ_a*xvTac@lB;9CiLcFI# zXUxWn7~dw8=cPPRS|&f~#?N2DEVZF7oqk^&%M_^Zy(npjHD&DbI{{g<9watqGX^nv zS6&p_*PDwI@08>0_dHkKOY`~oE?Omd`Q6_GroJ)~U|Yrps6743x=80odP-`Q{+&dA z?m72s$zcnj6!3`rC9EfEtcn`y887EgHuo9`?Lk#9XyNi{bmD{Ev!Ej_hZh{T(hArB z!BD4*czbPE&imEc0+($ih-*qy$yqMEVfcSrfn6PZ7`R=&)6 z-pXz{MEU`#kp7GfSQXcXJ3N=kQUM6_kzZ%dU8hZ`ZMeh6?sjhFh&E=OxWeomnV$yg z6lUHVco2Os!lsk&a`EnW)up*d3=>FBW^pM|s(`ra)l2)NH*lb8Rp-6AYT9mH<*I)p z5p?56te({0`?hOnQ~Jf?52@DBB;It^vsKJLqN z@ifls^!{0RyB2*E=eeTD$cHN8y3~z!hP94^EC%+zd~~b*lFZfcxz2;D^cVEZ=i?s@ z9aI=LA)yYv10P-Y#KzV>@O!_fF5(N(TBK;QzLJQWPdMe+FBW+_N87t8?J}C!W?pmp zc?Gy7jK+=b8u+nWttPlQ@|)dtcx#-(lB!qmpp$BAik#>@> zz>Y>K*@*xZu?w6|k;exzKMC^a7-G=-0EA?+OJ`|}D3xP?+%bS84y!I)*%uM`%7(6; z@Tq1`*bQ8g*~_#`J+SCzm{|yTJ3DK|GqUkI0>yO0sXIgd<}#hcSblO?)smgWSI?g; z9qP;{PUe1nUY9?6K?t|O+=KBLY~YKkCZSU$h~|Y9tz2~d!rWKo#X5~iU$qrxd2x#} zD+)kn1$fX)C1Mmd7{w+bvzPfac@c!z} zs>L60m`u0I>7|&zl|Q)<2Ta2^^t_W)s70QFUA&}cbtT2BRKqTXo2;!AE~ zHlI#Ocejlsn^0OR9ra@&!Q>O>NnQ7ki~l(6;%lN*{2q&@UArAEcW0z}tA`e7J_>Wk zYOBYk{TrXu@fh5Ce)i~b8O+G}CM|wsBf-y&CBi3l<$NGU9mZ2{o2P02VAX+O*H$aV z)S4cP20q;%#C7U@Khts%BSCy+ZL*3rHJ;*QgRoB+*pX+wIz!`u=$SQUIrpMFfq;eR zht3^m1o3HwT^-T)ZP>^@0yrC_E10OKm`JIQvu3o)-CSmi#A9MbQ&ObMi;lrxJd(zt#ZKf|Tn|NyscrLCqS^;0)l*ugKwFW! zPtxeU3fg0Uy>5g=2Yb_Lx0!fef@?p5Tv^Hu;LBor7a_bZFJHA}(v=ha#ZOJC4D5^O z91kTdCV0N5N_gS}gdtY(@K)6$J0smjC)9n*`YyG;>A${S8di|b8mt86Yb5h#*)6Ws z=>5Cx<=FPM55#)+O~#>d*XSz<>6`Da`CXQGLoj?NpUvOXLc>^~(_|Eiw4c=i}t?VdJX{a?n-R-N@u0H6nh^40XCFr_v{V3kG4H3y?> zA|A@1|IsXVUh~*|kzjx9`ITnSlFv7OBV3Z1@p7M?{u1gkitB8ZgG~Co2hl2#6j#J| zX*NYk{$`Io>4#!a-q)OzV7(2AqQd{P1}C|qR&;OVlY2wJLXaUUE#1F?R3ez$YZ<3ayG3V38V%~gyFo(+|TzaQN z%m%;(!IG+H?@xV{ZsA+N)*Tv8=pa82I|baPT-X~>DC^yJ$xTk=GgIY%SzJL&$!p3l_l}%9^3CT71{5YI^d4bIJ%E`u~d5QtR!9-UTYtImP zPQckNRC9*rPPzw4_j|A*{P&ff7zdRpd9%EJL!omMp(=6zf25svINS02?y1%)qG(Yg z+M2D}BW9EqHLKd9_9(T*o(ZK!?V`3=HQL(L-Xm4B_Fl0^i5LmN`Sknyo$LH_&UKyZ zoPYeq6%n8J`+4r?b>EL_Pp0%B(h=4BG;UBEY7?`jf^0rB+`FJqxc@#Ojri-+UlV~K z{1Z8@i-P~%{}7#TAJ1{3+j9By#>Dan#VFt3f$S^88(1{cm~O71u#6M1Gd$EM*Tp1J zd__Ww>k3$0&rRw5D0;tcMD#bvCyz7vJr<0mb9FM3bJ~rWo^n^T7UDbP{6j?PO_}kW z3SA9C07|BR2DmAhi4d=JIMHjkl148%A(y9mS zeiuX=Q7N`CrMuo!K<}#PT&haNNx>{T)LU20j46-@|0P$gQ4g zsS&2S5qcSkVLO(O4u@U^v2=RZS!>p<$JgB^K#!csQL;Mjav^rHS6a7G6P`@+Kgy<- zdnVB{K${TwNN=T}M-qv|NrpbUvW8d8U>d2|e|#Z;P5@wxhn!d{Y`Fde)Y>|M7t&%cE#Eq80aY4kZGY zQBfB{nAn9B2XxBl0R8Vyj zi<@rwcg@cw;$+0NkQ_RL_u%FQTYq}mCZ;<9(n-R5OWMS=&|#lwxVz|k{x5~@fEF1! z?m9!sZ_=$hu*EA6B+d*wUFKK!M>H^}=liix;Q6H=6laA!g|A@>yhslSr^k!^mwNiK z&4erpK`O(lZ@_|TdVgN#qQd?>6}dI$W$m@nE5FvIq;%*J!G1tFm40cjMl=xmIRjmgZDPVbT4Y|Y?TH4|gf{=fz$0o&$79gLimEJSVELmi*X>EAthPXy< zIu;aoSToc#7?^o2Y)^Lj2coc(#Cf&n-5yo`p7>C$EXv^$rs;Ch^u_o0jLV?UFGOUO z6W2~%md7K5A=n-(_?PJU+bHQ)`SOmYD%SarP#LYQv)SdSnB#9JzJ_H$9iD}`RrI3a zlbGfFx1!NOw-wd&5!`Se6x;lFw?JTv*xI~lU%{mlyz@?Bl*Z>ypg1qY5nMx!=fN<9+WX_b6~XDIPW6x8mz;y8|5Kj4TiC zrD((0?CFoE2G{glrw$oRz+Q-71C0qp#=k zItN7%PDuH_!Ml|59FH9pmyAmpQ%z|A8cr;>au^5T2fyvkkn0ZXpvkG=={m{g5_<4c}!H? zk5m8_lyNIOvj#?v0;=h(MODpjGN#2=|BYbkexwrP-BMs+g#7^aTWsOrnC>;cadqk0 zNsN+KCNfbSbr`&xv-rYv^=JfgVfO@>@|jh-^eLsMgFpOylL6ORztq*1;#yD?IcKFK z`E@HD3!xEm9)7fxLw1}2VWKaT9v;&_-x{(EArInrE*Z%m}2P1&Y*QfKeT6Y z9qha396cMXvUPUm;*yMe?lA^%BoXxMcU+w);RgkY!f<>+QVvcdI?SgAJ2gSfVQq?g z|NOm>u$UE3PwkY(+7-!FZbD&KC-r^0F)cU}qoG*(u?a+BjeXQ4CB#ZPtRiVJ@6egU z9I)5p=O(*)dH@jK!p_U@01~RRQ(9ZHr^~c%0h8he`$&T!2|y+Fa2_(cNh}RNuT6!? zX>>K;b*gzW1IQ-#(B)g@# zj$Atp6oY;p2pNE_a?1a{Ul&tB#NK>ceJ>H{k}+6K4ayKoscuxbAgjaV+`EtBxt6W`R~<7hvMxLhQ5X^$8QFt5^bp4I zklrLhhg;l>y7dX4c?1_ zI1+TS%cwY4_3N}V|4DUMRE<@?PmKksuMc=5J`9(w{pBj=l%B&BAq9;&6?K5Y&z7an zztIW&&n$pC)6?d@6L|dz6MXnym7ZZ$J4u_ZQXG%}-+>v-oT>91A}KN(8bH<6g26YUUm59T`<-r%zz zmK9D0@K;(%+gzD@GT_V4tVHr_NqzYTf~{A*>sL6GpcA{Xg?wMXVAjtrCb#^qch765 zcR9bvT#z7*`UE6eal+r79E3@lWX3S}XFLgS2(0KySg+*jgaVvxjSSCwR_ZU&q!%&Hjb_PmQmp}s>F?*9 zm0jA;$zJx>0QI-4NDm0Ny_~z`(KJmzS>7K-=A7p1u&)`I#$o(Lwfu#R;kPqanc1$X zteeR&gZHMIBmY$Fn-m>=9MC1Tb`Fc%Gd}7^#n{NkdKl4o8jN+y_cX8oZ2!wbLPd!~mgwiwtWdzB8WpS!{*->Lp)ivhrp2ziP;So{yrqJ|_ zYKrmWOqQ^mI@-t+wpJ+ASjeWdYv3bh_eVF*)$QADXHIC@n(_s%c;Rsa`A16kG2Iyf z6sDx<1MbDb&5^7AtCz&rl&G$k{u%nl!-7c8f=XIG;g&pJ+KU}x&k>4GbVG8WVTP`tU+PJseDl1s`A8uHiXCk>BuHK{ud#Sw4E{gS~D_1tgvt#@*>QXs6T-z z$MSxNN%26rGLWld@o}@|`XxZY_4Lo0s&TnYTuW^N0X}Rlc1y$LvG~INN9J zkzdfw8j$y69YexU5Vu79N{TTG6_FJSW^FzYCwB{A?1u;#LqJcQgU%&R3$%TI5mXVtVd}5T}HkB1vC3Sjx<2*I0hPTGN zp))-g-|qu#M4?JSyL=Z456ei;Tr0*-hcM?tASL6xn=2 z?_L^0v-EH71Y?5at@tK2GrfORiS$;SpRK9Akq35HAeKv)NjKL9xOx#JVE?3dlxP)JZ!Te>k4O@OU|F#Fzv z;QLZtf)ia9kmO(f5jD79du&9Usv6{%7vzpCjWsKCbihcOwLc$szeu8#Qs5vCMaI31 zdg0IA-o3ert8H5bNxW*5|3DL)!@DqE97%cWw6ilPqvEDccMAV$w=j*$MvAUlQ~=~M z?y7nhDBMuVGxy-Xj*4Mt)1QGJ%`7+AEABAbK$YF!OHxwK-S94|K-hW_Gm9C$G1=w6 zU^fk2eKVC|*WUTX4vS28_%$>|*M2Pem_1Jna`_Zz)T(&2+^L3)#-4tM!qvGZq z(yF`U_I0ed$8iT3gXMBY6P&YjR7ohm&@X4+?2&i(#fD3J{J{vog9+re4a7nP>Z*|7 z54fl2+oogmr8LQWLF$?6P3g9B8n3Zc4_SrNsy07R@7x*69^S=8c#1>wwbw9o+%hH}oAkEfm!`&3A_nt966n$9Kf#y5d4Eyvs03|K~yqrz; z;}NSoDMMqVu$w!*=(lg(dqx(SUF(ELTPX($@KREn7wd7W;bL*XI^D5$dB7s_J}yBR z-o|)YQMRmYT6~{frTfe>m1JY;aj%3aaU6N7(IzUgcWkm?;-zGB#=|Au|cuhs|K8ja}PoL{e*^&@IEZ*^X}V5DTbMRkNLrBML6)OSXxL*x%A54jNY=A1KSqHmR!71L9*_@$zLY-Di;@7V{*U`q)gF+Cv=(Zu>ZR z2MD#$#n7MD#;jy47Q^OWlvRv+aod!+r_1p_Yh2xLi~fC((IgA6S*~xbs$N?kRU>@w zTVxu3a#@IijlC}sJNck|PnIkq^B5-kfYZJ-4GjC6f|z#yJIT-VsK{4>b^i}+I3*oP z60G~M3txY6ib0Wh+QEC84drGoiOr7hxsU-`ezqEE%)I}%j6|6LEh-%GQbz8co5%m8 zr|**hCl2A&-`>6dN}&=GCpm0k@|o1p{R+q~oxzgA(QKrw>Owc85b%_&t2jq4S>i zzz#_ZvSF+rTxS@3WhpFs7i}r!!r0VYODy3*1d+{r9(T*;LXWZzeSApIXvz1(k_GjO z33{t-?zd$Aw*xx9C1C4Su)t0?<8(o)SJV|Rbk{+~;F7hrKcwLxZ%*xzso`ceSoelR zpyfrhF@nc`b}Ec0{roM7Rp?VTis0*iKsRz}ni)3n`+$e+NkB(1W(YQ zHz5G_cKLbb$V<07IEf|jz{w#@OrHLH{NN%r9WkD&h9BK?5j3h5KEEtC|UNd zWP)_**ibiUa%C9jwj`3%sh4V_UjB^KX`9HUWN2G-!JTb}zrtfnMzmDBYsHwufub6m zj}F&cTHz+-JDbJIZmaJZ^xN2rc=m44wwejy-+-L3(1Rh~xJ9w#v;*3BDKH6C31cGw z$+A$;QdDN!0A1{z?r%&AMtAPBf$rG8eLZ>ioV%_38JjZa7Up(Vad*~pQE_%b%n}2aW`E?HAL@}m2 zGBV?G=0Tb2mT)d~gT04ir~aeu3>VZKrV9EVvltCl2GSG0x=?dOi{GD>ChE%G97!g- zyoiqI?5=;4Atk! zM82w2;9}1m#FQ`Dt@1pDO`~333MC&rY=3yL8COs*Z-p0xOpwFoSeh&sr-2Hx#}8XI}&}b(5X{<^-gZza}z=T!{CR{j1q_QG(LI-+kTA><2EH89`3qP zC^+vK>71^WS%tPxXWg1kezm%MJtHg^1d>FQZcs%rS&c z>rave<^m}yMd58B_MO7S{pJN7IpTlgZJL>He7ldxVZ#9=!-|`oOfT;szt% z&{u-=ydfbWEZ#tQM<tR`19uIN@;;Xe~9RNljGkcJC(|`Gf z!I5?_tA!qyz*eD=h_*)#m2-w#J*Pk^1-`Lk$EN>gx|b2^_p|o8Vd$-_uOyH9t=< zwJrrTL`;NsnwEmjXnOHECL}b_jYmPzaTM)pD?DpqHT#h1Iu>ZtOlfO=Rm~x;7!`mK zd&yD*a6t1btj(gTcl$$<_rj``ezPu7N)4{Lv%uTlZVr9lNb1cRV-iVn7)JVH+Lp?% zJeF48QTEC{H%ILKVN%Dl415FkGNao2T;NIg=l5j!VuU0-Q4TxWL{v1_Zi{d^UblDb zEE7hsfmg%E^7qUa(pr|uD-_M;bre+`_8|TRi-`x!AL~`+-T{FD%l3aIFOX_#B1;#y z$e_<)YL_X9f81e&j|_V*LIuvBWS)Hn_UZe9;D(X*!yIS2r${txxI2qo>aKx9cV*=J zwex8hGV_J3<9-;b6dB&#(DKkdKS&Kwzg)atJ6U0JH(_cxMR&#XhwfN%+EpAI5FtQQ zRA|Jag$N}W0*%wdM91c9FDL^w*$r(>0rM^}j|R;DrZ=HSmQDs~G7dCbTSI&@WkE8@D2F{%kP3))*Zt9$+rh41c0gV(&TD& z;^#KyK3HFmd9x>+#>QP+Q(1LFmZ|9;;jL$LFmk3x3nGD{U(sGunJu%PD;7S|#fO#^ zD)uiKMrc701Kl4kBH0%6eK|du%=mxWw=H*eyz5DP>&@M31oU!f8!@XBeO@+GNhqIB zvjiE*1py^#&~o;UFwFRHTU30sR7 zZ>fwlgH9Pmn>)9}hm0NI2c}nJc6EVyV4p2tEZODGsng{GG@z+|>=ijiGr>$Ic{hG% z+T`xg-s35O1;h8PTGI=}*|YwR3U&8%!&Yn780uvzD1)FJB3}sr^4rw?T&LJ8J-=YS;xK;B4}^mV-u@I*Al$BsMp>9fA3`(nz^R^!MU;4C-LhdMYF@cmj%g zPMJ5^Xv(#AO?;lWJEq>Wps6zb^O5;nLzCexns3rq5M&Uc+lo3wy;-a+FuGNXC(=zE zlHm61iUPJ)T(Z&cIAHz2`D#y1gPc?3QW&(}CcHj{vxHnQ|8t8eM1Y^s5Q#`sF*J%% z_xjo#htxSO)RqqoWVOuh2$!+iK$DM!hu3VsfXPG+h_#T?$l^dTHC;_PfEOt2990#c zyz6%(+a;@_z1Bl{VB29HFrckaA?tka|``JTB^vP$@j3QV`&Fd^tZ0;nZ< z(m2K`ZY-hP=jHEM-KBcXO2=ca`6!5O-t^47jx7WzA1-`>XD(om z!0IboV!elLEkle}uH|K>1rgf(7YUHso!HDsEw;tk8Ru=H?1Z8FiQ*t9TwzDO3vi^c zzm(btqtJp+nYgvInp6o%;cGox~#Sno4A>?OUDt8E8|vq$F%E zROH?@t(w#|>Dul3`oHpEh;3<467Y#eG`!yefQDStn+vl>l)!Re6}vmP?t+^{1qPkL zP_t;9C-yrzneA7?)}hO-H}k(tGoA~y?X7=F|=RwmuB9+bMI`_$IOIe$C)z2 zzxqDQIfDXu-k~IiP^J=O?{$md-#BN4Q9^jxomp0-FCSGp_2G#PZKit z?R^{sVDGjW^`cgbn6@@eoH^L-{C+@{`czM=EMltkmbQO^NA6SCmsM5Uq+cxBdeS5u zz8OX}oaqF5g{dmVYeK@=mTHg~2B#o!1Sc|yzwV(rK7nbiJ$q<>238s? zBx$AMkE+rLZn(kcsVgedMv#lDkM;Mo< zi$;^PF#A%;Q=Y)T6!%0pzLd_qzAkhOeErhBRd`&iXA?$zMSfaZ5is4p?a-`fgs?0W zBO822W4r6oim}{#70!~pr{TNP3D>Au>@|EXZ1!P*ApUIua0a{U!yEk6V~ZPD27t#Z zvyYXm#_mBx%0P(Ma}gnUjF(fl8a-s4Zo@QV@o8O-cg5=>OvR_2qDNvwh^J|3tA%WrxD7np!~V^l;h2}h6)^Nr6o z_hnPfl$BXv1lIkmUgc~GBc5C9|6ak{N}t@Mv}lJ<^(-Fkq;xyXu5;XrmIC#>NKGPJ z>v%22QynYB5j4RWE&Q>ql?m~2A~ZPs-qqM7T}))YMucP?4WM&x8tRJGavchz#BZf+ zCjN?&a$y>UkpVWT@jvl9K*q*Xc>T}keLqBoZZieP{GZsfkgV=)eUz>Om&CeYdQ;E4 zH7cRuj~CBv-Y{!<@ipGyO9+bb{jCM_^Z#?9Y$<%Dn z|6az0^=4fQdv~T^*~(9&9mObQ5@5Se)aJjX*cnF*A$nHJNmJ+w{t#GK2Be``ZxA0M8 zBAK$sK8-s67E2~$Odrs`7>P`G#>Jab?|TjVl%6lEwe;YsOtAo!f!O%IMr*bq7QMKC zzB#@gdj+`5vrIHa7v}PormseK2G+rd&EE$)E8Re-96P|fAg>i_K2n$03=?dg)gRjU zCh193_cxQC#1_l_3^6MBv3D<7|9QjtVS=)>=3dKX+4))KoZRJ>3esuee7P{fq)zrH zOBNHV3d@L`vT5o)=Cc#*t=jUs>`a!31qK8Q3?~J5(JyI~=8@MLp2QBc!Cu}T@$E@Y zdjI~(^aQb)y$Lb-pKV=RkDr4k|I!ow^MJx1!k0Qfch4Ah60qsz$GsE1@#E9dut(G} zN^M#u6q~n$PmdfjXi=Ipl8yD0%5azX7K*a>8zERd#M~uN50}raeoYKjGX$^;rl)-Z z*Tx)EZx(9GwA9Rt3`zytO#G9$_k%52`yS7@G0x;ICYBq1OLqUcpXPG*tk-BQqqxvW z)F~jeRhLnwrDT)_t@d1w>-hgC(Y0B&*P*ytJpH9rIRpa=>S}G`59(Y`30SFje(&bS zhi{Q+TKx{Z#SI{L2CnNL&S(M+5B!$ z9;dRD$;d~u6EV4~UGL?l0Ql>UlKIbwn8La>LSXuhNO44*`hg{XvR(T!56$hb^>Klg zxM3ro3p$7czm97bL`4nP=DU!5DaD?`iwQQW3Nz}vUHbMeDC_6Z)F!bR|5J^k9C?6_ zF^d89h7<_KEM|aRe^y%&%p_yM6 zst1SAq1!zL_9HE2&(dOzZ5m5R0xx#UqUow5RHxUC+DX&TpWli#%TY)FZh zyQF1t?+iwptt_!bd?F|mw-F(@GA<%2N|Xn@z1oF)Udqro4448)L)X$~dLS)NPm0Hy zZc7`9@3un#kl51^^W{UpreT|hV^qI0rIExNHf1oCW6<0*yQcT&0Ny<)j&pAi8kr-{ z6O#&TL=PoV3W_~+DVNVyE~d~k4W3PaDrY6p+LL*tBWJjsb)-#9z~m#G1nM8HP6WRt zrcU|Zr;UgI;P6sqgKYgg+89{MV0^dQnhrirq+gn!qJDK( zhKQrVoS40kjwjMGmvFS9$-V7Li=4 z*{#AM_1|%;6TN)@OB62)$07%Zhk?dr3_`D=&D9& zg9Y3j+xQhRC&X7DrOI%e_`OH?)DpYV_4lDD%j%7nJE;LEl0P+Aw@G({9S>@DqO(_` z%N$kW#cUg$!QwLTMbVXI=b6n(!Q-U^zy2#J7s7&!n)lfD4^TbZ%*KBE8Wze$V~X0{ zfcS^Sj11n!{<9Y1Tn&f^udzrWqyYK?yTeW3SPaEeqaQ~Irs|0tKT`wvAg5Pmap||Y z-0j;Ezz5v;=W_4-`wS&P+kBIOhVomCT!eHIy$Q~uAUS=*(l+iZgUeLQE)M(lUiJVR z<&mp27u;|C;r)6}?CgvNYbY*n&-+uaBd%Rdo$#?}W@nEkj^_58zVGH&@JW=A1XmhC zT!B>j=_i}6^6T+57XyIx>B|W2Ao3)BqFo!s9Gi|w@a}uSydpQ6C*6ztSVwwp8%z=3 ze_;r9!t?lTO-&39Ngs4cU1aKhZq32Tuuru{vBRj(zpIq+31ZT^lq)} z`v*M99pmo0AkCI*fvXA>6r9hil7Dj39v$-k<%GzJoG87%ptuwd47NEmOk2DTvG^v* z7gfgfm(yX8KVG`MW$N{4@1@ClGT}S)NEZ{ zR!@I55@?$XLglYZ-sv`{vJTsw4E7QY7qHBBKRmI5qBO?9S23pH% zs|ve8GrtKc17da~a+)uM@3)eD+zDR@NTSIL4MupjQw5N_;jqIg-Yt#w1qHS!8xcaU zGI#W$FLQ_S6QN#ZRw2b2=j6F!OR%Y5$e)Q_+55Pvc{Wf4sn#{kv#CD@OJo<)tj&K^ zD9fN-N@3FnW-SpY;Ce|@5LU7XufF{{jR8htbDfQm+ioRnP$W?OSBmJ|tC17>;wnNh zw0PqQe4a#G_i-$O(WvYekJO5UAy^kJtH5RY)p~akydT}9@t_CFv?EUWjmt$@RIAm7 zF1iN&$N3Y<6~)u(PEo1>z5`23hW=9H@de_*s6Ora?$H@+|*fBe7vhnCvAJKPI&xj z%{)n9NTGTt(>MR-Qh>XgmNp7S{JY-3J&YOs1~C}q>>yh5hLhV*vC9CL{vi_(VlTt_ z!-iXbnJFNWXif`Ykg?7iNXz#!CV-WFVo>|AdhqQiS)eQ@e*Guu+?mzR(SI6r94BUq z=1DGh9116x%4=+SUP2*=w*ZP9Dc~k9yHN;s>mA7`d5n6@5_Y_ev;7a=PJ?V`&7Z4a z2}P479y1iKqabb$SRvzPUtLn5i+;Y_$ARjp=L3^m$exmh+v=AHw>sH>aAYI0lGqZw zvL;h_ebuW}>&n;_FIs;=aCVQmslqLeplR<~3lJpRatP3U+W|OfK7xdwsB(VIA#`?7B0{z;D@<2diRdE?kCW=eRubU&Xs)^ zqbgZ_dsz6yuUKQL+qkl2Rs-nGWqs~1^*1UVJn5sGv+w!eQ=1F-J0dQ>@-++H;xHid z+hdC9`}=8hb@&)4J=F+t>0J3WL(*Kk$^Xc!MoyEJ@Do0){FrVj8`9@HDRr~WrBYKI z4`7KfSV#h!sHDbf$*s*}OzN=a4;^dql!2s{yh~AuXscX)`z4g@BVKI_Wk+fPm1e8; zqHv+*^EfjV1=Q5imSb=p40}Gw(*ESqVolP4ksMv}sqf?39qt_L!u5K;{#*F`o zUJ|0pm}J9Fk-{nGW~x0%djb6iCgSqe*3x3<=r?d>M?(TCD_7k#>yAA}d^rm|;S+yb zqo<1lYFflzI`3-}snVtM;*r-LhjK_SXs^{^Nu1rl=b|aC@AfwE3FmtBSwlA+cM$)6 zd@$-4{z-b1&WQYMy}PWCP}fcTy~$nR?wf<`iE+-#5UuWyDO65%+)$e@b~zIUdi?VE zbwar#ql{&JNcD-14Ul=r{%H}Q`kc@D@Z*7_lU!DVpxOF-!TKz~P(`vD{JClMkP|1I zSD&!sT#M%+a(Ic;dl$~la6KDNFeoKIT^zD>;_zQ{yyEwE$1GM zZL-Vpko00+MEs%?JZYslWwWawzzipBH}oc=)np?@T3Hf*EODiF`8ssg=&773LVeky zp~Cr^)3GB~-$>P!VGJ|$r5Gk7v5m)}T+V*O@y9U&vw&=W6n2zi0B2U8%M4Vv^)MEm!NnQpTV#cvrK>3od>BnP$^L=`lE6`e zE3W9ARh!Qc6BF5*10`s3oF_J4Ze5!~38}k0M{u%3wz!($k874QP%n(a7gatR_T5HR zNgE<>g41{ce%vNA{g+>O=wp`wALBVc?K>mv`!vaXA!}X`m5jkFW{7x&0Z!a=s=(xX zPG@q3_6B}GO)6bp8b!s%{VCXn3#)_%Grbgu}0d(wa2k2_aw2*t!!F!}RjyEvTCl2A#^*TZw3WjH&g^ zV6l~-9(1mfcR<>?>>N=64eMQLN@Lt2qy!SwWhg>GC^;2cz z6CoG%Wnly9^M0C{SZ$7jJoDxiZ+$BzLLk3OB6i{dY@Kv3@|EPUa*nq6@)7f8r}Jg7 z$#ye=leFI2Xl}5nF;a*s|iyz$kc?6WrB?PFynB8n9)vI-?-UB%c zzG=PJtUz_-uTCFj3fmQSpf9HpBo#o|g16wxN-So@`nS-AP~!l1Ub}$2SfbQR>a6p} zF*|ADXYI!o))~SE3Z6$EN$(R0!({(#R7r38Y!& zfrFYgX~o^6do84U>#fR07h;3CY3IEXy77*xvxsPjmayUUw1ADT-a%d;d?6Y`41e(p zyzK-0VWMo+ofa*MCIKZvt^Wq0@`X0VMJ6mGhUJmw1qDZ!@}1UKCx>;DD0mkWpz`Xo zR6#=@JcsQ8XX`1xebP1LeegJ!S~wA!zH)c??+m7#nOjhbWStS?e=+vK^w-#BZ&;ZG z;=~#LzEe~og?{pm4eQMN=;f|q&}ralz^v+rHR|BdwO7J2XvOboF+M zh?;oB0w|D7b=_glh^V)xhG`Ywcdi3*+%((hu=p_-fdg8a=xRG`Wa=G>@J>P+9Vm5f z$W%t&&G%QU(wvih-NV(V4J{J>j}{qD?ZH5}LR+IeogU$Zt4k^B01mA_ct~UbV!oyS z5L3&LRSBwL_~$aVd8ODB)|~FP-11RgeYw2aepej(lZMFHrOqr>>|WB(W~fW(GrBw7 zRFIa^mkZ%-nB`k16OW-k6=fUVivCv+NnOib9#-y9C8`bI5V?ym4mF)~;g%=#{9)-G z8Fg>L6|qslRrfz#zN0>wu@753`j?op#r zuiqpfy&RePwv`@U-fszaQbt=U8qsP)^Y{o^_5+sCaoT%7)R%+|yq9@=UlwjUrFt|V z{n!fVznJM)t5O-q?LHjPU23;>x#7XEqcQ;T2V)U5td_YUxX`nV*yYsByJPj9ZX*c_ z83@R|3?@)*w)vHaY$@mnRfCWk;);3qYPEVcI~V>PWHd*2EXu z-XmXeSn_O%27A^?!mUB)&RFG#E$NN!7bB3B`5B7r3X;H#A*E__D#J zx#Q+pn^9b=P0WMR-((LJl?R7tV+ce5Q;u(>fCe?Y%bk$^-8YKeRLZ)Y{IjQMR=2P- z80t7LGJ8f(1gB7NeR?_h;h6FBgVL^|%)6*!iKN7|YSS(*bv6*Do*HVVi7sq(z52}% zEM<4EM@aqrE;Q{Wq=!Me$LpTa7Oj&sQsi6-B0vTQo)~&=@9DZY8B+vKfahPto0!NGM9~}GQ!!6*Hhq0LTM)DgFiB7(B3In6dzhDxa?zEt@C~n82Nlv2J=HX`dkxy2lRpi zrn;bph0lOXt7hdWl1{z~2(a-Is2dQLv6+|*9Yx-|vTILuJE|QcebZ5#&cKu7o=*(e zL^z(>^1jiaLiJ4pE%cg!=2uO`0`uK?!oWvRv`h{Aj-`VDkaFd=fzsCk?JZhP&Vgm3 z$Is8@WEK4o*TUV{)V17U{t&*mOE&(VwJMz>DiisdOAJz0jx*9WR)B(L*yR4W2Z*3Y zzx;pOxb%PXQ#4zJ9a374D%|VueMAHc_-Mlqxg!!5$B#jCk|$;{0j61q#vfHDurJ?P zjCsID%?VZS4`=aG3Pw|v4N1_YLQEOrbTZ4q1ZOG_sU&Nw_~n=_=GVl0*?N`0!!o6- z)Qszj_#S@z?LbxAz4?KP+97utJnH2bp2ggxTnuM7UcS3^^&Zz6rM<)93C8@x%bo{1o3)JNnO#E5(KzrW-sLH#;F& zxY=ID=2pEH>_ynsi3<9${6U0G31)&Te_A9Y9in>Lxl^020!Z68Xo#Y2a)nI_b?YCm zu*m$l6J{xbST9__4ai~%ax$z(PvLFd?F2WYnN|9i=_kIgueY#)H+TeizDDe*@J0L6 zp?JHJ7JLrON=q0A06t)|HXyY6Q|xD|G%>law)`J$wA=@X&tpJcf^_sZXo!an8BJYo zfP8&DR4iC;c5H`k?>b?M@;+7Wr14c+d!aYasybj*H!IiuCnN9G+@?M>>ttbTtLmx3 zu^uF#M@UWC5HXKPR!O}6NZ}*gL5BzstL~KKi5!t5n+<2(8D$_l*&ZfNhp{+<+~N7$ z)b=h5@`qpyqlG1&#JQkEEbZsQ4-FX%inQKz)H5!S5c(zWbTxhDHst z9s6JQEkVv}JV}?>L~Y6kJq@dhMM?kG{3n=|T7=f=(sABuCQZ}6b$n{+{vn^#ztt%( z9+NoE3uAVqsBdQu{OVH*R9-q*PrN1n*H12#y=ot4i>nZ>@F@5I$jE?mdwGOepM~V> zv30<8`3aks{{3+t-?Dry_ld*1Heen0l0a8oXT_I+P{<9KsiC`s(A;@xq(;yO)X&a4 zQa1B3+H9PZXFo%daibAEVQw9c>~SQ6$UUPkF2Eeb#Qs02?|~T#xvQ`WNIBQRT&VP$ zQzgKv$Z^Lwu0ai|xNLdtC)yQalzXv&8qdA?Pl>%Q4XRjkCf^r$XjGO?O(v&gRLH(D zBwOn|HW8rkxn?C$=$74%Qf+%lm)+FD?ZC< zCQ)F_E4_b{mYLyd%USxqwfahV^7K9-JPsX1eyI3-TN<7n=c*@bG~X&u#Vg&#dq<|_ z+jaZfleVaOw}r%%bE)My#$7@qE|2as`iU%Br>r}8Q(ab?otgK5q$N?*-GF34iawy! zwEC2XC|ZY|m;WvBrmw>&=D2U}8m7Z^w;IMixv?u-X z13p{#1SO>v5lohtgCUUy#Z>HNIAnVMqxn8nfjP@+8?IWL-6G>xrT6{xhIV%k;R)}# zL$8}Y{X_TQNw|pvC%FSj{Dx0$02ADN-KrhD{Lup-NZXiO?@2n$Oa*}&HwskVt?FbPYY5&`;?g|D|g z(o>wv`h3~cksD~%sv#`q-ZpC$XeZ^z%l*o$puU3B#o5wE47&-D6+Ja_^5=lm*8DmN z`scJ)k?&xBQ0$RI%^lrpOc|#bmGRY+n-AlWoU&C&=)o0)OmpB`=I)PzMiIa~`@b@+F z6~-7K96lba4-F(>p#0zrFkjE-@x!puij4CA!QNYiwG~AFq6tp%8cuLppirc^w1t)m zR%iY;Lmf#i9Qqo$F5>Wa|!@t4Nhc+E=~}$Hfi_b8{;(J$b9VD*q_xe z(rcp)vk|tBJlqny7V&zU?g&fF+n9=`&x$7O1pv)?L_*SKaPvfGxWZXX(D}36`ZMBf z9*=l%e1?-M53(jk#jIIrTrSuXHd#aByZ6kHEDZhVv9QhkKhV&9t&DjeqGOKJj4Ole zH}LU<`N{)0!7o}C~-i({^igJs^?ZsWeS6B|9{mOW@JBNapOTWH;y?oWK*`PQFuV3Z4{ z2AEkZN-;SxGTg*LNOv0Q%>>ikP!a+o_z(c}qF>eOkZAGJfuF6pCWWs_~9%K1hr7%+{b?rS|6V zU&5$l(}4NK$zgPiri>DcCX#L5uvd2*UL6xcCdpn&1lkOWbJ)FKdQS}aOTk1Eef`m- zG=do{m$d)ij(7iUXmr~|E?dTKrZPjD)D%%3r0X0$&bi`~juE^b*KnoR+Vkqu_i%o& z#`_*-K_T0?M>$Pdu6}bYc(P=kPLZcT_hm@{`q%suw8o6VR4d(GCU>;1HOP(HSyoRL zd!AGARGreM?Ih~`@ZE?{0)@fIJ@ z)_TlEzv=XNzk3HlOp@^g$)aqSzvOhMrhdz}Z$I7B`}Z8=;!~NPf1x|IPWy)qadQ03 z+uO^6Fld6Q${S99N&EG<$16x-rb=^onW@{Oi2-(iV<7bttEchw{<3!*bYGqKLr*rp zH`Z>0<%n$MlX2PT9;beK-$;SWj`>$P65z)ZpX$n5=Hz&6d|09QjHujt_%&{${ZUF* z4!v^_`xncJH*x=UKzlf?R)#e@!rlgDLz~wIl#1Sd< z_OkZPULMdLB&M%3No5S*I0O?>waB~X+V0S@_$NNEkPF}+1xJ-?yT(q;@|7IP&6l_W zCMS;6o`2X8wP&fB8-^1za3aIY+fyB$vV`%$<$M?@5_2Q`_WNtZgzzSPr~ZvCy=@%Dx7qYE}p=v|YbbXwcrCIlty5QmwzRm}VsUBQzy zw$=b+J{B3rcOPgs)Vl-{DKo^`^GMI1uK)gMMVHYeZB{-79oU<}e;I3~t9WzFmJCVF zmxO&%dGiua{w1DrvhVTJdF{%+R40vu1)mB&0L+!kHh14i)F-)7QL*^rzdy+v6yn)o zXkd?z1iX#LOGtdn(gucLyO^Gd29556xc2Y7tjsv967%tS7{UkD?1?if;p$UUtd1R*CbSb2&I!Y+pZ96%@4T>_l1|P z$U2ypg#NiT8`7q;j`Beq_v(MQ{3jg8`My&xNy3)pwHm< zH!p>rj`ZSX2xPzQl6bS0InFa2o^R{m_ZA;0;MIZ zs=R`oFZw7Cdby)Y9r8LX3!X>XI6|(!)(d+phwUaCbgQul`+N>m!Eauzy_&J(GDHJ2I_S)(cr#O91|>_51fAx7RLsCACk;>NvY4;B;kGz%js zF5DR2-k8!T5UqUKk(-c~bttPgXgZa!9zADOl93tfJ9KjR!OfqRdw9zfMDfF4x>)4t zH?cY<8Jpj_XD?tZE6pUnPA#ZhZ+%Bu46jgqtZSjv)SKBdjV1L(+U{(^^Cub7VDzV} zA1Q6*7T$9LdVJY4Rf5tcjHHXbr_PmEgu?syvSS425;DV*;U|Y>JYYPi2>9sfzSC7M zKQ!3wln+o>uQ0ZF^Fsff-JIDmu)snxw|?ULD&8lDsoUBp<%9es6_n-8MAqf^0>_6h zs^|e#1y)gY$LMMwMx|yGbA{VTtchgxL!@KQ`Bwyuh-td;HRP7GB*#!jZr>DVtE;l~ z@!ZqN5)-3j9h$FWM*o94i0Kbp=e&I1rFXF`xxxTT_@N@1b^^ukFdySouj%h*Yr_Q) zmPpV}qSIR9rYEB6V0mF<#i(u_yOTAffu^+IEoojNaWqMH^|w;l|0VzKF9-Jg?qW;Y z#m}52pv@!z&F*yEvs$5DStbbz5enp*Vzfr+hCRQ!a(P4-~zjk`>5u-1TxTj zFgp*e;Vdh1m&(uI3Po>BS<1|RtSqqOzJ7YNjo;z+3%R;rGUn6R8PqpGTe_oBKOD|8 zRZ~ZISYTuL*qQ1u^~$2!`C`C}RfOR|1EL;g2U`%9b}GvQM@S4c0ONaQ+0%?iUI()) zEA0c{56B9>1CIQ>9p_a#=Kt8Yi>XUAP&wxtPjutw2gH^Qsmv?UuPQB`;1)VE*v=Rq zwpAyKG5&dbPir!z|1UEB%s#%!VuC7hKN~d>7BdVE>-`?W*%GZ(cN~yKrMGCJ2hixA%XH`DBi3%_@IEZ<;=iT2)TwgR5odiXOmg*I3rAr>*HcK899+ zCWSyDgt(LT{$&4 z)Ndd=?5xW1+pY6Uiky+&Z~negJ3?1nyzePiQT463o*@-&w&!~K0G;#|Y>r-jK5W`59B)gbm zU5mjn*leVnRW6E?jy}S93-&vby7?Z)=qJ1@@J@zD=U!Fs_TGHI zXPdl>Tc&KTM>>$P%jr(Lf7S|tkISaO-*frxseHj?d&DBU^aN6*?!zLpRqsw zPA+lx>YH09o4LyYzYOggysk{J^B(FlXfH*T@9){FW6qstOb-f4^fB0@p$$$mNABog zXdFP$QgQ_P(5l+A%8e6{+8b~-rZ>FzwCyGad~-`9!O*|lHwSXi1gF_>{e?{wBwsL2 z*za0tV>I$0FW@g`R*O@L$jb<|&Cu!^KHb-L_5e{5%#jhxCiP?j8HQ{X=O`@_xNbqW zisK<7dw8pPOP7Xz^v8K`1v8$G0?j3sf_0}1tHyVe(Z=X!U;3gjI*ko!>=_X+ZV=9t z)BtLqlgFOX-A6+Mb>5@~4_K`p?uaFw;*qfN_ms+%+tk+1t`;kpTk`QU;w20cl9+tu znx*MoXJA>#+*~d{IJ<1Y*!TN=7hp2(Ixl-oI8}Eisue{(xr|X7x9+q3V_Rt+UB zq+k0en+C(;d_+}r{0Mw8Au1iKF}K;z%m*-#)pi&ys403TY2Qgf?>$p#uluucM{FUf zM{9C3pOAHT~wbs8oho}DWPMnrm_Eks&tI0KxN_*Lv zc?LLRS}K~y&O3DgEiR~M`V2}ij93fF8t=Wn_4c)TWw~E;U7H7m6wdJOxhN#x!JcTZ zUrsruU%zhDMU!7{N619x|BthEnX$W)FK6uC#yjMy+b6czTYoR@*!{$+Pbh($`x3^I zBO)VauH~_Zx5AQsg0Y|HdHoinPv8hV!;Eq!PMyP7?BvoH;vf8>>)Ns_{vk8fH=#wC zR=23E;T)M$gee*S#C2%8wd!* z+LU`sx*isS09PfzJs70njjcQhoSU9i8gLH;185i@5H6NtgRyC9M>x3+s+)Zgi__nV zKAswy{6s-98x?*1kt@v~yD{;?zm7DmEL#t43?*_`EvZ{|PTPDH0x^y%a>pyJ(J-2p79td}z;F(Ve+s72XNCOmB_A-ggFealEd@MKD>F-5z z% zkrWi&{x_hn6pjUXEpvuLKTs)UDKil&QrQ*15baMTB#0>j#UU@`c((%4H!4vsESTGK z#@tPcS4a4WIxcwx`Jr<(HGl$zv>scE%aftaAPp(n;QE*J~4tXjuZjbeeBl6IyM@ zuDsTlf*fJT`AkWktGrSC2kEGFr(udC;56ZSUR?gwq7KT^x}=h_Ap+P;{j&luiT@pehHK_}ezzwySjo%WZ}gDy3T zBlWNL-?ffOX#I_Od?n_ye|bsqD`9}}`o(#H121&e$5S3DT1lN*#=-fX6MXn zw&sqS{e+rHhpknDe|}`Ij_F=zb%{P1 zCtgANVe8o+BP6?;8?I4GW}@e3HX&(I<37t^By!M(w=&CX zT(tDMW&mwV>U|m9Oo1JRDV0wZWeoN4Z)G=+h29Yml0Og+3ziZSMz8xm=+UGaa9W0X z(MYF8HB~p2Y)j@X&C7wUdAHOLFq{W}*x+;56{j9HPsoKG`fD!VQ8oHD4_`9}7pqu=<*$f$>Tf8uG@o9WtEld^#6xM5zjNBAX(^2lF3T z7b9Yb$M7x|2FFDenwwHh&#pd#iq*ovaZd9O3A=gIZ!&I4MdimNhkXQw`(jO>XBd7_ zpd)k+a)0~l(gbw%JjjdL-dB;;YRbOO=`|q2EgGbDxYg}K`*q2@E%3dO{%wj9e?i@B z@C#X(*LXz}pnn*%hiz?+v?J7#iR21p2w}EKdTQD$6(cndFubGA8U~eOT%F;+J3>(R z*^5bE?o&?3gPNF<wnD+!z1&XUFo z-tIDdjKUl1TD-}ws!1~YKD@}3j@?_gRp;A2BgbBZJYx-ZSnHlskKoVTWUj6{

#sSnqglToJtojUaZg5c}XLdp7JFLoeP_L!$jo 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 literal 0 HcmV?d00001 diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 0000000..e97c688 --- /dev/null +++ b/Sources/AppDelegate.swift @@ -0,0 +1,262 @@ +import AppKit +import SwiftUI + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var settingsWindow: NSWindow? + + func applicationWillFinishLaunching(_ notification: Notification) { + NSApp.mainMenu = buildMainMenu() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { + true + } + + // Hold termination until in-flight scp uploads finish (bounded), so a + // Cmd-Q-then-save sync isn't abandoned or its failure swallowed. + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard SyncEngine.inFlightUploads > 0 else { return .terminateNow } + SyncEngine.onIdle = { + SyncEngine.onIdle = nil + NSApp.reply(toApplicationShouldTerminate: true) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { + if SyncEngine.onIdle != nil { + SyncEngine.onIdle = nil + NSApp.reply(toApplicationShouldTerminate: true) + } + } + return .terminateLater + } + + @objc func showSettings(_ sender: Any?) { + if settingsWindow == nil { + let host = NSHostingController(rootView: SettingsView()) + let window = NSWindow(contentViewController: host) + window.title = "TextPlus Settings" + window.styleMask.remove([.resizable, .miniaturizable]) + window.isReleasedWhenClosed = false + window.center() + settingsWindow = window + } + settingsWindow?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + // MARK: - Menu + + private func buildMainMenu() -> NSMenu { + let mainMenu = NSMenu() + + // App menu + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + let appMenu = NSMenu() + appMenuItem.submenu = appMenu + appMenu.addItem(withTitle: "About TextPlus", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Settings…", + action: #selector(AppDelegate.showSettings(_:)), + keyEquivalent: ",") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Hide TextPlus", + action: #selector(NSApplication.hide(_:)), + keyEquivalent: "h") + let hideOthers = appMenu.addItem(withTitle: "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), + keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit TextPlus", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + + // File menu + let fileMenuItem = NSMenuItem() + mainMenu.addItem(fileMenuItem) + let fileMenu = NSMenu(title: "File") + fileMenuItem.submenu = fileMenu + fileMenu.addItem(withTitle: "New", + action: #selector(NSDocumentController.newDocument(_:)), + keyEquivalent: "n") + fileMenu.addItem(withTitle: "Open…", + action: #selector(NSDocumentController.openDocument(_:)), + keyEquivalent: "o") + // AppKit auto-inserts a managed "Open Recent" submenu after the + // item whose action is openDocument: — don't build one by hand. + fileMenu.addItem(.separator()) + fileMenu.addItem(withTitle: "Close", + action: #selector(NSWindow.performClose(_:)), + keyEquivalent: "w") + fileMenu.addItem(withTitle: "Save", + action: #selector(NSDocument.save(_:)), + keyEquivalent: "s") + let saveAs = fileMenu.addItem(withTitle: "Save As…", + action: #selector(NSDocument.saveAs(_:)), + keyEquivalent: "S") + saveAs.keyEquivalentModifierMask = [.command, .shift] + fileMenu.addItem(withTitle: "Revert to Saved", + action: #selector(NSDocument.revertToSaved(_:)), + keyEquivalent: "") + fileMenu.addItem(.separator()) + let syncNow = fileMenu.addItem(withTitle: "Sync Now", + action: #selector(Document.syncNow(_:)), + keyEquivalent: "s") + syncNow.keyEquivalentModifierMask = [.command, .option] + + // Edit menu + let editMenuItem = NSMenuItem() + mainMenu.addItem(editMenuItem) + let editMenu = NSMenu(title: "Edit") + editMenuItem.submenu = editMenu + let undo = editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + undo.target = nil + let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + redo.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editMenu.addItem(.separator()) + let findItem = editMenu.addItem(withTitle: "Find…", + action: #selector(NSTextView.performFindPanelAction(_:)), + keyEquivalent: "f") + findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue) + let findNext = editMenu.addItem(withTitle: "Find Next", + action: #selector(NSTextView.performFindPanelAction(_:)), + keyEquivalent: "g") + findNext.tag = Int(NSTextFinder.Action.nextMatch.rawValue) + let findPrev = editMenu.addItem(withTitle: "Find Previous", + action: #selector(NSTextView.performFindPanelAction(_:)), + keyEquivalent: "G") + findPrev.keyEquivalentModifierMask = [.command, .shift] + findPrev.tag = Int(NSTextFinder.Action.previousMatch.rawValue) + let useSelection = editMenu.addItem(withTitle: "Use Selection for Find", + action: #selector(NSTextView.performFindPanelAction(_:)), + keyEquivalent: "e") + useSelection.tag = Int(NSTextFinder.Action.setSearchString.rawValue) + + // View menu + let viewMenuItem = NSMenuItem() + mainMenu.addItem(viewMenuItem) + let viewMenu = NSMenu(title: "View") + viewMenuItem.submenu = viewMenu + let editorMode = viewMenu.addItem(withTitle: "Editor", + action: #selector(Document.showEditorMode(_:)), + keyEquivalent: "1") + editorMode.keyEquivalentModifierMask = [.command, .control] + let splitMode = viewMenu.addItem(withTitle: "Editor + Preview", + action: #selector(Document.showSplitMode(_:)), + keyEquivalent: "2") + splitMode.keyEquivalentModifierMask = [.command, .control] + let boardMode = viewMenu.addItem(withTitle: "Board", + action: #selector(Document.showBoardMode(_:)), + keyEquivalent: "3") + boardMode.keyEquivalentModifierMask = [.command, .control] + viewMenu.addItem(.separator()) + let bigger = viewMenu.addItem(withTitle: "Make Text Bigger", + action: #selector(Document.makeTextBigger(_:)), + keyEquivalent: "+") + bigger.keyEquivalentModifierMask = [.command] + // "+" is a shifted key, so that item only fires on ⌘⇧=. This hidden + // alternate makes plain ⌘= work too, like TextEdit. + let biggerAlternate = viewMenu.addItem(withTitle: "Make Text Bigger", + action: #selector(Document.makeTextBigger(_:)), + keyEquivalent: "=") + biggerAlternate.keyEquivalentModifierMask = [.command] + biggerAlternate.isAlternate = true + viewMenu.addItem(withTitle: "Make Text Smaller", + action: #selector(Document.makeTextSmaller(_:)), + keyEquivalent: "-") + viewMenu.addItem(.separator()) + viewMenu.addItem(withTitle: "Toggle Soft Wrap", + action: #selector(Document.toggleSoftWrap(_:)), + keyEquivalent: "") + viewMenu.addItem(withTitle: "Toggle Line Numbers", + action: #selector(Document.toggleLineNumbers(_:)), + keyEquivalent: "") + let cycleTheme = viewMenu.addItem(withTitle: "Cycle Theme", + action: #selector(Document.cycleTheme(_:)), + keyEquivalent: "t") + cycleTheme.keyEquivalentModifierMask = [.command, .control] + + // Format menu (Markdown markup) + let formatMenuItem = NSMenuItem() + mainMenu.addItem(formatMenuItem) + let formatMenu = NSMenu(title: "Format") + formatMenuItem.submenu = formatMenu + let boldItem = formatMenu.addItem(withTitle: "Bold", + action: #selector(Document.markupBold(_:)), keyEquivalent: "b") + boldItem.keyEquivalentModifierMask = [.command] + let italicItem = formatMenu.addItem(withTitle: "Italic", + action: #selector(Document.markupItalic(_:)), keyEquivalent: "i") + italicItem.keyEquivalentModifierMask = [.command] + let codeItem = formatMenu.addItem(withTitle: "Inline Code", + action: #selector(Document.markupCode(_:)), keyEquivalent: "k") + codeItem.keyEquivalentModifierMask = [.command, .shift] + formatMenu.addItem(withTitle: "Strikethrough", + action: #selector(Document.markupStrikethrough(_:)), keyEquivalent: "") + formatMenu.addItem(.separator()) + formatMenu.addItem(withTitle: "Heading", + action: #selector(Document.markupHeading(_:)), keyEquivalent: "") + formatMenu.addItem(withTitle: "Bullet List", + action: #selector(Document.markupList(_:)), keyEquivalent: "") + formatMenu.addItem(withTitle: "Block Quote", + action: #selector(Document.markupQuote(_:)), keyEquivalent: "") + let linkItem = formatMenu.addItem(withTitle: "Link", + action: #selector(Document.markupLink(_:)), keyEquivalent: "k") + linkItem.keyEquivalentModifierMask = [.command] + + // Tools menu + let toolsMenuItem = NSMenuItem() + mainMenu.addItem(toolsMenuItem) + let toolsMenu = NSMenu(title: "Tools") + toolsMenuItem.submenu = toolsMenu + let listSel = toolsMenu.addItem(withTitle: "List Div & Class Selectors", + action: #selector(Document.listSelectors(_:)), + keyEquivalent: "l") + listSel.keyEquivalentModifierMask = [.command, .option] + let extractSel = toolsMenu.addItem(withTitle: "Extract Text for Selector…", + action: #selector(Document.extractForSelector(_:)), + keyEquivalent: "e") + extractSel.keyEquivalentModifierMask = [.command, .option] + toolsMenu.addItem(.separator()) + let listings = toolsMenu.addItem(withTitle: "Extract Discogs Listings", + action: #selector(Document.extractListings(_:)), + keyEquivalent: "d") + listings.keyEquivalentModifierMask = [.command, .option] + let sendPG = toolsMenu.addItem(withTitle: "Send Page to Postgres", + action: #selector(Document.sendToPostgres(_:)), + keyEquivalent: "p") + sendPG.keyEquivalentModifierMask = [.command, .option] + + // Window menu + let windowMenuItem = NSMenuItem() + mainMenu.addItem(windowMenuItem) + let windowMenu = NSMenu(title: "Window") + windowMenuItem.submenu = windowMenu + windowMenu.addItem(withTitle: "Minimize", + action: #selector(NSWindow.performMiniaturize(_:)), + keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", + action: #selector(NSWindow.performZoom(_:)), + keyEquivalent: "") + windowMenu.addItem(.separator()) + windowMenu.addItem(withTitle: "Bring All to Front", + action: #selector(NSApplication.arrangeInFront(_:)), + keyEquivalent: "") + NSApp.windowsMenu = windowMenu + + return mainMenu + } +} diff --git a/Sources/Appearance.swift b/Sources/Appearance.swift new file mode 100644 index 0000000..f2a9bfe --- /dev/null +++ b/Sources/Appearance.swift @@ -0,0 +1,147 @@ +import AppKit + +/// App-wide editor appearance: theme, font family, font size, soft-wrap and +/// line-number toggles. Persisted in UserDefaults and broadcast so every open +/// document window updates together. +enum AppSettings { + static let themeKey = "editorThemeID" + static let fontNameKey = "editorFontName" + static let fontSizeKey = "editorFontSize" + static let softWrapKey = "editorSoftWrap" + static let lineNumbersKey = "editorLineNumbers" + static let boardPageSizeKey = "boardPageSize" + static let boardOrientationKey = "boardOrientation" + static let boardSnapKey = "boardSnap" + + static let didChange = Notification.Name("TextPlusAppearanceDidChange") + + static var theme: EditorTheme { + EditorTheme.byID(UserDefaults.standard.string(forKey: themeKey)) } + + static var fontSize: CGFloat { + let s = UserDefaults.standard.double(forKey: fontSizeKey) + return s >= 6 ? CGFloat(s) : 13 + } + + static var fontName: String { + UserDefaults.standard.string(forKey: fontNameKey) ?? "SF Mono" + } + + static var softWrap: Bool { + UserDefaults.standard.object(forKey: softWrapKey) as? Bool ?? true + } + + static var lineNumbers: Bool { + UserDefaults.standard.object(forKey: lineNumbersKey) as? Bool ?? true + } + + static var boardPageSize: String { + UserDefaults.standard.string(forKey: boardPageSizeKey) ?? "letter" + } + + static var boardOrientation: PageOrientation { + PageOrientation(rawValue: UserDefaults.standard.string(forKey: boardOrientationKey) ?? "portrait") ?? .portrait + } + + static var boardSnap: Bool { + UserDefaults.standard.object(forKey: boardSnapKey) as? Bool ?? true + } + + static var editorFont: NSFont { + font(named: fontName, size: fontSize) + } + + static func font(named name: String, size: CGFloat) -> NSFont { + if name == "SF Mono" { + return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + if name == "System" { + return NSFont.systemFont(ofSize: size) + } + return NSFont(name: name, size: size) + ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + + /// Curated font menu — monospaced first (best for code/scraping), then a + /// couple of proportional faces for prose. + static let fontChoices: [String] = [ + "SF Mono", "Menlo", "Monaco", "Courier New", + "System", "Helvetica Neue", "Georgia", + ] + + static func set(_ key: String, _ value: Any) { + UserDefaults.standard.set(value, forKey: key) + NotificationCenter.default.post(name: didChange, object: nil) + } +} + +/// A complete editor color scheme. Markdown preview and the line-number gutter +/// derive their colors from the same palette so everything stays consistent. +struct EditorTheme: Equatable { + let id: String + let name: String + let isDark: Bool + let background: NSColor + let foreground: NSColor + let caret: NSColor + let selection: NSColor + let gutterBackground: NSColor + let gutterText: NSColor + let currentLine: NSColor + let accent: NSColor + let secondaryText: NSColor + + static func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: CGFloat = 1) -> NSColor { + NSColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: a) + } + + static let system = EditorTheme( + id: "system", name: "System", isDark: false, + background: .textBackgroundColor, foreground: .textColor, + caret: .textColor, selection: .selectedTextBackgroundColor, + gutterBackground: rgb(245, 245, 247), gutterText: rgb(160, 160, 168), + currentLine: rgb(0, 0, 0, 0.04), accent: .controlAccentColor, + secondaryText: .secondaryLabelColor) + + static let dark = EditorTheme( + id: "dark", name: "Midnight", isDark: true, + background: rgb(24, 26, 31), foreground: rgb(222, 226, 233), + caret: rgb(120, 200, 255), selection: rgb(56, 84, 130), + gutterBackground: rgb(19, 21, 25), gutterText: rgb(95, 102, 115), + currentLine: rgb(255, 255, 255, 0.05), accent: rgb(120, 200, 255), + secondaryText: rgb(150, 158, 172)) + + static let sepia = EditorTheme( + id: "sepia", name: "Sepia", isDark: false, + background: rgb(247, 240, 224), foreground: rgb(60, 50, 38), + caret: rgb(150, 90, 40), selection: rgb(220, 200, 160), + gutterBackground: rgb(240, 231, 211), gutterText: rgb(170, 150, 120), + currentLine: rgb(120, 90, 40, 0.07), accent: rgb(165, 100, 40), + secondaryText: rgb(130, 112, 86)) + + static let solarized = EditorTheme( + id: "solarized", name: "Solarized", isDark: true, + background: rgb(0, 43, 54), foreground: rgb(147, 161, 161), + caret: rgb(38, 139, 210), selection: rgb(7, 54, 66), + gutterBackground: rgb(0, 37, 46), gutterText: rgb(88, 110, 117), + currentLine: rgb(255, 255, 255, 0.04), accent: rgb(42, 161, 152), + secondaryText: rgb(101, 123, 131)) + + static let nord = EditorTheme( + id: "nord", name: "Nord", isDark: true, + background: rgb(46, 52, 64), foreground: rgb(216, 222, 233), + caret: rgb(136, 192, 208), selection: rgb(67, 76, 94), + gutterBackground: rgb(40, 45, 56), gutterText: rgb(110, 120, 138), + currentLine: rgb(255, 255, 255, 0.05), accent: rgb(143, 188, 187), + secondaryText: rgb(150, 160, 178)) + + static let all: [EditorTheme] = [system, dark, sepia, solarized, nord] + + static func byID(_ id: String?) -> EditorTheme { + all.first { $0.id == id } ?? system + } + + var appearance: NSAppearance? { + NSAppearance(named: isDark ? .darkAqua : .aqua) + } +} diff --git a/Sources/Board.swift b/Sources/Board.swift new file mode 100644 index 0000000..00cacf1 --- /dev/null +++ b/Sources/Board.swift @@ -0,0 +1,184 @@ +import AppKit + +/// What a card on the freeform board is. The single `.mainText` card hosts the +/// document's real text view (its text == the .txt bytes, never serialized here). +/// Other kinds are board-only objects stored in the sidecar. +enum BoardCardKind: String, Codable { + case mainText, text, image, box +} + +enum PageOrientation: String, Codable { + case portrait, landscape + var toggled: PageOrientation { self == .portrait ? .landscape : .portrait } +} + +/// A named page-size preset (point dimensions at 72pt/inch). `portrait` here is +/// always width ≤ height; orientation swaps for non-square sizes. +enum PageSize: String, Codable, CaseIterable { + case letter, a4, legal, square, wide, note + + var label: String { + switch self { + case .letter: return "US Letter" + case .a4: return "A4" + case .legal: return "US Legal" + case .square: return "Square" + case .wide: return "Wide 16:9" + case .note: return "Note" + } + } + + var shortLabel: String { + switch self { + case .letter: return "Letter" + case .a4: return "A4" + case .legal: return "Legal" + case .square: return "Square" + case .wide: return "Wide" + case .note: return "Note" + } + } + + private var portraitBase: CGSize { + switch self { + case .letter: return CGSize(width: 612, height: 792) // 8.5 × 11" + case .a4: return CGSize(width: 595, height: 842) + case .legal: return CGSize(width: 612, height: 1008) // 8.5 × 14" + case .square: return CGSize(width: 700, height: 700) + case .wide: return CGSize(width: 540, height: 960) // 9:16 portrait of 16:9 + case .note: return CGSize(width: 300, height: 220) + } + } + + func size(_ orientation: PageOrientation) -> CGSize { + if self == .square { return portraitBase } + let b = portraitBase + return orientation == .landscape ? CGSize(width: b.height, height: b.width) : b + } + + static func from(_ raw: String?) -> PageSize { raw.flatMap(PageSize.init(rawValue:)) ?? .letter } +} + +/// One object on the board, in world (board) coordinates. Codable with a +/// lenient decoder so a partial or older sidecar never throws. +struct BoardCard: Codable { + var id: UUID + var kind: BoardCardKind + var frame: CGRect // world coords (flipped board, origin top-left) + var z: Int // explicit stacking order + + var text: String? // .text annotations only; nil for mainText + var orientation: PageOrientation? // mainText/text page orientation + var pageSize: String? // PageSize raw value for mainText/text cards + var imageFile: String? // relative filename inside foo.txt.board/ + var fillHex: String? // .box + var strokeHex: String? // .box + var cornerRadius: CGFloat? // .box + var title: String? // optional label on any card + + init(id: UUID = UUID(), kind: BoardCardKind, frame: CGRect, z: Int, + text: String? = nil, orientation: PageOrientation? = nil, pageSize: String? = nil, + imageFile: String? = nil, fillHex: String? = nil, strokeHex: String? = nil, + cornerRadius: CGFloat? = nil, title: String? = nil) { + self.id = id; self.kind = kind; self.frame = frame; self.z = z + self.text = text; self.orientation = orientation; self.pageSize = pageSize + self.imageFile = imageFile; self.fillHex = fillHex; self.strokeHex = strokeHex + self.cornerRadius = cornerRadius; self.title = title + } + + enum CodingKeys: String, CodingKey { + case id, kind, frame, z, text, orientation, pageSize, imageFile, fillHex, strokeHex, cornerRadius, title + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID() + kind = (try? c.decode(BoardCardKind.self, forKey: .kind)) ?? .text + frame = (try? c.decode(CGRect.self, forKey: .frame)) ?? CGRect(x: 80, y: 80, width: 240, height: 160) + z = (try? c.decode(Int.self, forKey: .z)) ?? 0 + text = try? c.decodeIfPresent(String.self, forKey: .text) + orientation = try? c.decodeIfPresent(PageOrientation.self, forKey: .orientation) + pageSize = try? c.decodeIfPresent(String.self, forKey: .pageSize) + imageFile = try? c.decodeIfPresent(String.self, forKey: .imageFile) + fillHex = try? c.decodeIfPresent(String.self, forKey: .fillHex) + strokeHex = try? c.decodeIfPresent(String.self, forKey: .strokeHex) + cornerRadius = try? c.decodeIfPresent(CGFloat.self, forKey: .cornerRadius) + title = try? c.decodeIfPresent(String.self, forKey: .title) + } +} + +/// The persisted board state for a document. Stored in foo.txt.board/board.json. +/// Reuses the paint canvas's existing Codable doc verbatim for the shapes layer. +struct BoardDoc: Codable { + var version: Int + var cards: [BoardCard] + var shapes: DrawCanvasView.CanvasDoc + var magnification: CGFloat + var scrollOrigin: CGPoint + var boardBackgroundHex: String? + + init(version: Int = 1, cards: [BoardCard], shapes: DrawCanvasView.CanvasDoc, + magnification: CGFloat = 1, scrollOrigin: CGPoint = .zero, boardBackgroundHex: String? = nil) { + self.version = version; self.cards = cards; self.shapes = shapes + self.magnification = magnification; self.scrollOrigin = scrollOrigin + self.boardBackgroundHex = boardBackgroundHex + } + + enum CodingKeys: String, CodingKey { + case version, cards, shapes, magnification, scrollOrigin, boardBackgroundHex + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = (try? c.decode(Int.self, forKey: .version)) ?? 1 + cards = (try? c.decode([BoardCard].self, forKey: .cards)) ?? [] + shapes = (try? c.decode(DrawCanvasView.CanvasDoc.self, forKey: .shapes)) + ?? DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: 0, height: 0) + magnification = (try? c.decode(CGFloat.self, forKey: .magnification)) ?? 1 + scrollOrigin = (try? c.decode(CGPoint.self, forKey: .scrollOrigin)) ?? .zero + boardBackgroundHex = try? c.decodeIfPresent(String.self, forKey: .boardBackgroundHex) + } + + /// A fresh board with a single empty main page card, centered in the world. + static func makeDefault(worldSize: CGSize, + pageSize: PageSize = .from(AppSettings.boardPageSize), + orientation: PageOrientation = AppSettings.boardOrientation) -> BoardDoc { + let page = pageSize.size(orientation) + let origin = CGPoint(x: (worldSize.width - page.width) / 2, + y: max(120, (worldSize.height - page.height) / 2 - 200)) + let main = BoardCard(kind: .mainText, + frame: CGRect(origin: origin, size: page), + z: 0, orientation: orientation, pageSize: pageSize.rawValue) + let empty = DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: worldSize.width, height: worldSize.height) + return BoardDoc(cards: [main], shapes: empty) + } +} + +extension JSONEncoder { + /// Hand-inspectable sidecar JSON, matching the existing canvas sidecar style. + static let boardEncoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [.prettyPrinted, .sortedKeys] + return e + }() +} + +/// Shared plaintext-safety configuration for any NSTextView used as an editor +/// surface (the document editor and board text cards), so they behave identically. +enum TextViewConfig { + static func harden(_ tv: NSTextView) { + tv.isRichText = false + tv.importsGraphics = false + tv.usesFontPanel = false + tv.allowsUndo = true + tv.isAutomaticQuoteSubstitutionEnabled = false + tv.isAutomaticDashSubstitutionEnabled = false + tv.isAutomaticTextReplacementEnabled = false + tv.isAutomaticSpellingCorrectionEnabled = false + tv.isAutomaticLinkDetectionEnabled = false + tv.isAutomaticDataDetectionEnabled = false + tv.smartInsertDeleteEnabled = false + tv.isContinuousSpellCheckingEnabled = false + tv.enabledTextCheckingTypes = 0 + } +} diff --git a/Sources/BoardContainer.swift b/Sources/BoardContainer.swift new file mode 100644 index 0000000..b652dc5 --- /dev/null +++ b/Sources/BoardContainer.swift @@ -0,0 +1,1348 @@ +import AppKit + +/// The active board tool. `select` manipulates cards; `box` drops clean +/// rectangles; the rest forward to the freehand shapes layer. +enum BoardTool: Equatable { + case select, box, pen, marker, line, arrow, oval, eraser + + static let ordered: [BoardTool] = [.select, .box, .pen, .marker, .line, .arrow, .oval, .eraser] + + var name: String { + switch self { + case .select: return "Select" + case .box: return "Box" + case .pen: return "Pen" + case .marker: return "Marker" + case .line: return "Line" + case .arrow: return "Arrow" + case .oval: return "Oval" + case .eraser: return "Eraser" + } + } + + var isDraw: Bool { + switch self { case .select, .box: return false; default: return true } + } + var drawTool: DrawTool? { + switch self { + case .pen: return .pen + case .marker: return .marker + case .line: return .line + case .arrow: return .arrow + case .oval: return .oval + case .eraser: return .eraser + default: return nil + } + } +} + +// MARK: - Card view + +/// One on-board object: a page/text card (bare NSTextView), an image, or a clean +/// box. While idle it swallows clicks so the board can select/drag it; while +/// focused (text editing) it passes clicks through to its text view for the caret. +final class CardView: NSView { + let cardID: UUID + var kind: BoardCardKind + weak var board: BoardView? + + private(set) var textView: NSTextView? // text / mainText cards + private(set) var imageView: NSImageView? // image cards + var ownsTextView: Bool // false for the document's main text view + var isFocused = false + var title: String? + + // box styling + var fillColor: NSColor = .clear + var strokeColor: NSColor = .clear + var boxCornerRadius: CGFloat = 8 + + override var isFlipped: Bool { true } + + init(card: BoardCard, board: BoardView) { + self.cardID = card.id + self.kind = card.kind + self.board = board + self.ownsTextView = (card.kind != .mainText) + self.title = card.title + super.init(frame: card.frame) + wantsLayer = true + translatesAutoresizingMaskIntoConstraints = true + autoresizesSubviews = true + configure(for: card) + } + + required init?(coder: NSCoder) { fatalError() } + + private func configure(for card: BoardCard) { + switch card.kind { + case .mainText: + break // the document's real text view is adopted later + case .text: + let tv = NSTextView(frame: bounds.insetBy(dx: 10, dy: 10)) + TextViewConfig.harden(tv) + // The document is the delegate so edits route undo through it and + // mark the doc dirty (secondary-page text is saved to the sidecar). + tv.delegate = board?.document + tv.autoresizingMask = [.width, .height] + tv.drawsBackground = false + tv.isEditable = false + tv.isSelectable = false + tv.textContainerInset = NSSize(width: 6, height: 8) + tv.textContainer?.widthTracksTextView = true + tv.isHorizontallyResizable = false + tv.string = card.text ?? "" + addSubview(tv) + textView = tv + case .image: + let iv = NSImageView(frame: bounds) + iv.autoresizingMask = [.width, .height] + iv.imageScaling = .scaleProportionallyUpOrDown + iv.imageFrameStyle = .none + addSubview(iv) + imageView = iv + case .box: + if let f = card.fillHex.flatMap({ NSColor(hex: $0) }) { fillColor = f } + if let s = card.strokeHex.flatMap({ NSColor(hex: $0) }) { strokeColor = s } + if let r = card.cornerRadius { boxCornerRadius = r } + } + } + + /// Bind a text view to this card without mounting it as a subview — used for + /// the main card, whose text view starts life in the editor. + func assignTextView(_ tv: NSTextView) { + textView = tv + ownsTextView = (kind != .mainText) + } + + /// Mount this card's text view as its on-board content (idle, wraps to width). + func mountTextView() { + guard let tv = textView else { return } + tv.removeFromSuperview() + tv.frame = bounds.insetBy(dx: 10, dy: 10) + tv.autoresizingMask = [.width, .height] + tv.drawsBackground = false + tv.textContainerInset = NSSize(width: 6, height: 8) + tv.isHorizontallyResizable = false + tv.textContainer?.widthTracksTextView = true + tv.isEditable = false + tv.isSelectable = false + addSubview(tv) + } + + /// Lift this card's text view out (to host it in the editor); keeps the ref. + @discardableResult + func unmountTextView() -> NSTextView? { + textView?.removeFromSuperview() + return textView + } + + func setImage(_ image: NSImage) { imageView?.image = image } + + // MARK: drawing (page chrome / box fill) + + override func draw(_ dirtyRect: NSRect) { + guard let theme = board?.theme else { return } + let r = bounds + switch kind { + case .mainText, .text: + let bg = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6) + // Page uses the editor's own background so the theme.foreground text + // stays readable in every theme (white page would hide light text). + theme.background.setFill() + bg.fill() + (isFocused ? theme.accent : theme.secondaryText.withAlphaComponent(0.35)).setStroke() + bg.lineWidth = isFocused ? 2 : 1 + bg.stroke() + if kind == .text { + // little badge so board-only annotations read as distinct + drawBadge("note", theme: theme) + } + case .image: + let p = NSBezierPath(rect: r) + theme.secondaryText.withAlphaComponent(isFocused ? 0.9 : 0.3).setStroke() + p.lineWidth = isFocused ? 2 : 1 + p.stroke() + case .box: + let p = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: boxCornerRadius, yRadius: boxCornerRadius) + fillColor.setFill(); p.fill() + strokeColor.setStroke(); p.lineWidth = 2; p.stroke() + if let title, !title.isEmpty { drawBoxTitle(title, theme: theme) } + } + } + + private func drawBadge(_ text: String, theme: EditorTheme) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9, weight: .semibold), + .foregroundColor: theme.secondaryText, + ] + (text as NSString).draw(at: NSPoint(x: 8, y: 4), withAttributes: attrs) + } + + private func drawBoxTitle(_ text: String, theme: EditorTheme) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12, weight: .medium), + .foregroundColor: theme.foreground, + ] + (text as NSString).draw(at: NSPoint(x: 8, y: 6), withAttributes: attrs) + } + + // MARK: hit-testing (the core interaction rule) + + override func hitTest(_ point: NSPoint) -> NSView? { + // Focused: caret/selection clicks go to the text view. + if isFocused { return super.hitTest(point) } + // Idle (any tool): transparent so the BoardView owns select/drag/draw/box. + // (Returning self would steal events from the board; returning the text + // view would place a caret instead of selecting the card.) + return nil + } + + func snapshotText(into card: inout BoardCard) { + if kind == .text { card.text = textView?.string } + } +} + +// MARK: - Selection overlay (visual only; board owns all mouse handling) + +final class SelectionOverlayView: NSView { + weak var board: BoardView? + override var isFlipped: Bool { true } + override func hitTest(_ point: NSPoint) -> NSView? { nil } // transparent to events + + override func draw(_ dirtyRect: NSRect) { + guard let board else { return } + let theme = board.theme + // live box preview (box tool drag) on top of everything + if let b = board.liveBox { + let p = NSBezierPath(roundedRect: b, xRadius: 8, yRadius: 8) + board.boxFill.withAlphaComponent(0.18).setFill(); p.fill() + board.boxStroke.setStroke(); p.lineWidth = 2; p.stroke() + } + guard let card = board.selectedCardView else { return } + let r = card.frame + let sel = NSBezierPath(rect: r.insetBy(dx: -2, dy: -2)) + theme.accent.setStroke() + sel.lineWidth = 1.5 + sel.stroke() + for h in board.handleRects(for: r) { + let hp = NSBezierPath(ovalIn: h) + theme.accent.setFill(); hp.fill() + NSColor.white.setStroke(); hp.lineWidth = 1; hp.stroke() + } + } +} + +// MARK: - Board view (the zooming world) + +final class BoardView: NSView { + static let worldSize = CGSize(width: 8000, height: 6000) + static let handleSize: CGFloat = 9 + + let shapes = DrawCanvasView(frame: NSRect(origin: .zero, size: BoardView.worldSize)) + private let overlay = SelectionOverlayView(frame: NSRect(origin: .zero, size: BoardView.worldSize)) + private(set) var cardViews: [CardView] = [] + private(set) var cards: [BoardCard] = [] + + weak var document: Document? + weak var container: BoardContainerView? + var onChange: (() -> Void)? // mark document dirty + var onSelectionChange: (() -> Void)? // update palette enablement + var theme: EditorTheme = .system + + var tool: BoardTool = .select { + didSet { applyToolGating() } + } + var boxFill: NSColor = .systemBlue + var boxStroke: NSColor = .systemBlue + + private(set) var selectedCardView: CardView? + private(set) var liveBox: NSRect? + private let boardUndo = UndoManager() + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + override var undoManager: UndoManager? { boardUndo } + + override init(frame frameRect: NSRect) { + super.init(frame: NSRect(origin: .zero, size: BoardView.worldSize)) + wantsLayer = true + shapes.canvasBackground = .clear + shapes.autoresizingMask = [] + // Freehand strokes/erases route straight to the shapes layer, bypassing + // BoardView's mouse handling — so mark the document dirty from here too, + // or a draw-only session wouldn't prompt to save on close. + shapes.onChange = { [weak self] in self?.markDirty() } + addSubview(shapes) // bottom + overlay.board = self + addSubview(overlay) // top + applyToolGating() + registerForDraggedTypes([.fileURL, .png, .tiff]) + } + + func viewportCenter() -> NSPoint { + let r = enclosingScrollView?.documentVisibleRect ?? NSRect(origin: .zero, size: BoardView.worldSize) + return NSPoint(x: r.midX, y: r.midY) + } + + // MARK: paste / drag-drop images + + @objc func paste(_ sender: Any?) { + let pb = NSPasteboard.general + if let images = pb.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], + let img = images.first { + addImageCard(img, at: viewportCenter()) + } + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { .copy } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let pb = sender.draggingPasteboard + let drop = convert(sender.draggingLocation, from: nil) + if let urls = pb.readObjects(forClasses: [NSURL.self], + options: [.urlReadingContentsConformToTypes: ["public.image"]]) as? [URL], + let url = urls.first, let img = NSImage(contentsOf: url) { + addImageCard(img, at: drop) + return true + } + if let images = pb.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], + let img = images.first { + addImageCard(img, at: drop) + return true + } + return false + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: model → views + + func load(doc: BoardDoc) { + var sorted = doc.cards.sorted { $0.z < $1.z } + // Invariant: exactly one main card must exist, or the document's text + // view would have nowhere to live. Synthesize one if a sidecar lacks it. + if !sorted.contains(where: { $0.kind == .mainText }) { + let dims = PageSize.from(AppSettings.boardPageSize).size(.portrait) + let origin = CGPoint(x: (BoardView.worldSize.width - dims.width) / 2, y: 200) + sorted.insert(BoardCard(kind: .mainText, frame: CGRect(origin: origin, size: dims), + z: -1, orientation: .portrait, pageSize: AppSettings.boardPageSize), at: 0) + } + cards = sorted + rebuildCardViews() + shapes.load(from: (try? JSONEncoder().encode(doc.shapes)) ?? Data()) + shapes.canvasBackground = .clear + } + + var snapshotDoc: BoardDoc { + // pull live frames + secondary text back into the model + var out: [BoardCard] = [] + for (i, var card) in cards.enumerated() { + if let v = cardViews.first(where: { $0.cardID == card.id }) { + card.frame = v.frame + if card.kind == .text { card.text = v.textView?.string } + card.z = i + } + if card.kind == .mainText { card.text = nil } // never duplicate the .txt bytes + out.append(card) + } + let shapeDoc = (shapes.serialized()).flatMap { try? JSONDecoder().decode(DrawCanvasView.CanvasDoc.self, from: $0) } + ?? DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: 0, height: 0) + return BoardDoc(cards: out, shapes: shapeDoc) + } + + private func rebuildCardViews() { + for v in cardViews { v.removeFromSuperview() } + cardViews = cards.map { CardView(card: $0, board: self) } + // insert below the overlay, above the shapes layer, in model order + for v in cardViews { addSubview(v, positioned: .below, relativeTo: overlay) } + overlay.needsDisplay = true + } + + var mainCardView: CardView? { cardViews.first { $0.kind == .mainText } } + + // MARK: editor page binding + + /// Which card the Editor tab should show: the selected text card, else the + /// main page. Lets the editor follow the on-board selection. + func editorPageCandidate() -> CardView? { + if let s = selectedCardView, s.kind == .text || s.kind == .mainText { return s } + return mainCardView + } + + /// Bind the document's text view to the main card (it starts in the editor). + func assignMainTextView(_ tv: NSTextView) { + mainCardView?.assignTextView(tv) + } + + /// Theme each on-board text card's text view to the current theme. + func applyTextTheme(font: NSFont, fg: NSColor, caret: NSColor) { + for v in cardViews where v.kind == .text || v.kind == .mainText { + v.textView?.font = font + v.textView?.textColor = fg + v.textView?.insertionPointColor = caret + v.textView?.typingAttributes = [.font: font, .foregroundColor: fg] + } + } + + // MARK: tool gating + + private func applyToolGating() { + shapes.hitTestEnabled = tool.isDraw + if let dt = tool.drawTool { shapes.tool = dt } + if tool != .select { unfocusCard(commit: true) } + window?.invalidateCursorRects(for: self) + } + + func applyDrawStyle(color: NSColor, width: CGFloat, fill: Bool) { + shapes.strokeColor = color + shapes.lineWidth = width + shapes.fillEnabled = fill + } + + // MARK: theming + + func apply(theme: EditorTheme) { + self.theme = theme + // The board "surface" is the gutter shade so the page card (theme.background) + // stands out against it in every theme. + layer?.backgroundColor = theme.gutterBackground.cgColor + for v in cardViews { v.needsDisplay = true } + overlay.needsDisplay = true + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + theme.gutterBackground.setFill() + dirtyRect.fill() + // dot grid + let spacing: CGFloat = 40 + let dot = theme.gutterText.withAlphaComponent(theme.isDark ? 0.18 : 0.25) + dot.setFill() + let startX = (floor(dirtyRect.minX / spacing)) * spacing + let startY = (floor(dirtyRect.minY / spacing)) * spacing + var y = startY + while y <= dirtyRect.maxY { + var x = startX + while x <= dirtyRect.maxX { + NSBezierPath(ovalIn: NSRect(x: x - 1, y: y - 1, width: 2, height: 2)).fill() + x += spacing + } + y += spacing + } + } + + // MARK: selection + handles + + func handleRects(for r: NSRect) -> [NSRect] { + let s = BoardView.handleSize + let pts = [ + NSPoint(x: r.minX, y: r.minY), NSPoint(x: r.midX, y: r.minY), NSPoint(x: r.maxX, y: r.minY), + NSPoint(x: r.minX, y: r.midY), NSPoint(x: r.maxX, y: r.midY), + NSPoint(x: r.minX, y: r.maxY), NSPoint(x: r.midX, y: r.maxY), NSPoint(x: r.maxX, y: r.maxY), + ] + return pts.map { NSRect(x: $0.x - s / 2, y: $0.y - s / 2, width: s, height: s) } + } + + private func handleIndex(at p: NSPoint, for r: NSRect) -> Int? { + for (i, h) in handleRects(for: r).enumerated() where h.insetBy(dx: -3, dy: -3).contains(p) { return i } + return nil + } + + func select(_ card: CardView?) { + if selectedCardView !== card { unfocusCard(commit: true) } + selectedCardView = card + overlay.needsDisplay = true + onSelectionChange?() + } + + // MARK: focus + + func focusCard(_ card: CardView) { + guard card.kind == .mainText || card.kind == .text, let tv = card.textView else { return } + select(card) + raise(card) + card.isFocused = true + tv.isEditable = true + tv.isSelectable = true + window?.makeFirstResponder(tv) + card.needsDisplay = true + overlay.needsDisplay = true + if let scroll = enclosingScrollView { zoom(scroll, to: card.frame) } + } + + @discardableResult + func unfocusCard(commit: Bool) -> Bool { + guard let card = cardViews.first(where: { $0.isFocused }) else { return false } + card.textView?.breakUndoCoalescing() + if commit, card.kind == .text, let idx = cards.firstIndex(where: { $0.id == card.cardID }) { + cards[idx].text = card.textView?.string + markDirty() + } + if card.kind == .text { card.textView?.isEditable = false; card.textView?.isSelectable = false } + else { card.textView?.isEditable = false; card.textView?.isSelectable = false } + card.isFocused = false + card.needsDisplay = true + window?.makeFirstResponder(self) + overlay.needsDisplay = true + return true + } + + private func zoom(_ scroll: NSScrollView, to rect: NSRect) { + let target = min(1.4, max(1.0, (scroll.contentView.bounds.width - 80) / max(1, rect.width))) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.25 + ctx.allowsImplicitAnimation = true + scroll.animator().setMagnification(target, centeredAt: NSPoint(x: rect.midX, y: rect.midY)) + } + } + + private func raise(_ card: CardView) { + guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return } + cardViews.remove(at: idx) + cardViews.append(card) + card.removeFromSuperview() + addSubview(card, positioned: .below, relativeTo: overlay) + syncModelOrder() + } + + private func syncModelOrder() { + var reordered: [BoardCard] = [] + for v in cardViews { + if let card = cards.first(where: { $0.id == v.cardID }) { reordered.append(card) } + } + cards = reordered + } + + // MARK: mutation helpers + + func markDirty() { + onChange?() + document?.updateChangeCount(.changeDone) + } + + private func updateModelFrame(_ id: UUID, _ frame: NSRect) { + if let i = cards.firstIndex(where: { $0.id == id }) { cards[i].frame = frame } + } + + func addBox(_ rect: NSRect) { + let card = BoardCard(kind: .box, frame: rect, z: cards.count, + fillHex: boxFill.withAlphaComponent(0.18).hexString, + strokeHex: boxStroke.hexString, cornerRadius: 8) + addCard(card, undoName: "Add Box") + } + + func addTextCard(at center: NSPoint) { + // A new text "doc" matches the current page size / orientation; the user + // can resize it freely afterward. + let ps = mainPageSize, o = mainOrientation + let size = ps.size(o) + // Place it beside existing content (not on top of the main page) so pages + // never overlap; fall back to the given center on an empty board. + let origin: NSPoint + if cardViews.isEmpty { + origin = NSPoint(x: center.x - size.width / 2, y: center.y - size.height / 2) + } else { + let content = contentBounds() + origin = NSPoint(x: content.maxX + 48, y: content.minY) + } + let rect = NSRect(origin: origin, size: size) + let card = BoardCard(kind: .text, frame: rect, z: cards.count, text: "", + orientation: o, pageSize: ps.rawValue) + addCard(card, undoName: "Add Text Page") + scrollToVisible(rect.insetBy(dx: -60, dy: -60)) // reveal the new page + } + + func addImageCard(_ image: NSImage, at center: NSPoint) { + var size = image.size + let maxDim: CGFloat = 360 + if size.width > maxDim || size.height > maxDim { + let s = maxDim / max(size.width, size.height); size = NSSize(width: size.width * s, height: size.height * s) + } + if size.width < 10 || size.height < 10 { size = NSSize(width: 240, height: 180) } + let id = UUID() + let rect = NSRect(x: center.x - size.width / 2, y: center.y - size.height / 2, + width: size.width, height: size.height) + let card = BoardCard(id: id, kind: .image, frame: rect, z: cards.count, imageFile: "img-\(id).png") + let view = addCard(card, undoName: "Add Image") + view?.setImage(image) + } + + func setImage(_ image: NSImage, forCardID id: UUID) { + cardViews.first { $0.cardID == id }?.setImage(image) + } + + func image(forCardID id: UUID) -> NSImage? { + cardViews.first { $0.cardID == id }?.imageView?.image + } + + @discardableResult + private func addCard(_ card: BoardCard, undoName: String) -> CardView? { + let v = CardView(card: card, board: self) + cards.append(card) + cardViews.append(v) + addSubview(v, positioned: .below, relativeTo: overlay) + boardUndo.registerUndo(withTarget: self) { b in b.extract(v, card: card) } + boardUndo.setActionName(undoName) + select(v) + markDirty() + return v + } + + func removeCard(id: UUID, undoName: String = "Delete") { + guard let idx = cardViews.firstIndex(where: { $0.cardID == id }), + cardViews[idx].kind != .mainText, + let card = cards.first(where: { $0.id == id }) else { return } + boardUndo.setActionName(undoName) + extract(cardViews[idx], card: card) + } + + /// Remove a card view, undoably. Reuses the LIVE view (preserving its image + /// pixels / text) instead of rebuilding a blank one from the model, and + /// registers a symmetric redo so delete↔undo↔redo all round-trip. + private func extract(_ view: CardView, card: BoardCard) { + if selectedCardView === view { select(nil) } + view.removeFromSuperview() + cardViews.removeAll { $0 === view } + cards.removeAll { $0.id == card.id } + boardUndo.registerUndo(withTarget: self) { b in b.reinsert(view, card: card) } + markDirty() + } + + private func reinsert(_ view: CardView, card: BoardCard) { + cards.append(card) + cardViews.append(view) + addSubview(view, positioned: .below, relativeTo: overlay) + boardUndo.registerUndo(withTarget: self) { b in b.extract(view, card: card) } + select(view) + markDirty() + } + + func deleteSelection() { + if let c = selectedCardView { removeCard(id: c.cardID, undoName: "Delete Card") } + } + + func undo() { if boardUndo.canUndo { boardUndo.undo(); overlay.needsDisplay = true } } + func redo() { if boardUndo.canRedo { boardUndo.redo(); overlay.needsDisplay = true } } + + // MARK: orientation + framing + + var mainOrientation: PageOrientation { cards.first { $0.kind == .mainText }?.orientation ?? .portrait } + var mainPageSize: PageSize { PageSize.from(cards.first { $0.kind == .mainText }?.pageSize) } + + func setMainOrientation(_ o: PageOrientation) { + applyMainPage(size: mainPageSize, orientation: o) + } + + func setMainPageSize(_ size: PageSize) { + applyMainPage(size: size, orientation: mainOrientation) + } + + /// Resize the main page card to a preset size × orientation, keeping its + /// top-left corner fixed. + private func applyMainPage(size: PageSize, orientation: PageOrientation) { + guard let i = cards.firstIndex(where: { $0.kind == .mainText }) else { return } + let dims = size.size(orientation) + cards[i].orientation = orientation + cards[i].pageSize = size.rawValue + cards[i].frame.size = dims + mainCardView?.frame.size = dims + overlay.needsDisplay = true + markDirty() + } + + /// Union of all card frames (the meaningful content), for fit + export. + func contentBounds() -> NSRect { + var rects = cardViews.map { $0.frame } + if let sb = shapes.contentBounds() { rects.append(sb) } + guard var u = rects.first else { + let p = PageSize.letter.size(.portrait) + return NSRect(x: (BoardView.worldSize.width - p.width) / 2, y: 200, width: p.width, height: p.height) + } + for r in rects.dropFirst() { u = u.union(r) } + return u + } + + func zoomToFit() { + guard let scroll = enclosingScrollView else { return } + let content = contentBounds().insetBy(dx: -80, dy: -80) + let vp = scroll.contentView.bounds.size + let scale = min(scroll.maxMagnification, max(scroll.minMagnification, + min(vp.width / max(1, content.width), vp.height / max(1, content.height)))) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.25 + ctx.allowsImplicitAnimation = true + scroll.animator().setMagnification(scale, centeredAt: NSPoint(x: content.midX, y: content.midY)) + } + DispatchQueue.main.async { + self.scrollToVisible(content) + } + } + + /// Center the viewport on the main card without changing magnification. + func centerOnMain() { + guard let main = mainCardView else { return } + scrollToVisible(main.frame.insetBy(dx: -60, dy: -60)) + } + + // MARK: mouse — select / drag / resize / box + + private enum DragMode { case none, move, resize(Int), box } + private var dragMode: DragMode = .none + private var dragStart = NSPoint.zero + private var origFrame = NSRect.zero + + override func mouseDown(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + if tool == .box { + dragMode = .box + dragStart = p + liveBox = NSRect(origin: p, size: .zero) + return + } + // select tool + if let sel = selectedCardView, let h = handleIndex(at: p, for: sel.frame) { + dragMode = .resize(h); dragStart = p; origFrame = sel.frame + return + } + if let hitView = topCard(at: p) { + select(hitView) + if event.clickCount >= 2 { focusCard(hitView); dragMode = .none; return } + dragMode = .move; dragStart = p; origFrame = hitView.frame + } else { + select(nil) + unfocusCard(commit: true) + dragMode = .none + } + } + + override func mouseDragged(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + switch dragMode { + case .move: + guard let v = selectedCardView else { return } + var f = origFrame + f.origin.x += p.x - dragStart.x + f.origin.y += p.y - dragStart.y + v.frame = snap(f) + overlay.needsDisplay = true + case .resize(let h): + guard let v = selectedCardView else { return } + v.frame = snap(resized(origFrame, handle: h, by: NSPoint(x: p.x - dragStart.x, y: p.y - dragStart.y))) + overlay.needsDisplay = true + case .box: + liveBox = NSRect(corner: dragStart, to: p) + overlay.needsDisplay = true + case .none: + break + } + } + + override func mouseUp(with event: NSEvent) { + switch dragMode { + case .move, .resize: + if let v = selectedCardView { + updateModelFrame(v.cardID, v.frame) + markDirty() + } + case .box: + if let b = liveBox, b.width > 8, b.height > 8 { addBox(b) } + liveBox = nil + overlay.needsDisplay = true + case .none: + break + } + dragMode = .none + } + + private func topCard(at p: NSPoint) -> CardView? { + for v in cardViews.reversed() where v.frame.contains(p) { return v } + return nil + } + + private func snap(_ r: NSRect) -> NSRect { + guard AppSettings.boardSnap else { + return NSRect(x: r.origin.x, y: r.origin.y, width: max(40, r.width), height: max(30, r.height)) + } + let g: CGFloat = 8 + return NSRect(x: (r.origin.x / g).rounded() * g, y: (r.origin.y / g).rounded() * g, + width: max(40, (r.width / g).rounded() * g), height: max(30, (r.height / g).rounded() * g)) + } + + private func resized(_ r: NSRect, handle: Int, by d: NSPoint) -> NSRect { + // handle order: 0 TL,1 TM,2 TR,3 ML,4 MR,5 BL,6 BM,7 BR + let minW: CGFloat = 40, minH: CGFloat = 30 + var minX = r.minX, maxX = r.maxX, minY = r.minY, maxY = r.maxY + let left = [0, 3, 5].contains(handle), right = [2, 4, 7].contains(handle) + let top = [0, 1, 2].contains(handle), bottom = [5, 6, 7].contains(handle) + // Move the dragged edge but never past the anchored (opposite) edge — + // clamp against min size so the card can't flip or explode. + if left { minX = min(r.minX + d.x, maxX - minW) } + if right { maxX = max(r.maxX + d.x, minX + minW) } + if top { minY = min(r.minY + d.y, maxY - minH) } + if bottom { maxY = max(r.maxY + d.y, minY + minH) } + return NSRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 53: // esc + if !unfocusCard(commit: true) { select(nil) } + case 51, 117: // delete / fwd-delete + deleteSelection() + case 36: // return → focus selected text card + if let c = selectedCardView, c.kind == .mainText || c.kind == .text { focusCard(c) } + default: + super.keyDown(with: event) + } + } + + // MARK: right-click context menu + + override func menu(for event: NSEvent) -> NSMenu? { + let p = convert(event.locationInWindow, from: nil) + let card = topCard(at: p) + select(card) // right-click selects what's under the cursor + return container?.contextMenu(forCard: card) + } + + // MARK: z-order + duplicate (used by the context menu) + + func bringToFront(_ card: CardView) { + guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return } + cardViews.remove(at: idx) + cardViews.append(card) + card.removeFromSuperview() + addSubview(card, positioned: .below, relativeTo: overlay) + syncModelOrder(); markDirty(); overlay.needsDisplay = true + } + + func sendToBack(_ card: CardView) { + guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return } + cardViews.remove(at: idx) + cardViews.insert(card, at: 0) + card.removeFromSuperview() + addSubview(card, positioned: .above, relativeTo: shapes) + syncModelOrder(); markDirty(); overlay.needsDisplay = true + } + + func duplicateSelected() { + guard let v = selectedCardView, v.kind != .mainText, + let card = cards.first(where: { $0.id == v.cardID }) else { return } + var copy = card + copy.id = UUID() + copy.frame = card.frame.offsetBy(dx: 28, dy: 28) + copy.z = cards.count + if copy.kind == .image { copy.imageFile = "img-\(copy.id).png" } + let view = addCard(copy, undoName: "Duplicate") + if copy.kind == .image, let img = v.imageView?.image { view?.setImage(img) } + } + + func focusSelected() { + if let c = selectedCardView, c.kind == .mainText || c.kind == .text { focusCard(c) } + } +} + +// MARK: - Board scroll view (modifier-aware wheel) + +/// Wheel behavior in board mode: ⌃ zooms in/out centered on the cursor, +/// ⌥ scrolls horizontally, ⌘ scrolls vertically; a plain wheel keeps the +/// default scrolling. Pan amounts are divided by magnification so the felt +/// speed stays constant at any zoom. +final class BoardScrollView: NSScrollView { + override func scrollWheel(with event: NSEvent) { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let precise = event.hasPreciseScrollingDeltas + let dy = precise ? event.scrollingDeltaY : event.deltaY * 24 + let dx = precise ? event.scrollingDeltaX : event.deltaX * 24 + + if flags.contains(.control) { + let raw = precise ? event.scrollingDeltaY : event.deltaY + guard raw != 0 else { return } + let factor = 1 + raw * (precise ? 0.004 : 0.05) + let newMag = max(minMagnification, min(maxMagnification, magnification * factor)) + if let doc = documentView { + setMagnification(newMag, centeredAt: doc.convert(event.locationInWindow, from: nil)) + } + return + } + if flags.contains(.option) { + // Map the (usually vertical) wheel onto the X axis. + pan(dx: -(dy != 0 ? dy : dx), dy: 0) + return + } + if flags.contains(.command) { + pan(dx: 0, dy: -dy) + return + } + super.scrollWheel(with: event) + } + + private func pan(dx: CGFloat, dy: CGFloat) { + let m = max(magnification, 0.0001) + let clip = contentView + var origin = clip.bounds.origin + origin.x += dx / m + origin.y += dy / m + let constrained = clip.constrainBoundsRect(NSRect(origin: origin, size: clip.bounds.size)) + clip.scroll(to: constrained.origin) + reflectScrolledClipView(clip) + } +} + +// MARK: - Board container (palette + magnifying scroll view) + +/// Hosts the freeform board: a fixed left tool palette plus a magnifying scroll +/// view whose document view is the BoardView world. Mirrors CanvasContainerView's +/// palette idiom and absorbs the old paint canvas (the board's shapes layer). +final class BoardContainerView: NSView { + let boardView = BoardView(frame: .zero) + let boardScroll = BoardScrollView() + private let palette = NSStackView() + private var theme: EditorTheme = .system + + weak var document: Document? { + didSet { boardView.document = document } + } + var onStatus: ((String) -> Void)? + var onSendToTailnet: ((Data) -> Void)? + var onStatusChange: (() -> Void)? // pan/zoom/tool/selection → refresh status bar + + var currentTool: BoardTool { tool } + var zoomPercent: Int { Int((boardScroll.magnification * 100).rounded()) } + + private var tool: BoardTool = .select + private var toolButtons: [BoardTool: NSButton] = [:] + private var strokeWell: NSColorWell? + private var orientationSeg: NSSegmentedControl? + private var pageSizeButton: NSButton? + + private let paletteWidth: CGFloat = 60 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + required init?(coder: NSCoder) { fatalError() } + deinit { NotificationCenter.default.removeObserver(self) } + + @objc private func boardViewportChanged() { onStatusChange?() } + + private func setup() { + boardView.container = self + boardScroll.translatesAutoresizingMaskIntoConstraints = false + boardScroll.hasVerticalScroller = true + boardScroll.hasHorizontalScroller = true + boardScroll.borderType = .noBorder + boardScroll.allowsMagnification = true + boardScroll.minMagnification = 0.15 + boardScroll.maxMagnification = 4 + boardScroll.documentView = boardView + addSubview(boardScroll) + // Status updates on pan/zoom. + boardScroll.contentView.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver(self, selector: #selector(boardViewportChanged), + name: NSView.boundsDidChangeNotification, + object: boardScroll.contentView) + + palette.orientation = .vertical + palette.alignment = .centerX + palette.spacing = 5 + palette.edgeInsets = NSEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) + palette.translatesAutoresizingMaskIntoConstraints = false + palette.wantsLayer = true + addSubview(palette) + + buildPalette() + + NSLayoutConstraint.activate([ + palette.leadingAnchor.constraint(equalTo: leadingAnchor), + palette.topAnchor.constraint(equalTo: topAnchor), + palette.bottomAnchor.constraint(equalTo: bottomAnchor), + palette.widthAnchor.constraint(equalToConstant: paletteWidth), + boardScroll.leadingAnchor.constraint(equalTo: palette.trailingAnchor), + boardScroll.trailingAnchor.constraint(equalTo: trailingAnchor), + boardScroll.topAnchor.constraint(equalTo: topAnchor), + boardScroll.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + boardView.onSelectionChange = { [weak self] in self?.refreshPageControls(); self?.onStatusChange?() } + applyColor(strokeWell?.color ?? .systemBlue) // sync pen to the well at launch + selectTool(.select) + apply(theme: .system) + } + + private let toolOrder: [(BoardTool, String, String)] = [ + (.select, "cursorarrow", "Select / move / resize"), + (.box, "rectangle", "Clean box"), + (.pen, "pencil.tip", "Pen"), + (.marker, "highlighter", "Marker"), + (.line, "line.diagonal", "Line"), + (.arrow, "arrow.up.right", "Arrow"), + (.oval, "oval", "Oval"), + (.eraser, "eraser", "Eraser"), + ] + + private func buildPalette() { + for (t, sym, tip) in toolOrder { + let b = paletteButton(symbol: sym, tooltip: tip) + b.target = self; b.action = #selector(toolTapped(_:)) + b.tag = toolOrder.firstIndex { $0.0 == t }! + toolButtons[t] = b + palette.addArrangedSubview(b) + } + + palette.addArrangedSubview(separator()) + let stroke = NSColorWell() + stroke.color = .systemBlue + stroke.target = self; stroke.action = #selector(strokeChanged(_:)) + stroke.toolTip = "Color for pen, shapes & boxes — click to pick" + constrainSquare(stroke, 30) + strokeWell = stroke + palette.addArrangedSubview(stroke) + let eyedrop = paletteButton(symbol: "eyedropper", tooltip: "Grab a color from anywhere on screen") + eyedrop.target = self; eyedrop.action = #selector(pickScreenColor) + palette.addArrangedSubview(eyedrop) + let width = NSSlider(value: 3, minValue: 1, maxValue: 24, target: self, action: #selector(widthChanged(_:))) + width.toolTip = "Stroke width" + width.translatesAutoresizingMaskIntoConstraints = false + width.widthAnchor.constraint(equalToConstant: 48).isActive = true + palette.addArrangedSubview(width) + + palette.addArrangedSubview(separator()) + let newText = paletteButton(symbol: "doc.badge.plus", tooltip: "New text page (current page size)") + newText.target = self; newText.action = #selector(newTextCard); palette.addArrangedSubview(newText) + let addImage = paletteButton(symbol: "photo", tooltip: "Add image…") + addImage.target = self; addImage.action = #selector(addImage(_:)); palette.addArrangedSubview(addImage) + + let pageBtn = paletteButton(symbol: "ruler", tooltip: "Main page size") + pageBtn.target = self; pageBtn.action = #selector(showPageSizeMenu(_:)) + pageSizeButton = pageBtn + palette.addArrangedSubview(pageBtn) + + let orient = NSSegmentedControl(labels: ["P", "L"], trackingMode: .selectOne, + target: self, action: #selector(orientationChanged(_:))) + orient.toolTip = "Portrait / Landscape page" + orient.selectedSegment = 0 + orient.translatesAutoresizingMaskIntoConstraints = false + orient.widthAnchor.constraint(equalToConstant: 48).isActive = true + orientationSeg = orient + palette.addArrangedSubview(orient) + + palette.addArrangedSubview(separator()) + let fit = paletteButton(symbol: "arrow.up.left.and.down.right.magnifyingglass", tooltip: "Zoom to fit") + fit.target = self; fit.action = #selector(zoomFit); palette.addArrangedSubview(fit) + let undo = paletteButton(symbol: "arrow.uturn.backward", tooltip: "Undo board change") + undo.target = self; undo.action = #selector(undoTapped); palette.addArrangedSubview(undo) + let redo = paletteButton(symbol: "arrow.uturn.forward", tooltip: "Redo board change") + redo.target = self; redo.action = #selector(redoTapped); palette.addArrangedSubview(redo) + + palette.addArrangedSubview(separator()) + let export = paletteButton(symbol: "square.and.arrow.down", tooltip: "Export board PNG…") + export.target = self; export.action = #selector(exportTapped); palette.addArrangedSubview(export) + let send = paletteButton(symbol: "paperplane", tooltip: "Send board to tailnet") + send.target = self; send.action = #selector(sendTapped); palette.addArrangedSubview(send) + } + + private func paletteButton(symbol: String, tooltip: String) -> NSButton { + let b = NSButton() + b.bezelStyle = .regularSquare + b.isBordered = true + b.setButtonType(.momentaryPushIn) + b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: tooltip) + b.imageScaling = .scaleProportionallyDown + b.toolTip = tooltip + constrainSquare(b, 30) + return b + } + + private func separator() -> NSView { + let v = NSBox(); v.boxType = .separator + v.translatesAutoresizingMaskIntoConstraints = false + v.widthAnchor.constraint(equalToConstant: 42).isActive = true + return v + } + + private func constrainSquare(_ v: NSView, _ side: CGFloat) { + v.translatesAutoresizingMaskIntoConstraints = false + v.widthAnchor.constraint(equalToConstant: side).isActive = true + v.heightAnchor.constraint(equalToConstant: side).isActive = true + } + + // MARK: actions + + @objc private func toolTapped(_ sender: NSButton) { + selectTool(toolOrder[sender.tag].0) + } + + func selectTool(_ t: BoardTool) { + tool = t + boardView.tool = t + for (bt, b) in toolButtons { + b.contentTintColor = (bt == t) ? theme.accent : nil + b.layer?.backgroundColor = (bt == t) ? theme.accent.withAlphaComponent(0.16).cgColor : nil + } + onStatusChange?() + } + + @objc private func strokeChanged(_ s: NSColorWell) { + applyColor(s.color) + } + + /// Single source of truth for the active color — drives the well, the box + /// fill/stroke, and the freehand pen/shape color together. + func applyColor(_ color: NSColor) { + strokeWell?.color = color + boardView.boxFill = color + boardView.boxStroke = color + boardView.applyDrawStyle(color: color, width: boardView.shapes.lineWidth, fill: boardView.shapes.fillEnabled) + } + + /// Native macOS screen color sampler — click anywhere on screen (the loupe) + /// to grab a color and apply it to pen, shapes, and boxes. + @objc private func pickScreenColor() { + NSColorSampler().show { [weak self] picked in + guard let self, let picked else { return } + self.applyColor(picked) + } + } + @objc private func widthChanged(_ s: NSSlider) { + boardView.applyDrawStyle(color: boardView.shapes.strokeColor, width: CGFloat(s.doubleValue), fill: boardView.shapes.fillEnabled) + } + @objc private func newTextCard() { + boardView.addTextCard(at: viewportCenterInWorld()) + selectTool(.select) + } + @objc private func orientationChanged(_ s: NSSegmentedControl) { + boardView.setMainOrientation(s.selectedSegment == 1 ? .landscape : .portrait) + } + @objc private func showPageSizeMenu(_ sender: NSButton) { + let menu = NSMenu() + let current = boardView.mainPageSize + for ps in PageSize.allCases { + let item = NSMenuItem(title: ps.label, action: #selector(pageSizePicked(_:)), keyEquivalent: "") + item.target = self + item.representedObject = ps.rawValue + item.state = (ps == current) ? .on : .off + menu.addItem(item) + } + menu.popUp(positioning: nil, at: NSPoint(x: sender.bounds.maxX, y: sender.bounds.midY), in: sender) + } + + @objc private func pageSizePicked(_ item: NSMenuItem) { + guard let raw = item.representedObject as? String else { return } + boardView.setMainPageSize(PageSize.from(raw)) + refreshPageControls() + } + + /// Sync the orientation control + page-size tooltip to the loaded main page. + func refreshPageControls() { + orientationSeg?.selectedSegment = boardView.mainOrientation == .landscape ? 1 : 0 + pageSizeButton?.toolTip = "Main page size — \(boardView.mainPageSize.label)" + } + @objc private func zoomFit() { boardView.zoomToFit(); onStatusChange?() } + @objc private func undoTapped() { boardView.undo() } + @objc private func redoTapped() { boardView.redo() } + + @objc private func zoomIn() { setZoom(boardScroll.magnification * 1.25) } + @objc private func zoomOut() { setZoom(boardScroll.magnification / 1.25) } + @objc private func actualSize() { setZoom(1) } + private func setZoom(_ m: CGFloat) { + let clamped = max(boardScroll.minMagnification, min(boardScroll.maxMagnification, m)) + boardScroll.setMagnification(clamped, centeredAt: boardView.viewportCenter()) + onStatusChange?() + } + + // MARK: - Right-click context menu (full feature access from the mouse) + + private let colorSwatches: [(String, NSColor)] = [ + ("Red", .systemRed), ("Orange", .systemOrange), ("Yellow", .systemYellow), + ("Green", .systemGreen), ("Blue", .systemBlue), ("Purple", .systemPurple), + ("Pink", .systemPink), ("Black", .black), ("Gray", .systemGray), ("White", .white), + ] + + func contextMenu(forCard card: CardView?) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + if let card { addCardSection(menu, card: card); menu.addItem(.separator()) } + addBoardSection(menu) + return menu + } + + private func mi(_ title: String, _ action: Selector?, obj: Any? = nil, + checked: Bool = false, enabled: Bool = true) -> NSMenuItem { + let it = NSMenuItem(title: title, action: action, keyEquivalent: "") + it.target = self + it.representedObject = obj + it.state = checked ? .on : .off + it.isEnabled = enabled + return it + } + + private func submenu(_ title: String, _ build: (NSMenu) -> Void) -> NSMenuItem { + let parent = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let sub = NSMenu(title: title) + sub.autoenablesItems = false + build(sub) + parent.submenu = sub + return parent + } + + private func addCardSection(_ menu: NSMenu, card: CardView) { + let kindName: String + switch card.kind { + case .mainText: kindName = "Main page" + case .text: kindName = "Text page" + case .image: kindName = "Image" + case .box: kindName = "Box" + } + menu.addItem(mi(kindName, nil, enabled: false)) + if card.kind == .mainText || card.kind == .text { + menu.addItem(mi("Edit Text", #selector(menuFocus))) + } + menu.addItem(.separator()) + menu.addItem(mi("Bring to Front", #selector(menuBringFront))) + menu.addItem(mi("Send to Back", #selector(menuSendBack))) + if card.kind != .mainText { + menu.addItem(mi("Duplicate", #selector(menuDuplicate))) + menu.addItem(mi("Delete", #selector(menuDelete))) + } + } + + private func addBoardSection(_ menu: NSMenu) { + menu.addItem(submenu("Tools") { sub in + for t in BoardTool.ordered { + sub.addItem(self.mi(t.name, #selector(self.menuTool(_:)), obj: t, checked: t == self.tool)) + } + }) + menu.addItem(.separator()) + menu.addItem(mi("New Text Page", #selector(newTextCard))) + menu.addItem(mi("Add Image…", #selector(addImage(_:)))) + let hasImg = NSPasteboard.general.canReadObject(forClasses: [NSImage.self], options: nil) + menu.addItem(mi("Paste Image", #selector(menuPaste), enabled: hasImg)) + menu.addItem(.separator()) + menu.addItem(submenu("Page") { sub in + sub.addItem(self.submenu("Size") { s2 in + for ps in PageSize.allCases { + s2.addItem(self.mi(ps.label, #selector(self.menuPageSize(_:)), obj: ps.rawValue, + checked: ps == self.boardView.mainPageSize)) + } + }) + sub.addItem(self.submenu("Orientation") { s2 in + s2.addItem(self.mi("Portrait", #selector(self.menuOrientation(_:)), obj: "portrait", + checked: self.boardView.mainOrientation == .portrait)) + s2.addItem(self.mi("Landscape", #selector(self.menuOrientation(_:)), obj: "landscape", + checked: self.boardView.mainOrientation == .landscape)) + }) + }) + menu.addItem(submenu("Zoom") { sub in + sub.addItem(self.mi("Zoom In", #selector(self.zoomIn))) + sub.addItem(self.mi("Zoom Out", #selector(self.zoomOut))) + sub.addItem(self.mi("Zoom to Fit", #selector(self.zoomFit))) + sub.addItem(self.mi("Actual Size (100%)", #selector(self.actualSize))) + }) + menu.addItem(submenu("Color") { sub in + for (name, color) in self.colorSwatches { + sub.addItem(self.mi(name, #selector(self.menuColor(_:)), obj: color)) + } + sub.addItem(.separator()) + sub.addItem(self.mi("Pick from Screen…", #selector(self.pickScreenColor))) + sub.addItem(self.mi("Show Color Picker…", #selector(self.menuShowColors))) + }) + menu.addItem(submenu("View Mode") { sub in + sub.addItem(self.mi("Editor", #selector(self.menuModeEditor))) + sub.addItem(self.mi("Editor + Preview", #selector(self.menuModeSplit))) + sub.addItem(self.mi("Board", #selector(self.menuModeBoard), checked: true)) + }) + menu.addItem(submenu("Theme") { sub in + let cur = AppSettings.theme.id + for th in EditorTheme.all { + sub.addItem(self.mi(th.name, #selector(self.menuTheme(_:)), obj: th.id, checked: th.id == cur)) + } + }) + menu.addItem(.separator()) + menu.addItem(mi("Undo", #selector(undoTapped))) + menu.addItem(mi("Redo", #selector(redoTapped))) + menu.addItem(.separator()) + menu.addItem(mi("Export Board PNG…", #selector(exportTapped))) + menu.addItem(mi("Send Board to Tailnet", #selector(sendTapped))) + } + + @objc private func menuTool(_ s: NSMenuItem) { if let t = s.representedObject as? BoardTool { selectTool(t) } } + @objc private func menuPaste() { boardView.paste(nil) } + @objc private func menuPageSize(_ s: NSMenuItem) { + if let raw = s.representedObject as? String { boardView.setMainPageSize(PageSize.from(raw)); refreshPageControls() } + } + @objc private func menuOrientation(_ s: NSMenuItem) { + if let raw = s.representedObject as? String, let o = PageOrientation(rawValue: raw) { + boardView.setMainOrientation(o); refreshPageControls() + } + } + @objc private func menuColor(_ s: NSMenuItem) { if let c = s.representedObject as? NSColor { applyColor(c) } } + @objc private func menuShowColors() { strokeWell?.activate(true) } + @objc private func menuFocus() { boardView.focusSelected() } + @objc private func menuBringFront() { if let c = boardView.selectedCardView { boardView.bringToFront(c) } } + @objc private func menuSendBack() { if let c = boardView.selectedCardView { boardView.sendToBack(c) } } + @objc private func menuDuplicate() { boardView.duplicateSelected() } + @objc private func menuDelete() { boardView.deleteSelection() } + @objc private func menuModeEditor() { document?.showEditorMode(nil) } + @objc private func menuModeSplit() { document?.showSplitMode(nil) } + @objc private func menuModeBoard() { document?.showBoardMode(nil) } + @objc private func menuTheme(_ s: NSMenuItem) { if let id = s.representedObject as? String { AppSettings.set(AppSettings.themeKey, id) } } + + @objc private func addImage(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .tiff, .gif, .heic, .image] + panel.allowsMultipleSelection = false + let complete: (NSApplication.ModalResponse) -> Void = { [weak self] resp in + guard resp == .OK, let url = panel.url, let self else { return } + self.document?.importImageToBoard(url: url, at: self.viewportCenterInWorld()) + } + if let w = window { panel.beginSheetModal(for: w, completionHandler: complete) } + else { complete(panel.runModal()) } + } + + @objc private func exportTapped() { + guard let data = boardPNG() else { onStatus?("Couldn't render board"); return } + let panel = NSSavePanel() + panel.nameFieldStringValue = "board.png" + panel.allowedContentTypes = [.png] + let complete: (NSApplication.ModalResponse) -> Void = { resp in + guard resp == .OK, let url = panel.url else { return } + do { try data.write(to: url); self.onStatus?("Exported \(url.lastPathComponent)") } + catch { self.onStatus?("Export failed: \(error.localizedDescription)") } + } + if let w = window { panel.beginSheetModal(for: w, completionHandler: complete) } + else { complete(panel.runModal()) } + } + @objc private func sendTapped() { + guard let data = boardPNG() else { onStatus?("Couldn't render board"); return } + onSendToTailnet?(data) + } + + /// Render the content region of the board to PNG at 1x (not the zoomed view, + /// not the whole 8000×6000 world). + func boardPNG() -> Data? { + boardView.unfocusCard(commit: true) + let region = boardView.contentBounds().insetBy(dx: -20, dy: -20).integral + guard region.width > 1, region.height > 1, + let rep = boardView.bitmapImageRepForCachingDisplay(in: region) else { return nil } + boardView.cacheDisplay(in: region, to: rep) + return rep.representation(using: .png, properties: [:]) + } + + private func viewportCenterInWorld() -> NSPoint { + let v = boardScroll.contentView.bounds + return NSPoint(x: v.midX, y: v.midY) + } + + func apply(theme: EditorTheme) { + self.theme = theme + palette.layer?.backgroundColor = theme.gutterBackground.cgColor + boardScroll.backgroundColor = theme.isDark ? EditorTheme.rgb(26, 28, 34) : EditorTheme.rgb(228, 229, 234) + boardScroll.drawsBackground = true + boardView.apply(theme: theme) + selectTool(tool) + refreshPageControls() + } +} diff --git a/Sources/Document.swift b/Sources/Document.swift new file mode 100644 index 0000000..bb01686 --- /dev/null +++ b/Sources/Document.swift @@ -0,0 +1,556 @@ +import AppKit + +final class Document: NSDocument, NSTextViewDelegate { + private var text = "" + private weak var textView: NSTextView? + private(set) var workspace: WorkspaceController? + // Carries "the user picked Sync Now" intent across the save that a dirty + // or untitled document needs before it can sync. + private var pendingManualSync = false + // Lets a newer sync's status win over a slower, older one's completion. + private var syncGeneration = 0 + + override class var autosavesInPlace: Bool { false } + + // MARK: - Window + + override func makeWindowControllers() { + let scrollView = NSTextView.scrollableTextView() + guard let tv = scrollView.documentView as? NSTextView else { return } + configure(textView: tv) + tv.string = text + textView = tv + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 620), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.minSize = NSSize(width: 480, height: 320) + window.center() + + let workspace = WorkspaceController(document: self, editorScrollView: scrollView, textView: tv) + self.workspace = workspace + window.contentView = workspace.containerView + workspace.installToolbar(on: window) + + let controller = NSWindowController(window: window) + controller.shouldCascadeWindows = true + addWindowController(controller) + loadBoardSidecar() + window.makeFirstResponder(tv) + } + + private func configure(textView tv: NSTextView) { + TextViewConfig.harden(tv) + tv.usesFindBar = true + tv.isIncrementalSearchingEnabled = true + tv.font = AppSettings.editorFont + tv.textContainerInset = NSSize(width: 8, height: 10) + tv.delegate = self + } + + // Route the text view's undo stack through the document so the + // dirty dot and change count stay accurate automatically. + func undoManager(for view: NSTextView) -> UndoManager? { + undoManager + } + + // MARK: - Reading / writing + + override func data(ofType typeName: String) throws -> Data { + if let tv = textView { + text = tv.string + } + guard let data = text.data(using: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, + code: NSFileWriteInapplicableStringEncodingError, + userInfo: [NSLocalizedDescriptionKey: "Couldn't encode the text as UTF-8."]) + } + return data + } + + override func read(from data: Data, ofType typeName: String) throws { + if let utf8 = String(data: data, encoding: .utf8) { + text = utf8 + } else { + var converted: NSString? + var lossy: ObjCBool = false + let encoding = NSString.stringEncoding(for: data, + encodingOptions: nil, + convertedString: &converted, + usedLossyConversion: &lossy) + // Refuse lossy conversions: saving would silently destroy bytes. + guard encoding != 0, let converted, !lossy.boolValue else { + throw NSError(domain: NSCocoaErrorDomain, + code: NSFileReadInapplicableStringEncodingError, + userInfo: [NSLocalizedDescriptionKey: "Couldn't determine the file's text encoding."]) + } + text = converted as String + } + textView?.string = text + workspace?.refreshTextMetrics() // keep the status bar counts current on Revert + } + + // MARK: - Save hook → sync + + override func save(to url: URL, + ofType typeName: String, + for saveOperation: NSDocument.SaveOperationType, + completionHandler: @escaping (Error?) -> Void) { + textView?.breakUndoCoalescing() + super.save(to: url, ofType: typeName, for: saveOperation) { [weak self] error in + completionHandler(error) + guard let self else { return } + let manual = self.pendingManualSync + self.pendingManualSync = false + guard error == nil, + saveOperation == .saveOperation || saveOperation == .saveAsOperation else { return } + self.saveBoardSidecar() + self.syncAfterSave(url: url, manual: manual) + self.autoIngestAfterSave() + } + } + + @objc func syncNow(_ sender: Any?) { + if let url = fileURL, !isDocumentEdited { + syncAfterSave(url: url, manual: true) + return + } + // Dirty or untitled: save first; the save hook picks up the manual flag. + pendingManualSync = true + save(withDelegate: self, + didSave: #selector(document(_:didSave:contextInfo:)), + contextInfo: nil) + } + + // Clears the manual flag when the user cancels the Save panel (the save + // override never runs in that case, so the flag would leak into the next save). + @objc private func document(_ document: NSDocument, didSave: Bool, contextInfo: UnsafeMutableRawPointer?) { + if !didSave { pendingManualSync = false } + } + + private func syncAfterSave(url: URL, manual: Bool) { + guard SyncSettings.enabled || manual else { + syncStatus = "" // sync is off — don't leave a stale "Synced ✓" + refreshSubtitle() + return + } + guard SyncSettings.isConfigured else { + if manual { + presentSyncError("No remote configured. Set one in TextPlus → Settings…") + } else { + syncStatus = "Sync is on, but no remote is configured — see Settings" + refreshSubtitle() + } + return + } + let remoteHost = SyncSettings.remote + syncGeneration += 1 + let generation = syncGeneration + syncStatus = "Syncing to \(remoteHost)…" + refreshSubtitle() + SyncEngine.upload(fileURL: url) { [weak self] result in + guard let self, generation == self.syncGeneration else { return } + switch result { + case .success: + let time = Date().formatted(date: .omitted, time: .shortened) + self.syncStatus = "Synced to \(remoteHost) ✓ \(time)" + case .failure(let error): + let firstLine = error.message.split(separator: "\n").first.map(String.init) ?? "unknown error" + self.syncStatus = "Sync failed — \(firstLine)" + NSApp.requestUserAttention(.informationalRequest) + if manual { self.presentSyncError(error.message) } + } + self.refreshSubtitle() + } + } + + private var syncStatus = "" + private var dbStatus = "" + private var dbGeneration = 0 + private var ingestInFlight = false + + private func refreshSubtitle() { + let subtitle = [syncStatus, dbStatus].filter { !$0.isEmpty }.joined(separator: " · ") + windowControllers.first?.window?.subtitle = subtitle + } + + private func presentSyncError(_ message: String) { + let alert = NSAlert() + alert.messageText = "Sync Failed" + alert.informativeText = message + alert.alertStyle = .warning + if let window = windowControllers.first?.window { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + + // MARK: - HTML parsing tools + + private var currentText: String { + textView?.string ?? text + } + + private static let lastSelectorKey = "lastExtractSelector" + + @objc func listSelectors(_ sender: Any?) { + let data = HTMLScanner.summarize(html: currentText) + Document.openResults(HTMLScanner.formatSummary(data, sourceName: displayName ?? "document")) + } + + @objc func extractListings(_ sender: Any?) { + let page = PageDetector.detect(in: currentText) ?? DetectedPage(url: nil, html: currentText) + let profile = ScrapeProfile.matching(url: page.url) ?? ScrapeProfile.discogsMarketplace + guard let extraction = HTMLScanner.extractRows(html: page.html, + rowSelector: profile.rowSelector, + fields: profile.fields), + !extraction.rows.isEmpty else { + presentSimpleAlert(title: "No Listings Found", + message: "No \(profile.rowSelector) rows in this document. This works on Discogs marketplace pages (sell/list or a seller's profile).") + return + } + var out = extraction.header.joined(separator: "\t") + "\n" + out += extraction.rows.map { $0.joined(separator: "\t") }.joined(separator: "\n") + "\n" + Document.openResults(out) + } + + @objc func sendToPostgres(_ sender: Any?) { + guard let page = PageDetector.detect(in: currentText) else { + presentSimpleAlert(title: "Not an HTML Page", + message: "Paste inspector HTML — optionally with the page URL as the first line — and try again.") + return + } + runIngest(page: page, manual: true) + } + + private func autoIngestAfterSave() { + guard PGSettings.autoIngest, let page = PageDetector.detect(in: currentText) else { + dbStatus = "" + refreshSubtitle() + return + } + runIngest(page: page, manual: false) + } + + private func runIngest(page: DetectedPage, manual: Bool) { + // One ingest per document at a time: a second overlapping ingest of the + // same page would bypass the content-hash dedup (its check runs before + // the first commits) and double-insert audit rows. + if ingestInFlight { + if manual { + presentSimpleAlert(title: "Ingest In Progress", + message: "This document is already being sent to Postgres. Try again once it finishes.") + } + return + } + ingestInFlight = true + dbGeneration += 1 + let generation = dbGeneration + dbStatus = "DB: parsing…" + refreshSubtitle() + let sourceName = displayName ?? "document" + + func finish(_ status: String, alert: String? = nil) { + ingestInFlight = false + guard generation == dbGeneration else { return } + dbStatus = status + refreshSubtitle() + if let alert { presentSimpleAlert(title: "Postgres Ingest Failed", message: alert) } + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let summary = HTMLScanner.summarize(html: page.html) + let profile = ScrapeProfile.matching(url: page.url) + var header: [String] = [] + var rows: [[String]] = [] + if let profile, + let extraction = HTMLScanner.extractRows(html: page.html, + rowSelector: profile.rowSelector, + fields: profile.fields) { + header = extraction.header + rows = extraction.rows + } + let ingest = PostgresStore.PageIngest( + url: page.url, + sourceName: sourceName, + contentHash: PostgresStore.sha256(page.html), + elementCount: summary.elementCount, + selectors: summary.entries, + profileName: rows.isEmpty ? nil : profile?.name, + header: header, + rows: rows + ) + DispatchQueue.main.async { + guard let self else { return } + guard generation == self.dbGeneration else { self.ingestInFlight = false; return } + self.dbStatus = "DB: ingesting…" + self.refreshSubtitle() + // `finish` already holds self for the duration of the ingest. + PostgresStore.ingest(ingest, force: manual) { result in + switch result { + case .success(let report): + if report.skippedDuplicate { + finish("DB: unchanged") + } else { + var bits = ["\(report.selectorCount) selectors"] + if report.snapshotCount > 0 { bits.append("\(report.snapshotCount) listings upserted") } + else if report.rowCount > 0 { bits.append("\(report.rowCount) rows") } + if report.skippedRows > 0 { bits.append("\(report.skippedRows) skipped") } + finish("DB ✓ page #\(report.pageId): " + bits.joined(separator: ", ")) + } + case .failure(let error): + let firstLine = error.message.split(separator: "\n").first.map(String.init) ?? "error" + finish("DB failed — \(firstLine)", alert: manual ? error.message : nil) + } + } + } + } + } + + @objc func extractForSelector(_ sender: Any?) { + let alert = NSAlert() + alert.messageText = "Extract Text for Selector" + alert.informativeText = "Pulls the text of every matching element into a new document, one match per line (table cells tab-separated). A trailing @attr adds that attribute as the first column; [attr], [!attr], [attr=v], [attr^=v] filter matches.\n\nExamples: .price tr.shortcut_navigable a.item_description_title@href .item_condition span[!class]" + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 380, height: 24)) + field.stringValue = UserDefaults.standard.string(forKey: Document.lastSelectorKey) ?? "" + field.placeholderString = "tag, .class, #id, or a descendant chain" + alert.accessoryView = field + alert.addButton(withTitle: "Extract") + alert.addButton(withTitle: "Cancel") + alert.window.initialFirstResponder = field + // The field editor swallows Return before the default button sees it; + // wire the field's action to the Extract button so Return submits. + field.target = alert.buttons[0] + field.action = #selector(NSButton.performClick(_:)) + + let runExtract: () -> Void = { [weak self] in + guard let self else { return } + let selector = field.stringValue.trimmingCharacters(in: .whitespaces) + guard !selector.isEmpty else { return } + UserDefaults.standard.set(selector, forKey: Document.lastSelectorKey) + guard let result = HTMLScanner.extractText(html: self.currentText, + selector: selector, + sourceName: self.displayName ?? "document") else { + self.presentSimpleAlert(title: "Bad Selector", + message: "Couldn't parse \"\(selector)\". Use tag, .class, #id, combos like tr.shortcut_navigable, or a space-separated descendant chain.") + return + } + guard result.matchCount > 0 else { + self.presentSimpleAlert(title: "No Matches", + message: "Nothing matched \"\(selector)\". Run Tools → List Div & Class Selectors to see what's in this page.") + return + } + Document.openResults(result.body) + } + + if let window = windowControllers.first?.window { + alert.beginSheetModal(for: window) { response in + guard response == .alertFirstButtonReturn else { return } + runExtract() + } + } else if alert.runModal() == .alertFirstButtonReturn { + runExtract() + } + } + + private static func openResults(_ content: String) { + guard let doc = (try? NSDocumentController.shared.openUntitledDocumentAndDisplay(true)) as? Document else { return } + doc.setContent(content) + } + + private func setContent(_ s: String) { + text = s + textView?.string = s + workspace?.applyAppearance() // theme the freshly inserted results + workspace?.refreshTextMetrics() + updateChangeCount(.changeDone) + } + + private func presentSimpleAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + if let window = windowControllers.first?.window { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + + // MARK: - Font size (broadcast to every open window via AppSettings) + + @objc func makeTextBigger(_ sender: Any?) { + AppSettings.set(AppSettings.fontSizeKey, Double(min(48, AppSettings.fontSize + 1))) + } + + @objc func makeTextSmaller(_ sender: Any?) { + AppSettings.set(AppSettings.fontSizeKey, Double(max(8, AppSettings.fontSize - 1))) + } + + // MARK: - View mode (menu) + appearance toggles + + @objc func showEditorMode(_ sender: Any?) { workspace?.setMode(.editor) } + @objc func showSplitMode(_ sender: Any?) { workspace?.setMode(.split) } + @objc func showBoardMode(_ sender: Any?) { workspace?.setMode(.board) } + + @objc func toggleSoftWrap(_ sender: Any?) { + AppSettings.set(AppSettings.softWrapKey, !AppSettings.softWrap) + } + + @objc func toggleLineNumbers(_ sender: Any?) { + AppSettings.set(AppSettings.lineNumbersKey, !AppSettings.lineNumbers) + } + + @objc func cycleTheme(_ sender: Any?) { + let all = EditorTheme.all + let idx = all.firstIndex { $0.id == AppSettings.theme.id } ?? 0 + AppSettings.set(AppSettings.themeKey, all[(idx + 1) % all.count].id) + } + + // MARK: - Markdown markup (selection-aware) + + @objc func markupBold(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "**", suffix: "**", placeholder: "bold") } } + @objc func markupItalic(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "*", suffix: "*", placeholder: "italic") } } + @objc func markupCode(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "`", suffix: "`", placeholder: "code") } } + @objc func markupStrikethrough(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "~~", suffix: "~~", placeholder: "strike") } } + @objc func markupHeading(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "# ") } } + @objc func markupList(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "- ") } } + @objc func markupQuote(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "> ") } } + @objc func markupLink(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "[", suffix: "](https://)", placeholder: "label") } } + + private func withTextView(_ body: (NSTextView) -> Void) { + guard let textView else { return } + body(textView) + } + + // MARK: - Board sidecar (foo.txt.board/) + image import + + /// Bundle directory next to the document: foo.txt → foo.txt.board/ + private func boardSidecarDir() -> URL? { + fileURL?.appendingPathExtension("board") + } + + private var legacyCanvasURL: URL? { + fileURL?.appendingPathExtension("canvas.json") + } + + /// Build the board model: from the sidecar if present, else migrate a legacy + /// canvas sidecar's shapes, else a fresh default board. Never throws. + func loadBoardSidecar() { + guard let board = workspace?.boardContainer.boardView else { return } + var doc: BoardDoc? + if let dir = boardSidecarDir(), + let data = try? Data(contentsOf: dir.appendingPathComponent("board.json")), + let decoded = try? JSONDecoder().decode(BoardDoc.self, from: data) { + doc = decoded + } else if let legacy = legacyCanvasURL, + let data = try? Data(contentsOf: legacy), + let canvasDoc = try? JSONDecoder().decode(DrawCanvasView.CanvasDoc.self, from: data) { + // One-way migration of the old paint canvas into the board shapes layer. + var fresh = BoardDoc.makeDefault(worldSize: BoardView.worldSize) + fresh.shapes = canvasDoc + doc = fresh + } + let finalDoc = doc ?? BoardDoc.makeDefault(worldSize: BoardView.worldSize) + board.load(doc: finalDoc) + workspace?.boardContainer.refreshPageControls() + workspace?.bindInitialEditorPage() // main card owns the document text view + // Load image assets by card id. + if let dir = boardSidecarDir() { + for card in finalDoc.cards where card.kind == .image { + let name = card.imageFile ?? "img-\(card.id).png" + if let img = NSImage(contentsOf: dir.appendingPathComponent(name)) { + board.setImage(img, forCardID: card.id) + } + } + } + } + + private func saveBoardSidecar() { + guard let board = workspace?.boardContainer.boardView, let dir = boardSidecarDir() else { return } + let doc = board.snapshotDoc + let fm = FileManager.default + // Don't create a sidecar for an untouched plaintext doc, but ALWAYS keep + // an existing one current — otherwise clearing a board back to empty + // would leave a stale board.json that resurrects the deleted objects. + let used = doc.cards.count > 1 || !doc.shapes.elements.isEmpty + || !(board.mainCardView?.textView?.string.isEmpty ?? true) + guard used || fm.fileExists(atPath: dir.path) else { return } + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + + if let data = try? JSONEncoder.boardEncoder.encode(doc) { + let tmp = dir.appendingPathComponent("board.json.tmp") + let dest = dir.appendingPathComponent("board.json") + if (try? data.write(to: tmp)) != nil { + _ = try? fm.replaceItemAt(dest, withItemAt: tmp) + } + } + // Write image assets; collect kept filenames for orphan GC. + var kept = Set(["board.json", "preview.png"]) + for card in doc.cards where card.kind == .image { + let name = card.imageFile ?? "img-\(card.id).png" + kept.insert(name) + if let img = board.image(forCardID: card.id), + let png = pngData(from: img) { + try? png.write(to: dir.appendingPathComponent(name), options: .atomic) + } + } + if let preview = workspace?.boardContainer.boardPNG() { + try? preview.write(to: dir.appendingPathComponent("preview.png"), options: .atomic) + } + // GC orphaned image files for this document's bundle only. + if let names = try? fm.contentsOfDirectory(atPath: dir.path) { + for n in names where n.hasPrefix("img-") && !kept.contains(n) { + try? fm.removeItem(at: dir.appendingPathComponent(n)) + } + } + } + + /// Import an image file as a new board card (re-encoded to PNG on save). + func importImageToBoard(url: URL, at center: NSPoint) { + guard let image = NSImage(contentsOf: url), let board = workspace?.boardContainer.boardView else { return } + board.addImageCard(image, at: center) + } + + func importImageToBoard(_ image: NSImage, at center: NSPoint) { + workspace?.boardContainer.boardView.addImageCard(image, at: center) + } + + private func pngData(from image: NSImage) -> Data? { + guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil } + return rep.representation(using: .png, properties: [:]) + } + + /// Write the PNG to a temp file and reuse the scp sync engine to push it to + /// the configured tailnet Mac — same destination as text files. + func sendSketchToTailnet(pngData: Data) { + guard SyncSettings.isConfigured else { + workspace?.setStatus("Set a tailnet remote in Settings first") + return + } + let base = (displayName ?? "sketch").replacingOccurrences(of: " ", with: "_") + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("\(base)-sketch.png") + do { + try pngData.write(to: tmp) + } catch { + workspace?.setStatus("Couldn't stage PNG: \(error.localizedDescription)") + return + } + workspace?.setStatus("Sending sketch to \(SyncSettings.remote)…") + SyncEngine.upload(fileURL: tmp) { [weak self] result in + switch result { + case .success: + self?.workspace?.setStatus("Sketch sent to \(SyncSettings.remote) ✓") + case .failure(let error): + let line = error.message.split(separator: "\n").first.map(String.init) ?? "failed" + self?.workspace?.setStatus("Sketch sync failed — \(line)") + } + try? FileManager.default.removeItem(at: tmp) + } + } +} diff --git a/Sources/DrawingCanvas.swift b/Sources/DrawingCanvas.swift new file mode 100644 index 0000000..57a0d11 --- /dev/null +++ b/Sources/DrawingCanvas.swift @@ -0,0 +1,395 @@ +import AppKit + +enum DrawTool: String, CaseIterable { + case pen, marker, line, arrow, rect, oval, text, eraser + + var symbol: String { + switch self { + case .pen: return "pencil.tip" + case .marker: return "highlighter" + case .line: return "line.diagonal" + case .arrow: return "arrow.up.right" + case .rect: return "rectangle" + case .oval: return "oval" + case .text: return "textformat" + case .eraser: return "eraser" + } + } + + var label: String { rawValue.capitalized } +} + +/// One drawn element. Freehand tools keep a point list; shapes keep two corner +/// points; text keeps an origin + string. Stored as plain values so the whole +/// canvas serializes to JSON for an editable sidecar. +struct DrawElement: Codable { + var tool: String + var colorHex: String + var alpha: CGFloat + var lineWidth: CGFloat + var filled: Bool + var points: [CGPoint] + var text: String + + var drawTool: DrawTool { DrawTool(rawValue: tool) ?? .pen } + var color: NSColor { NSColor(hex: colorHex)?.withAlphaComponent(alpha) ?? .black } +} + +/// The drawing surface: an unflattened vector model rendered each frame, with +/// per-element undo/redo and PNG/JSON export. Tool/color/width are driven by +/// the palette to its left. +final class DrawCanvasView: NSView { + var tool: DrawTool = .pen + var strokeColor: NSColor = .systemRed + var fillEnabled = false + var lineWidth: CGFloat = 3 + var canvasBackground: NSColor = .white { didSet { needsDisplay = true } } + /// When false, the view is transparent to mouse events (used as the board's + /// bottom shapes layer so card drags win unless a draw tool is active). + var hitTestEnabled = true + + override func hitTest(_ point: NSPoint) -> NSView? { + hitTestEnabled ? super.hitTest(point) : nil + } + + private(set) var elements: [DrawElement] = [] + private var current: DrawElement? + private let undoMgr = UndoManager() + + /// Called whenever the model changes, so the host can mark the sidecar dirty. + var onChange: (() -> Void)? + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + override var undoManager: UndoManager? { undoMgr } + + // MARK: - Rendering + + override func draw(_ dirtyRect: NSRect) { + canvasBackground.setFill() + bounds.fill() + for element in elements { render(element) } + if let current { render(current) } + } + + private func render(_ e: DrawElement) { + let color = e.color + color.setStroke() + color.setFill() + let path = NSBezierPath() + path.lineWidth = e.lineWidth + path.lineCapStyle = .round + path.lineJoinStyle = .round + + switch e.drawTool { + case .pen, .marker, .eraser: + guard let first = e.points.first else { return } + path.move(to: first) + for p in e.points.dropFirst() { path.line(to: p) } + if e.drawTool == .marker { color.withAlphaComponent(min(e.alpha, 0.4)).setStroke() } + path.stroke() + case .line: + guard e.points.count >= 2 else { return } + path.move(to: e.points[0]); path.line(to: e.points[1]); path.stroke() + case .arrow: + guard e.points.count >= 2 else { return } + drawArrow(from: e.points[0], to: e.points[1], width: e.lineWidth, color: color) + case .rect: + guard e.points.count >= 2 else { return } + let r = NSRect(corner: e.points[0], to: e.points[1]) + let rp = NSBezierPath(rect: r); rp.lineWidth = e.lineWidth + if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); rp.fill() } + color.setStroke(); rp.stroke() + case .oval: + guard e.points.count >= 2 else { return } + let r = NSRect(corner: e.points[0], to: e.points[1]) + let op = NSBezierPath(ovalIn: r); op.lineWidth = e.lineWidth + if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); op.fill() } + color.setStroke(); op.stroke() + case .text: + guard let origin = e.points.first else { return } + let font = NSFont.systemFont(ofSize: max(11, e.lineWidth * 5)) + (e.text as NSString).draw(at: origin, withAttributes: [.font: font, .foregroundColor: color]) + } + } + + private func drawArrow(from: NSPoint, to: NSPoint, width: CGFloat, color: NSColor) { + let shaft = NSBezierPath() + shaft.lineWidth = width + shaft.lineCapStyle = .round + shaft.move(to: from); shaft.line(to: to) + color.setStroke(); shaft.stroke() + + let angle = atan2(to.y - from.y, to.x - from.x) + let headLen = max(10, width * 4) + let spread = CGFloat.pi / 7 + let head = NSBezierPath() + head.move(to: to) + head.line(to: NSPoint(x: to.x - headLen * cos(angle - spread), y: to.y - headLen * sin(angle - spread))) + head.move(to: to) + head.line(to: NSPoint(x: to.x - headLen * cos(angle + spread), y: to.y - headLen * sin(angle + spread))) + head.lineWidth = width + head.lineCapStyle = .round + head.stroke() + } + + // MARK: - Mouse → drawing + + override func mouseDown(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + if tool == .text { + beginTextEntry(at: p) + return + } + if tool == .eraser { + eraseHits(at: p) + return + } + current = DrawElement( + tool: tool.rawValue, + colorHex: strokeColor.hexString, + alpha: strokeColor.alphaComponent, + lineWidth: lineWidth, + filled: fillEnabled, + points: [p], + text: "" + ) + needsDisplay = true + } + + override func mouseDragged(with event: NSEvent) { + guard current != nil else { return } + let p = convert(event.locationInWindow, from: nil) + switch tool { + case .pen, .marker: + current?.points.append(p) + case .eraser: + eraseHits(at: p) + return + default: + if current!.points.count < 2 { current?.points.append(p) } else { current?.points[1] = p } + } + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + guard let element = current else { return } + current = nil + // Ignore a stray click that produced no real shape. + if element.drawTool != .text, element.points.count < 2, + element.drawTool != .pen, element.drawTool != .marker { + needsDisplay = true + return + } + commit(element) + } + + // MARK: - Model mutation (undoable) + + private func commit(_ element: DrawElement) { + elements.append(element) + undoMgr.registerUndo(withTarget: self) { canvas in + canvas.removeLast(restoring: element) + } + undoMgr.setActionName("Draw \(element.drawTool.label)") + needsDisplay = true + onChange?() + } + + private func removeLast(restoring element: DrawElement) { + guard !elements.isEmpty else { return } + let removed = elements.removeLast() + undoMgr.registerUndo(withTarget: self) { canvas in + canvas.commit(removed) + } + needsDisplay = true + onChange?() + } + + private func eraseHits(at p: NSPoint) { + let hitRadius = max(8, lineWidth * 3) + let survivors = elements.filter { !$0.hits(point: p, within: hitRadius) } + guard survivors.count != elements.count else { return } + let before = elements + elements = survivors + undoMgr.registerUndo(withTarget: self) { canvas in + canvas.restore(before) + } + undoMgr.setActionName("Erase") + needsDisplay = true + onChange?() + } + + private func restore(_ snapshot: [DrawElement]) { + let before = elements + elements = snapshot + undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) } + needsDisplay = true + onChange?() + } + + private func beginTextEntry(at p: NSPoint) { + let field = NSTextField(frame: NSRect(x: p.x, y: p.y, width: 200, height: 24)) + field.font = NSFont.systemFont(ofSize: max(11, lineWidth * 5)) + field.textColor = strokeColor + field.backgroundColor = .clear + field.isBordered = false + field.focusRingType = .none + field.placeholderString = "type, ⏎" + field.target = self + field.action = #selector(commitTextField(_:)) + field.tag = 0 + addSubview(field) + window?.makeFirstResponder(field) + textEntryOrigin = p + activeTextField = field + } + + private var activeTextField: NSTextField? + private var textEntryOrigin: NSPoint = .zero + + @objc private func commitTextField(_ sender: NSTextField) { + let value = sender.stringValue + sender.removeFromSuperview() + activeTextField = nil + guard !value.isEmpty else { return } + var element = DrawElement(tool: DrawTool.text.rawValue, + colorHex: strokeColor.hexString, alpha: strokeColor.alphaComponent, + lineWidth: lineWidth, filled: false, + points: [textEntryOrigin], text: value) + // Nudge baseline so text sits where the click landed. + element.points = [NSPoint(x: textEntryOrigin.x, y: textEntryOrigin.y)] + commit(element) + } + + // MARK: - Commands + + func clearAll() { + guard !elements.isEmpty else { return } + let before = elements + elements = [] + undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) } + undoMgr.setActionName("Clear Canvas") + needsDisplay = true + onChange?() + } + + func undo() { if undoMgr.canUndo { undoMgr.undo() } } + func redo() { if undoMgr.canRedo { undoMgr.redo() } } + + // MARK: - Export / persistence + + func pngData() -> Data? { + guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } + cacheDisplay(in: bounds, to: rep) + return rep.representation(using: .png, properties: [:]) + } + + struct CanvasDoc: Codable { + var backgroundHex: String + var elements: [DrawElement] + var width: CGFloat + var height: CGFloat + } + + func serialized() -> Data? { + let doc = CanvasDoc(backgroundHex: canvasBackground.hexString, elements: elements, + width: bounds.width, height: bounds.height) + return try? JSONEncoder().encode(doc) + } + + func load(from data: Data) { + guard let doc = try? JSONDecoder().decode(CanvasDoc.self, from: data) else { return } + if let bg = NSColor(hex: doc.backgroundHex) { canvasBackground = bg } + elements = doc.elements + undoMgr.removeAllActions() + needsDisplay = true + } + + var isEmpty: Bool { elements.isEmpty } + + /// Bounding rect of all drawn elements (nil if empty), for fit/export. + func contentBounds() -> NSRect? { + var rect: NSRect? + for e in elements { + for p in e.points { + let r = NSRect(x: p.x, y: p.y, width: 0, height: 0) + rect = rect?.union(r) ?? r + } + } + return rect?.insetBy(dx: -12, dy: -12) + } +} + +// MARK: - Geometry helpers + +private extension DrawElement { + func hits(point p: NSPoint, within r: CGFloat) -> Bool { + switch drawTool { + case .pen, .marker, .eraser, .line, .arrow: + for i in 0..= 2 else { return false } + return NSRect(corner: points[0], to: points[1]).insetBy(dx: -r, dy: -r).contains(p) + case .text: + guard let o = points.first else { return false } + return NSRect(x: o.x - r, y: o.y - r, width: 200, height: 28).contains(p) + } + } + + func distance(_ p: NSPoint, toSegment a: NSPoint, _ b: NSPoint) -> CGFloat { + let dx = b.x - a.x, dy = b.y - a.y + let lenSq = dx * dx + dy * dy + if lenSq == 0 { return p.distance(to: a) } + var t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq + t = max(0, min(1, t)) + return p.distance(to: NSPoint(x: a.x + t * dx, y: a.y + t * dy)) + } +} + +extension NSPoint { + func distance(to o: NSPoint) -> CGFloat { hypot(x - o.x, y - o.y) } +} + +extension NSRect { + init(corner a: NSPoint, to b: NSPoint) { + self.init(x: min(a.x, b.x), y: min(a.y, b.y), width: abs(a.x - b.x), height: abs(a.y - b.y)) + } +} + +// MARK: - Color hex + +extension NSColor { + /// "#RRGGBB" when opaque, "#RRGGBBAA" when translucent (so box/shape alpha + /// survives a save). + var hexString: String { + guard let c = usingColorSpace(.sRGB) else { return "#000000" } + let r = Int(round(c.redComponent * 255)) + let g = Int(round(c.greenComponent * 255)) + let b = Int(round(c.blueComponent * 255)) + let a = Int(round(c.alphaComponent * 255)) + return a >= 255 ? String(format: "#%02X%02X%02X", r, g, b) + : String(format: "#%02X%02X%02X%02X", r, g, b, a) + } + + convenience init?(hex: String) { + var s = hex.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("#") { s.removeFirst() } + if s.count == 6, let v = UInt32(s, radix: 16) { + self.init(srgbRed: CGFloat((v >> 16) & 0xFF) / 255, + green: CGFloat((v >> 8) & 0xFF) / 255, + blue: CGFloat(v & 0xFF) / 255, alpha: 1) + } else if s.count == 8, let v = UInt32(s, radix: 16) { + self.init(srgbRed: CGFloat((v >> 24) & 0xFF) / 255, + green: CGFloat((v >> 16) & 0xFF) / 255, + blue: CGFloat((v >> 8) & 0xFF) / 255, + alpha: CGFloat(v & 0xFF) / 255) + } else { + return nil + } + } +} diff --git a/Sources/HTMLScanner.swift b/Sources/HTMLScanner.swift new file mode 100644 index 0000000..6492869 --- /dev/null +++ b/Sources/HTMLScanner.swift @@ -0,0 +1,739 @@ +import Foundation + +/// Lightweight streaming HTML tokenizer + selector tools for inspector-copied +/// markup. Not a spec HTML parser — built for "paste from devtools, +/// find the repeating rows, pull their data" scraping workflows. +/// +/// Selector syntax: `tag`, `#id`, `.class`, compounds (`tr.shortcut_navigable`), +/// attribute filters (`[data-id]`, `[!class]`, `[rel=tag]`, `[href^=/seller/]`), +/// space-separated descendant chains, and a trailing `@attr` to extract an +/// attribute value from the matched element. +enum HTMLScanner { + + // MARK: - Public: selector summary + + struct SummaryData { + let entries: [(selector: String, count: Int, sample: String)] + let elementCount: Int + } + + static func summarize(html: String) -> SummaryData { + var counts: [String: Int] = [:] + var samples: [String: String] = [:] + + struct Entry { + let tag: String + let key: String? + var sampling: Bool + var buf: String + } + var stack: [Entry] = [] + var totalElements = 0 + + func finalize(_ entry: Entry) { + guard let key = entry.key, entry.sampling else { return } + if samples[key, default: ""].isEmpty { + samples[key] = String(entry.buf.prefix(90)) + } + } + + tokenize(html, openTag: { tag, attrs, closed in + let id = normalizedID(attrs) + let classes = splitClasses(attrs["class"]) + var key: String? + if id != nil || !classes.isEmpty { + key = selectorString(tag: tag, id: id, classes: classes) + counts[key!, default: 0] += 1 + totalElements += 1 + } + if closed { return } + // Keep sampling until a NON-empty sample lands, so a first + // occurrence that wraps only an doesn't lose the sample. + let sampling = key.map { samples[$0, default: ""].isEmpty } ?? false + stack.append(Entry(tag: tag, key: key, sampling: sampling, buf: "")) + }, closeTag: { tag in + guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } + for entry in stack[matchIdx...].reversed() { finalize(entry) } + stack.removeSubrange(matchIdx...) + }, text: { raw in + let collapsed = collapseWhitespace(decodeEntities(raw)) + guard !collapsed.isEmpty else { return } + for i in stack.indices where stack[i].sampling && stack[i].buf.count < 90 { + stack[i].buf += (stack[i].buf.isEmpty ? "" : " ") + collapsed + } + }) + for entry in stack.reversed() { finalize(entry) } + + let sorted = counts.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key } + return SummaryData( + entries: sorted.map { (selector: $0.key, count: $0.value, sample: samples[$0.key] ?? "") }, + elementCount: totalElements + ) + } + + static func formatSummary(_ data: SummaryData, sourceName: String) -> String { + var out = "Selector summary of \(sourceName)\n" + out += "\(data.elementCount) elements with an id or class, \(data.entries.count) unique selectors.\n" + out += "Repeating rows (your data) sort to the top. Next: Tools → Extract Text for Selector…\n\n" + for entry in data.entries { + out += String(format: "%5d× %@\n", entry.count, entry.selector) + if !entry.sample.isEmpty { + out += " ↳ \(entry.sample)\n" + } + } + return out + } + + // MARK: - Public: extract text for a selector + + struct ExtractResult { + let matchCount: Int + let body: String + } + + /// Returns nil if the selector string can't be parsed. A trailing `@attr` + /// prepends that attribute's value as a tab-separated first column. + static func extractText(html: String, selector: String, sourceName: String) -> ExtractResult? { + guard let compounds = parseSelector(selector) else { return nil } + let extractAttr = compounds.last?.extractAttr + + struct Entry { + let tag: String + let id: String? + let classes: Set + let attrs: [String: String] + var isCaptureRoot: Bool + } + var stack: [Entry] = [] + var capturing = false + var buf = "" + var rootAttrValue = "" + var records: [String] = [] + + func selectorMatches() -> Bool { + matchChain(compounds, stack: stack.map { ($0.tag, $0.id, $0.classes, $0.attrs) }, scopeStart: 0) + } + + func attrValue(_ attrs: [String: String]) -> String { + extractAttr.flatMap { attrs[$0] } ?? "" + } + + func finishRecord() { + var record = buf.trimmingCharacters(in: CharacterSet(charactersIn: " \t")) + if extractAttr != nil { + record = rootAttrValue + "\t" + record + } + records.append(record) + buf = "" + rootAttrValue = "" + capturing = false + } + + tokenize(html, openTag: { tag, attrs, closed in + let entry = Entry(tag: tag, id: normalizedID(attrs), + classes: Set(splitClasses(attrs["class"])), + attrs: attrs, isCaptureRoot: false) + if closed { + if !capturing { + stack.append(entry) + if selectorMatches() { + records.append(extractAttr != nil ? attrValue(attrs) + "\t" : "") + } + stack.removeLast() + } + return + } + stack.append(entry) + if selectorMatches() { + if capturing { + // A new match opening mid-capture means the previous row's + // close tag was missing (raw source) or rows nest — either + // way, finish the old record so rows don't silently merge. + finishRecord() + if let oldRoot = stack.firstIndex(where: { $0.isCaptureRoot }) { + stack[oldRoot].isCaptureRoot = false + } + } + stack[stack.count - 1].isCaptureRoot = true + capturing = true + buf = "" + rootAttrValue = attrValue(attrs) + } + }, closeTag: { tag in + guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } + let popped = stack[matchIdx...] + stack.removeSubrange(matchIdx...) + if popped.contains(where: { $0.isCaptureRoot }) { + finishRecord() + } else if capturing, tag == "td" || tag == "th", + !buf.isEmpty, !buf.hasSuffix("\t") { + buf += "\t" // cell boundary → tab, so table rows come out TSV-ish + } + }, text: { raw in + guard capturing else { return } + let collapsed = collapseWhitespace(decodeEntities(raw)) + guard !collapsed.isEmpty else { return } + if !buf.isEmpty && !buf.hasSuffix("\t") { buf += " " } + buf += collapsed + }) + if capturing { finishRecord() } // unclosed capture at EOF + + var out = "# \(records.count) match\(records.count == 1 ? "" : "es") for `\(selector)` in \(sourceName)\n" + out += records.joined(separator: "\n") + if !records.isEmpty { out += "\n" } + return ExtractResult(matchCount: records.count, body: out) + } + + // MARK: - Public: structured row extraction (scrape profiles) + + struct RowExtraction { + let header: [String] + let rows: [[String]] + } + + /// For each element matching rowSelector, capture named fields by selector + /// scoped to that row's subtree (the row element itself included, so + /// "tr@data-release-id" reads an attribute off the row). First match per + /// field wins. Returns nil if any selector fails to parse. + static func extractRows(html: String, rowSelector: String, + fields: [(name: String, selector: String)]) -> RowExtraction? { + guard let rowCompounds = parseSelector(rowSelector) else { return nil } + struct FieldSpec { + let name: String + let compounds: [Compound] + let attr: String? + } + var specs: [FieldSpec] = [] + for field in fields { + guard let comps = parseSelector(field.selector) else { return nil } + specs.append(FieldSpec(name: field.name, compounds: comps, attr: comps.last?.extractAttr)) + } + + typealias StackEl = (tag: String, id: String?, classes: Set, attrs: [String: String]) + enum FieldState { + case pending + case capturing(rootIndex: Int, buf: String) + case done(String) + } + var stack: [StackEl] = [] + var inRow = false + var rowRootIndex = -1 + var fieldStates: [FieldState] = [] + var rows: [[String]] = [] + + func startRow() { + inRow = true + rowRootIndex = stack.count - 1 + fieldStates = Array(repeating: .pending, count: specs.count) + tryMatchFields(closedAttrs: nil) + } + + func finishRow() { + let columns = fieldStates.map { state -> String in + switch state { + case .pending: return "" + case .capturing(_, let buf): return buf.trimmingCharacters(in: .whitespaces) + case .done(let value): return value.trimmingCharacters(in: .whitespaces) + } + } + rows.append(columns) + inRow = false + rowRootIndex = -1 + fieldStates = [] + } + + // closedAttrs non-nil = the just-seen element is void/self-closed and + // sits on top of the stack only transiently. + func tryMatchFields(closedAttrs: [String: String]?) { + guard inRow else { return } + for i in specs.indices { + guard case .pending = fieldStates[i] else { continue } + guard matchChain(specs[i].compounds, stack: stack, scopeStart: rowRootIndex) else { continue } + if let attr = specs[i].attr { + fieldStates[i] = .done(stack[stack.count - 1].attrs[attr] ?? "") + } else if closedAttrs != nil { + fieldStates[i] = .done("") // void element has no text + } else { + fieldStates[i] = .capturing(rootIndex: stack.count - 1, buf: "") + } + } + } + + tokenize(html, openTag: { tag, attrs, closed in + let el: StackEl = (tag, normalizedID(attrs), Set(splitClasses(attrs["class"])), attrs) + if closed { + stack.append(el) + if inRow { tryMatchFields(closedAttrs: attrs) } + stack.removeLast() + return + } + stack.append(el) + if matchChain(rowCompounds, stack: stack, scopeStart: 0) { + if inRow { finishRow() } // unclosed previous row + startRow() + } else if inRow { + tryMatchFields(closedAttrs: nil) + } + }, closeTag: { tag in + guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } + for i in fieldStates.indices { + if case .capturing(let root, let buf) = fieldStates[i], root >= matchIdx { + fieldStates[i] = .done(buf) + } + } + if inRow && rowRootIndex >= matchIdx { finishRow() } + stack.removeSubrange(matchIdx...) + }, text: { raw in + guard inRow else { return } + let collapsed = collapseWhitespace(decodeEntities(raw)) + guard !collapsed.isEmpty else { return } + for i in fieldStates.indices { + if case .capturing(let root, var buf) = fieldStates[i] { + if !buf.isEmpty { buf += " " } + buf += collapsed + fieldStates[i] = .capturing(rootIndex: root, buf: buf) + } + } + }) + if inRow { finishRow() } + + return RowExtraction(header: specs.map { $0.name }, rows: rows) + } + + /// True if the selector parses (for validating user/profile input). + static func isValidSelector(_ selector: String) -> Bool { + parseSelector(selector) != nil + } + + // MARK: - Selector parsing & matching + + private static func parseSelector(_ selector: String) -> [Compound]? { + let parts = selector.split(separator: " ").map(String.init) + guard !parts.isEmpty else { return nil } + var compounds: [Compound] = [] + for part in parts { + guard let c = Compound(token: part) else { return nil } + compounds.append(c) + } + // @attr extraction is only meaningful on the final compound + for c in compounds.dropLast() where c.extractAttr != nil { return nil } + return compounds + } + + /// Match the last compound against the top of the stack and earlier + /// compounds against ancestors in order, never looking below scopeStart. + private static func matchChain(_ compounds: [Compound], + stack: [(tag: String, id: String?, classes: Set, attrs: [String: String])], + scopeStart: Int) -> Bool { + guard let last = stack.last, + compounds[compounds.count - 1].matches(tag: last.tag, id: last.id, classes: last.classes, attrs: last.attrs) + else { return false } + var ci = compounds.count - 2 + var si = stack.count - 2 + while ci >= 0 { + while si >= scopeStart, + !compounds[ci].matches(tag: stack[si].tag, id: stack[si].id, classes: stack[si].classes, attrs: stack[si].attrs) { + si -= 1 + } + if si < scopeStart { return false } + ci -= 1 + si -= 1 + } + return true + } + + private struct AttrFilter { + enum Op { case present, absent, equals, prefix } + let name: String + let op: Op + let value: String + + init?(_ content: String) { + let s = content.trimmingCharacters(in: .whitespaces) + guard !s.isEmpty else { return nil } + if s.hasPrefix("!") { + let n = String(s.dropFirst()).trimmingCharacters(in: .whitespaces).lowercased() + guard !n.isEmpty else { return nil } + name = n; op = .absent; value = "" + } else if let r = s.range(of: "^=") { + let n = String(s[.. String { + let t = v.trimmingCharacters(in: .whitespaces) + if t.count >= 2, + (t.hasPrefix("\"") && t.hasSuffix("\"")) || (t.hasPrefix("'") && t.hasSuffix("'")) { + return String(t.dropFirst().dropLast()) + } + return t + } + + func matches(_ attrs: [String: String]) -> Bool { + switch op { + case .present: return attrs[name] != nil + case .absent: return attrs[name] == nil + case .equals: return attrs[name] == value + case .prefix: return attrs[name]?.hasPrefix(value) ?? false + } + } + } + + private struct Compound { + var tag: String? + var id: String? + var classes: [String] = [] + var filters: [AttrFilter] = [] + var extractAttr: String? + + init?(token: String) { + var mode: Character = "t" + var current = "" + + func commit() -> Bool { + guard !current.isEmpty else { return false } + switch mode { + case "t": + guard tag == nil else { return false } // "div[x]y" — two tags + let t = current.lowercased() + // Reject combinators/pseudo junk (">", "+", "td:nth-child(2)") + // so the "Bad Selector" path fires instead of 0 matches. + // ':' stays legal (namespaced tags); ids/classes aren't + // validated — real class values contain odd characters. + guard t == "*" || t.allSatisfy({ + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == ":" + }) else { return false } + tag = t + case "#": id = current + default: classes.append(current) + } + current = "" + return true + } + + let chars = Array(token) + var i = 0 + while i < chars.count { + let ch = chars[i] + if ch == "[" { + if !current.isEmpty { + guard commit() else { return nil } + } else if mode != "t" { + return nil // ".[x]" + } + mode = "t" + guard let close = chars[i...].firstIndex(of: "]") else { return nil } + guard let filter = AttrFilter(String(chars[(i + 1).., attrs: [String: String]) -> Bool { + if let tag, tag != t { return false } + if let id, id != i { return false } + for c in classes where !cs.contains(c) { return false } + for f in filters where !f.matches(attrs) { return false } + return true + } + } + + // MARK: - Tokenizer + + private static let voidTags: Set = [ + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr", + ] + private static let rawTextTags: Set = ["script", "style", "textarea", "title"] + // Tags that implicitly close a same-tag sibling (covers unclosed + //

  • //

    runs in raw page source; inspector copies are balanced). + private static let siblingClosingTags: Set = ["li", "tr", "td", "th", "p", "dt", "dd", "option"] + // Inline tags the sibling-close rule may look "through" — an unclosed + // between two

  • s must not defeat the implicit close. Bounded so + // legitimately nested lists (li inside ul inside li) stay nested. + private static let inlineTags: Set = [ + "a", "abbr", "b", "code", "em", "font", "i", "label", "mark", + "s", "small", "span", "strong", "sub", "sup", "time", "u", + ] + + /// Walks the markup once. openTag receives the tag name, all attributes + /// (lowercased names, entity-decoded values, first occurrence wins), and + /// whether the element can't have children (self-closed or void). + private static func tokenize(_ html: String, + openTag: (String, [String: String], Bool) -> Void, + closeTag: (String) -> Void, + text: (String) -> Void) { + let bytes = Array(html.utf8) + let n = bytes.count + let lt = UInt8(ascii: "<"), gt = UInt8(ascii: ">") + let slash = UInt8(ascii: "/"), bang = UInt8(ascii: "!") + let eq = UInt8(ascii: "="), dash = UInt8(ascii: "-") + let dq = UInt8(ascii: "\""), sq = UInt8(ascii: "'") + + func isSpace(_ b: UInt8) -> Bool { b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C } + func isAlpha(_ b: UInt8) -> Bool { (b | 0x20) >= 0x61 && (b | 0x20) <= 0x7A } + func isNameByte(_ b: UInt8) -> Bool { + isAlpha(b) || (b >= 0x30 && b <= 0x39) || b == dash || b == UInt8(ascii: "_") || b == UInt8(ascii: ":") + } + + var i = 0 + var textStart = 0 + var openStack: [String] = [] + + func flushText(upTo end: Int) { + if end > textStart { + text(String(decoding: bytes[textStart.. and close + // (their terminator overlaps the opening dashes). + var j = i + 2 + while j + 2 < n && !(bytes[j] == dash && bytes[j + 1] == dash && bytes[j + 2] == gt) { j += 1 } + i = min(n, j + 3) + } else { + var j = i + 2 + while j < n && bytes[j] != gt { j += 1 } + i = min(n, j + 1) + } + textStart = i + } else if next == slash { + flushText(upTo: i) + var j = i + 2 + var name = "" + while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 } + while j < n && bytes[j] != gt { j += 1 } + i = min(n, j + 1) + textStart = i + if !name.isEmpty { emitClose(name.lowercased()) } + } else if isAlpha(next) { + flushText(upTo: i) + var j = i + 1 + var name = "" + while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 } + let tag = name.lowercased() + + var attrs: [String: String] = [:] + var selfClosed = false + attrs: while j < n { + while j < n, isSpace(bytes[j]) { j += 1 } + if j >= n { break } + if bytes[j] == gt { j += 1; break } + if bytes[j] == slash { + if j + 1 < n, bytes[j + 1] == gt { selfClosed = true; j += 2; break } + j += 1 + continue + } + var attrName = "" + while j < n, !isSpace(bytes[j]), bytes[j] != eq, bytes[j] != gt, bytes[j] != slash { + attrName.append(Character(UnicodeScalar(bytes[j]))) + j += 1 + } + var value = "" + while j < n, isSpace(bytes[j]) { j += 1 } + if j < n, bytes[j] == eq { + j += 1 + while j < n, isSpace(bytes[j]) { j += 1 } + if j < n, bytes[j] == dq || bytes[j] == sq { + let quote = bytes[j] + j += 1 + let start = j + while j < n, bytes[j] != quote { j += 1 } + value = String(decoding: bytes[start..= n { break attrs } + } + i = j + textStart = i + + if siblingClosingTags.contains(tag) { + var idx = openStack.count - 1 + while idx >= 0, inlineTags.contains(openStack[idx]) { idx -= 1 } + if idx >= 0, openStack[idx] == tag { emitClose(tag) } + } + let childless = selfClosed || voidTags.contains(tag) + openTag(tag, attrs, childless) + if !childless && !rawTextTags.contains(tag) { + openStack.append(tag) + } + + if !childless && rawTextTags.contains(tag) { + // Skip raw content (scripts, styles) up to the real closing tag. + let needle = Array(": the spec requires + // '>', '/', or whitespace after the tag name. + let after = k + needle.count + if after < n { + let b = bytes[after] + if !(b == gt || b == slash || isSpace(b)) { k += 1; continue search } + } + break + } + var j2 = min(k, n) + while j2 < n && bytes[j2] != gt { j2 += 1 } + i = min(n, j2 + 1) + textStart = i + closeTag(tag) + } + } else { + i += 1 // stray '<' in text + } + } + flushText(upTo: n) + } + + // MARK: - Text helpers + + private static func normalizedID(_ attrs: [String: String]) -> String? { + guard let id = attrs["id"]?.trimmingCharacters(in: .whitespaces), !id.isEmpty else { return nil } + return id + } + + private static func splitClasses(_ value: String?) -> [String] { + guard let value else { return [] } + return value.split(whereSeparator: { $0.isWhitespace }).map(String.init) + } + + private static func selectorString(tag: String, id: String?, classes: [String]) -> String { + var s = tag + if let id, !id.isEmpty { s += "#\(id)" } + for c in classes { s += ".\(c)" } + return s + } + + private static func collapseWhitespace(_ s: String) -> String { + s.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + private static func decodeEntities(_ s: String) -> String { + guard s.contains("&") else { return s } + var result = "" + result.reserveCapacity(s.count) + var i = s.startIndex + while i < s.endIndex { + if s[i] == "&" { + var j = s.index(after: i) + var entity = "" + var terminated = false + var steps = 0 + while j < s.endIndex, steps < 12 { + if s[j] == ";" { terminated = true; break } + entity.append(s[j]) + j = s.index(after: j) + steps += 1 + } + if terminated, let decoded = decodeEntity(entity) { + result.append(decoded) + i = s.index(after: j) + continue + } + } + result.append(s[i]) + i = s.index(after: i) + } + return result + } + + private static func decodeEntity(_ e: String) -> Character? { + switch e { + case "amp": return "&" + case "lt": return "<" + case "gt": return ">" + case "quot": return "\"" + case "apos": return "'" + case "nbsp": return " " + case "ndash": return "–" + case "mdash": return "—" + case "hellip": return "…" + default: + if e.hasPrefix("#x") || e.hasPrefix("#X"), + let v = UInt32(e.dropFirst(2), radix: 16), let u = UnicodeScalar(v) { + return Character(u) + } + if e.hasPrefix("#"), let v = UInt32(e.dropFirst()), let u = UnicodeScalar(v) { + return Character(u) + } + return nil + } + } +} diff --git a/Sources/LineNumberRuler.swift b/Sources/LineNumberRuler.swift new file mode 100644 index 0000000..af67a00 --- /dev/null +++ b/Sources/LineNumberRuler.swift @@ -0,0 +1,148 @@ +import AppKit + +/// A line-number gutter for an NSTextView, themed to match the editor. +/// Highlights the line containing the insertion point. +final class LineNumberRulerView: NSRulerView { + private weak var textView: NSTextView? + private var theme: EditorTheme = .system + + init(textView: NSTextView) { + self.textView = textView + super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler) + clientView = textView + ruleThickness = 44 + + let center = NotificationCenter.default + center.addObserver(self, selector: #selector(needsRedraw), + name: NSText.didChangeNotification, object: textView) + center.addObserver(self, selector: #selector(needsRedraw), + name: NSTextView.didChangeSelectionNotification, object: textView) + if let clip = textView.enclosingScrollView?.contentView { + clip.postsBoundsChangedNotifications = true + center.addObserver(self, selector: #selector(needsRedraw), + name: NSView.boundsDidChangeNotification, object: clip) + } + } + + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { NotificationCenter.default.removeObserver(self) } + + func apply(theme: EditorTheme) { + self.theme = theme + needsDisplay = true + } + + /// Point the gutter at a different text view (used when the editor switches + /// which board page it is showing). + func retarget(to tv: NSTextView) { + NotificationCenter.default.removeObserver(self) + textView = tv + clientView = tv + let center = NotificationCenter.default + center.addObserver(self, selector: #selector(needsRedraw), + name: NSText.didChangeNotification, object: tv) + center.addObserver(self, selector: #selector(needsRedraw), + name: NSTextView.didChangeSelectionNotification, object: tv) + if let clip = tv.enclosingScrollView?.contentView { + clip.postsBoundsChangedNotifications = true + center.addObserver(self, selector: #selector(needsRedraw), + name: NSView.boundsDidChangeNotification, object: clip) + } + needsDisplay = true + } + + @objc private func needsRedraw() { needsDisplay = true } + + /// Widen the gutter as the line count grows so 4- and 5-digit files fit. + func updateThickness() { + guard let lineCount = textView?.string.reduce(into: 1, { c, ch in if ch == "\n" { c += 1 } }) else { return } + let digits = max(2, String(lineCount).count) + let font = gutterFont + let sample = String(repeating: "8", count: digits) + let width = (sample as NSString).size(withAttributes: [.font: font]).width + let thickness = ceil(width) + 16 + if abs(thickness - ruleThickness) > 0.5 { ruleThickness = thickness } + } + + private var gutterFont: NSFont { + let base = (textView?.font?.pointSize ?? 13) - 2 + return NSFont.monospacedDigitSystemFont(ofSize: max(9, base), weight: .regular) + } + + override func drawHashMarksAndLabels(in rect: NSRect) { + guard let textView, + let layoutManager = textView.layoutManager, + let container = textView.textContainer, + let scrollView = scrollView else { return } + + theme.gutterBackground.setFill() + bounds.fill() + + // Subtle separator line on the gutter's trailing edge. + theme.gutterText.withAlphaComponent(0.25).setStroke() + let sep = NSBezierPath() + sep.move(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.minY)) + sep.line(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.maxY)) + sep.lineWidth = 1 + sep.stroke() + + let content = scrollView.contentView + let visibleRect = content.bounds + let inset = textView.textContainerInset + let nsString = textView.string as NSString + + let font = gutterFont + let normalAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.gutterText] + let activeAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.accent] + + // Which line holds the insertion point? + let selectedLine = currentLineNumber(in: nsString, selection: textView.selectedRange()) + + let glyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: container) + let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + var lineNumber = lineCount(in: nsString, upTo: charRange.location) + var index = charRange.location + + while index <= NSMaxRange(charRange) { + let lineRange = nsString.lineRange(for: NSRange(location: index, length: 0)) + let glyphLineRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) + var lineRect = layoutManager.boundingRect(forGlyphRange: glyphLineRange, in: container) + lineRect.origin.y += inset.height + + // Convert text Y into ruler Y. + let y = lineRect.minY - visibleRect.minY + let isActive = lineNumber == selectedLine + if isActive { + theme.currentLine.setFill() + NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() + } + let label = "\(lineNumber)" as NSString + let attrs = isActive ? activeAttrs : normalAttrs + let size = label.size(withAttributes: attrs) + let drawY = y + (lineRect.height - size.height) / 2 + label.draw(at: NSPoint(x: bounds.width - size.width - 8, y: drawY), withAttributes: attrs) + + if NSMaxRange(lineRange) <= index { break } + index = NSMaxRange(lineRange) + lineNumber += 1 + if index > nsString.length { break } + } + } + + private func lineCount(in s: NSString, upTo location: Int) -> Int { + var count = 1 + var i = 0 + let end = min(location, s.length) + while i < end { + if s.character(at: i) == 10 { count += 1 } + i += 1 + } + return count + } + + private func currentLineNumber(in s: NSString, selection: NSRange) -> Int { + lineCount(in: s, upTo: selection.location) + } +} diff --git a/Sources/Markdown.swift b/Sources/Markdown.swift new file mode 100644 index 0000000..fd624ac --- /dev/null +++ b/Sources/Markdown.swift @@ -0,0 +1,292 @@ +import AppKit + +/// A pragmatic Markdown → NSAttributedString renderer for the live preview. +/// Handles the constructs people actually type: ATX headings, bold/italic/ +/// bold-italic, inline code, fenced & indented code, blockquotes, ordered and +/// unordered lists, horizontal rules, and links. Colors come from the theme. +enum MarkdownRenderer { + + static func render(_ markdown: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let out = NSMutableAttributedString() + let lines = markdown.components(separatedBy: "\n") + var i = 0 + var inFence = false + var fenceBuffer: [String] = [] + + func flushFence() { + let code = fenceBuffer.joined(separator: "\n") + out.append(codeBlock(code, theme: theme, baseSize: baseSize)) + fenceBuffer = [] + } + + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { + if inFence { flushFence(); inFence = false } else { inFence = true } + i += 1 + continue + } + if inFence { fenceBuffer.append(line); i += 1; continue } + + // Horizontal rule + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + out.append(horizontalRule(theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // ATX heading + if let (level, content) = heading(trimmed) { + out.append(headingLine(content, level: level, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Blockquote + if trimmed.hasPrefix(">") { + let content = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) + out.append(blockquote(content, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Unordered list item + if let marker = unorderedMarker(trimmed) { + let content = String(trimmed.dropFirst(marker.count)).trimmingCharacters(in: .whitespaces) + out.append(listItem(bullet: "• ", content: content, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Ordered list item + if let (number, content) = orderedItem(trimmed) { + out.append(listItem(bullet: "\(number). ", content: content, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Blank line → paragraph spacing + if trimmed.isEmpty { + out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)])) + i += 1 + continue + } + + // Paragraph + let para = inline(trimmed, theme: theme, baseSize: baseSize, font: bodyFont(baseSize)) + out.append(para) + out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)])) + i += 1 + } + if inFence { flushFence() } + // Every builder already sets its own foreground color, so there's no + // blanket recolor here — that would clobber links, code, and accents. + return out + } + + // MARK: - Block builders + + private static func bodyFont(_ size: CGFloat) -> NSFont { NSFont.systemFont(ofSize: size) } + private static func monoFont(_ size: CGFloat) -> NSFont { NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } + + private static func heading(_ s: String) -> (Int, String)? { + var level = 0 + for ch in s { + if ch == "#" { level += 1 } else { break } + } + guard level >= 1, level <= 6 else { return nil } + let rest = s.dropFirst(level) + guard rest.first == " " || rest.isEmpty else { return nil } + return (level, rest.trimmingCharacters(in: .whitespaces)) + } + + private static func unorderedMarker(_ s: String) -> String? { + for m in ["- ", "* ", "+ "] where s.hasPrefix(m) { return String(m.dropLast()) } + return nil + } + + private static func orderedItem(_ s: String) -> (Int, String)? { + var digits = "" + for ch in s { + if ch.isNumber { digits.append(ch) } else { break } + } + guard let n = Int(digits) else { return nil } + let rest = s.dropFirst(digits.count) + guard rest.first == "." || rest.first == ")" else { return nil } + let content = rest.dropFirst().trimmingCharacters(in: .whitespaces) + return (n, content) + } + + private static func headingLine(_ content: String, level: Int, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let scale: CGFloat = [1.9, 1.55, 1.3, 1.15, 1.05, 1.0][min(level - 1, 5)] + let weight: NSFont.Weight = level <= 2 ? .bold : .semibold + let font = NSFont.systemFont(ofSize: round(baseSize * scale), weight: weight) + let para = NSMutableParagraphStyle() + para.paragraphSpacingBefore = baseSize * 0.6 + para.paragraphSpacing = baseSize * 0.3 + let attr = inline(content, theme: theme, baseSize: baseSize, font: font) + let m = NSMutableAttributedString(attributedString: attr) + m.addAttributes([.paragraphStyle: para], range: NSRange(location: 0, length: m.length)) + m.append(NSAttributedString(string: "\n")) + return m + } + + private static func codeBlock(_ code: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = 12 + para.headIndent = 12 + para.paragraphSpacingBefore = baseSize * 0.3 + para.paragraphSpacing = baseSize * 0.3 + let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.05) + let attrs: [NSAttributedString.Key: Any] = [ + .font: monoFont(baseSize - 1), + .foregroundColor: theme.foreground, + .backgroundColor: bg, + .paragraphStyle: para, + ] + return NSAttributedString(string: code + "\n", attributes: attrs) + } + + private static func blockquote(_ content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = 14 + para.headIndent = 14 + para.paragraphSpacingBefore = 2 + para.paragraphSpacing = 2 + let attr = inline(content, theme: theme, baseSize: baseSize, + font: NSFontManager.shared.convert(bodyFont(baseSize), toHaveTrait: .italicFontMask)) + let m = NSMutableAttributedString(attributedString: attr) + m.addAttributes([.paragraphStyle: para, .foregroundColor: theme.secondaryText], + range: NSRange(location: 0, length: m.length)) + m.append(NSAttributedString(string: "\n")) + return m + } + + private static func listItem(bullet: String, content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = 16 + para.headIndent = 16 + (bullet as NSString).size(withAttributes: [.font: bodyFont(baseSize)]).width + let m = NSMutableAttributedString() + m.append(NSAttributedString(string: bullet, attributes: [ + .font: bodyFont(baseSize), .foregroundColor: theme.accent, .paragraphStyle: para, + ])) + let body = inline(content, theme: theme, baseSize: baseSize, font: bodyFont(baseSize)) + let bodyM = NSMutableAttributedString(attributedString: body) + bodyM.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: bodyM.length)) + m.append(bodyM) + m.append(NSAttributedString(string: "\n")) + return m + } + + private static func horizontalRule(theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.paragraphSpacingBefore = baseSize * 0.4 + para.paragraphSpacing = baseSize * 0.4 + return NSAttributedString(string: "________________________\n", attributes: [ + .font: bodyFont(baseSize), + .foregroundColor: theme.secondaryText.withAlphaComponent(0.4), + .paragraphStyle: para, + ]) + } + + // MARK: - Inline parsing + + /// Parses bold/italic/code/links inside a single line. `font` is the base + /// font for this block (so headings keep their size while going bold etc). + private static func inline(_ text: String, theme: EditorTheme, baseSize: CGFloat, font: NSFont) -> NSAttributedString { + let result = NSMutableAttributedString() + let chars = Array(text) + var i = 0 + var plainStart = 0 + + func flushPlain(upTo end: Int) { + if end > plainStart { + let s = String(chars[plainStart.. NSFont { + var f = font + let mgr = NSFontManager.shared + if bold { f = mgr.convert(f, toHaveTrait: .boldFontMask) } + if italic { f = mgr.convert(f, toHaveTrait: .italicFontMask) } + return f + } + + func matchRun(_ marker: Character, count: Int, from start: Int) -> Int? { + // Find a closing run of `count` markers after start. + var j = start + while j < chars.count { + if chars[j] == marker { + var run = 0 + while j + run < chars.count && chars[j + run] == marker { run += 1 } + if run >= count { return j } + j += run + } else { + j += 1 + } + } + return nil + } + + while i < chars.count { + let c = chars[i] + // Inline code `...` + if c == "`" { + if let close = chars[(i + 1)...].firstIndex(of: "`") { + flushPlain(upTo: i) + let codeText = String(chars[(i + 1)..= 2 + let italic = count == 1 || count == 3 + let inner = inline(innerText, theme: theme, baseSize: baseSize, font: emphasisFont(bold: bold, italic: italic)) + result.append(inner) + i = close + count + plainStart = i + continue + } + } + // Link [text](url) + if c == "[" { + if let closeBracket = chars[(i + 1)...].firstIndex(of: "]"), + closeBracket + 1 < chars.count, chars[closeBracket + 1] == "(", + let closeParen = chars[(closeBracket + 2)...].firstIndex(of: ")") { + flushPlain(upTo: i) + let label = String(chars[(i + 1).. String { + SHA256.hash(data: Data(s.utf8)).map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Public entry points (call from main; completions on main) + + static func test(uri: String, completion: @escaping (Result) -> Void) { + let sql = schemaSQL + "\nSELECT current_database() || ' · ' || split_part(version(), ' on ', 1);" + runSQL(uri: uri, sql: sql) { result in + switch result { + case .success(let out): + let last = out.split(separator: "\n").last.map(String.init) ?? "ok" + completion(.success("Connected, tables ready — \(last)")) + case .failure(let error): + completion(.failure(error)) + } + } + } + + static func ingest(_ page: PageIngest, force: Bool, + completion: @escaping (Result) -> Void) { + let uri = PGSettings.uri + + func runMainIngest() { + let built = buildIngestSQL(page) + runSQL(uri: uri, sql: built.sql) { result in + switch result { + case .success(let out): + guard let idLine = out.split(separator: "\n").last, + let pageId = Int64(idLine.trimmingCharacters(in: .whitespaces)) else { + completion(.failure(PGError(message: "Couldn't read the new page id from psql output: \(out.suffix(120))"))) + return + } + completion(.success(IngestReport(pageId: pageId, + selectorCount: page.selectors.count, + rowCount: page.rows.count, + snapshotCount: built.snapshotCount, + skippedRows: built.skipped, + skippedDuplicate: false))) + case .failure(let error): + completion(.failure(error)) + } + } + } + + if force || page.url == nil { + runMainIngest() + return + } + // Auto mode: skip if the latest capture of this URL has identical content. + let checkSQL = schemaSQL + """ + + SELECT content_hash FROM tp_pages WHERE url = \(lit(page.url)) + ORDER BY captured_at DESC LIMIT 1; + """ + runSQL(uri: uri, sql: checkSQL) { result in + switch result { + case .success(let out): + let lastHash = out.split(separator: "\n").last.map(String.init) ?? "" + if lastHash == page.contentHash { + completion(.success(IngestReport(pageId: -1, selectorCount: 0, rowCount: 0, + snapshotCount: 0, skippedRows: 0, skippedDuplicate: true))) + } else { + runMainIngest() + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - SQL construction + + private static let schemaSQL = """ + CREATE TABLE IF NOT EXISTS tp_pages ( + id BIGSERIAL PRIMARY KEY, + url TEXT, + source_name TEXT, + content_hash TEXT, + element_count INT, + selector_count INT, + captured_at TIMESTAMPTZ DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tp_selectors ( + id BIGSERIAL PRIMARY KEY, + page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE, + selector TEXT NOT NULL, + match_count INT NOT NULL, + sample TEXT + ); + CREATE TABLE IF NOT EXISTS tp_rows ( + id BIGSERIAL PRIMARY KEY, + page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE, + profile TEXT, + row_num INT, + fields JSONB + ); + CREATE INDEX IF NOT EXISTS idx_tp_pages_url ON tp_pages(url); + CREATE INDEX IF NOT EXISTS idx_tp_rows_page ON tp_rows(page_id); + CREATE TABLE IF NOT EXISTS discogs_sellers ( + seller_id BIGINT PRIMARY KEY, + username VARCHAR(100) NOT NULL, + last_scraped_at TIMESTAMPTZ DEFAULT now() + ); + -- A username can be recycled across seller_ids over time; the seller_id is + -- the real key. Drop the old UNIQUE(username) so a reused name can't abort + -- a whole page ingest. (IF NOT EXISTS skips the table on existing installs, + -- so the constraint must be dropped explicitly.) + ALTER TABLE discogs_sellers DROP CONSTRAINT IF EXISTS discogs_sellers_username_key; + CREATE TABLE IF NOT EXISTS marketplace_snapshots ( + item_id BIGINT PRIMARY KEY, + release_id BIGINT NOT NULL, + seller_id BIGINT REFERENCES discogs_sellers(seller_id), + title TEXT, + listing_url TEXT, + media_condition VARCHAR(120), + sleeve_condition VARCHAR(120), + price_currency VARCHAR(10), + price_value NUMERIC(12,2), + shipping_value NUMERIC(12,2), + first_seen TIMESTAMPTZ DEFAULT now(), + last_seen TIMESTAMPTZ DEFAULT now(), + is_active BOOLEAN DEFAULT TRUE + ); + CREATE INDEX IF NOT EXISTS idx_market_release_id ON marketplace_snapshots(release_id); + CREATE INDEX IF NOT EXISTS idx_market_seller_active ON marketplace_snapshots(seller_id) WHERE is_active = TRUE; + """ + + private static func buildIngestSQL(_ page: PageIngest) -> (sql: String, snapshotCount: Int, skipped: Int) { + var sql = schemaSQL + "\nBEGIN;\n" + sql += """ + INSERT INTO tp_pages (url, source_name, content_hash, element_count, selector_count) + VALUES (\(lit(page.url)), \(lit(page.sourceName)), \(lit(page.contentHash)), \(page.elementCount), \(page.selectors.count)) + RETURNING id AS page_id \\gset + + """ + + for chunk in page.selectors.chunked(into: 500) { + let values = chunk.map { + "(:page_id, \(lit($0.selector)), \($0.count), \(lit(String($0.sample.prefix(300)))))" + }.joined(separator: ",\n") + sql += "INSERT INTO tp_selectors (page_id, selector, match_count, sample) VALUES\n\(values);\n" + } + + if !page.rows.isEmpty, let profile = page.profileName { + for (chunkIndex, chunk) in page.rows.chunked(into: 200).enumerated() { + let values = chunk.enumerated().map { offset, row -> String in + let rowNum = chunkIndex * 200 + offset + 1 + var dict: [String: String] = [:] + for (i, name) in page.header.enumerated() where i < row.count { + // Condition fields capture the grade plus its tooltip + // description; keep the JSONB mirror as clean as the + // snapshot column (grade through the closing paren). + if name.hasSuffix("_condition"), let close = row[i].range(of: ")") { + dict[name] = String(row[i][.. (sql: String, snapshotCount: Int, skipped: Int) { + func col(_ name: String) -> Int? { header.firstIndex(of: name) } + guard let itemIdx = col("item_id"), let relIdx = col("release_id") else { return ("", 0, 0) } + let sellerIdIdx = col("seller_id"), sellerNameIdx = col("seller_username") + let sellerNameAltIdx = col("seller_username_alt") + let titleIdx = col("title"), urlIdx = col("listing_url") + let mediaIdx = col("media_condition"), sleeveIdx = col("sleeve_condition") + let curIdx = col("price_currency"), priceIdx = col("price_value") + let shipIdx = col("shipping") + + func value(_ row: [String], _ idx: Int?) -> String? { + guard let idx, idx < row.count else { return nil } + let v = row[idx].trimmingCharacters(in: .whitespaces) + return v.isEmpty ? nil : v + } + + // "Near Mint (NM or M-) A nearly perfect record…" → keep through the ")". + func condition(_ s: String?) -> String? { + guard let s else { return nil } + if let close = s.range(of: ")") { return String(s[.. String? { + guard let s, let d = Double(s) else { return nil } + return String(format: "%.2f", d) + } + + func firstDecimal(_ s: String?) -> String? { + guard let s, let r = s.range(of: #"\d+(\.\d+)?"#, options: .regularExpression) else { return nil } + return numeric(String(s[r])) + } + + var sellers: [Int64: String] = [:] + var snapshots: [Int64: String] = [:] // item_id → VALUES tuple (dedup within batch) + var skipped = 0 + + for row in rows { + guard let itemId = value(row, itemIdx).flatMap({ Int64($0) }), + let releaseId = value(row, relIdx).flatMap({ Int64($0) }) else { + skipped += 1 + continue + } + // Only reference a seller_id the snapshot's FK can resolve: upsert + // the seller whenever its id is present (falling back to the + // co-located data-username, then a derived name), and NULL the + // snapshot's seller_id when no id was found. + var linkedSellerId: Int64? + if let sellerId = value(row, sellerIdIdx).flatMap({ Int64($0) }) { + let name = value(row, sellerNameIdx) + ?? value(row, sellerNameAltIdx) + ?? "seller_\(sellerId)" + sellers[sellerId] = name + linkedSellerId = sellerId + } + let tuple = """ + (\(itemId), \(releaseId), \(linkedSellerId.map(String.init) ?? "NULL"), \ + \(lit(value(row, titleIdx))), \(lit(value(row, urlIdx))), \ + \(lit(condition(value(row, mediaIdx)))), \(lit(condition(value(row, sleeveIdx)))), \ + \(lit(value(row, curIdx))), \(numeric(value(row, priceIdx)) ?? "NULL"), \ + \(firstDecimal(value(row, shipIdx)) ?? "NULL")) + """ + snapshots[itemId] = tuple + } + guard !snapshots.isEmpty else { return ("", 0, skipped) } + + var sql = "" + if !sellers.isEmpty { + let sellerValues = sellers + .sorted { $0.key < $1.key } + .map { "(\($0.key), \(lit(String($0.value.prefix(100)))))" } + .joined(separator: ",\n") + sql += """ + INSERT INTO discogs_sellers (seller_id, username) VALUES + \(sellerValues) + ON CONFLICT (seller_id) DO UPDATE SET username = EXCLUDED.username, last_scraped_at = now(); + + """ + } + let snapshotValues = snapshots.sorted { $0.key < $1.key }.map { $0.value }.joined(separator: ",\n") + sql += """ + INSERT INTO marketplace_snapshots + (item_id, release_id, seller_id, title, listing_url, media_condition, + sleeve_condition, price_currency, price_value, shipping_value) VALUES + \(snapshotValues) + ON CONFLICT (item_id) DO UPDATE SET + last_seen = now(), + price_value = EXCLUDED.price_value, + shipping_value = EXCLUDED.shipping_value, + media_condition = EXCLUDED.media_condition, + sleeve_condition = EXCLUDED.sleeve_condition, + is_active = TRUE; + + """ + return (sql, snapshots.count, skipped) + } + + private static func lit(_ s: String?) -> String { + guard let s else { return "NULL" } + let cleaned = s.replacingOccurrences(of: "\0", with: "") + return "'" + cleaned.replacingOccurrences(of: "'", with: "''") + "'" + } + + // MARK: - psql runner + + private static let workQueue = DispatchQueue(label: "textplus.postgres") + private static let timeoutSeconds: TimeInterval = 60 + + private static func runSQL(uri: String, sql: String, + completion: @escaping (Result) -> Void) { + guard let psql = psqlPath else { + DispatchQueue.main.async { + completion(.failure(PGError(message: "psql not found — install with: brew install postgresql@17"))) + } + return + } + workQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: psql) + process.arguments = ["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", uri] + + let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe() + process.standardInput = inPipe + process.standardOutput = outPipe + process.standardError = errPipe + + do { + try process.run() + } catch { + DispatchQueue.main.async { completion(.failure(PGError(message: error.localizedDescription))) } + return + } + + let watchdog = DispatchWorkItem { [weak process] in + guard let process, process.isRunning else { return } + process.terminate() + } + DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: watchdog) + + // Drain stdout/stderr concurrently while we feed stdin, so a large + // script can't deadlock on a full pipe in either direction. + var outData = Data() + var errData = Data() + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .utility).async { + outData = outPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + group.enter() + DispatchQueue.global(qos: .utility).async { + errData = errPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + + let writer = inPipe.fileHandleForWriting + do { + try writer.write(contentsOf: Data(sql.utf8)) + try writer.close() + } catch { + // Broken pipe (psql died early) — fall through to read its stderr. + try? writer.close() + } + + group.wait() + process.waitUntilExit() + watchdog.cancel() + + let out = String(decoding: outData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + let err = String(decoding: errData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + let status = process.terminationStatus + + DispatchQueue.main.async { + if status == 0 { + completion(.success(out)) + } else { + let detail = err.isEmpty ? "psql exited with status \(status)" : err + completion(.failure(PGError(message: detail))) + } + } + } + } +} + +private extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { Array(self[$0.. Bool { + url.range(of: urlPattern, options: [.regularExpression, .caseInsensitive]) != nil + } + + static let discogsMarketplace = ScrapeProfile( + name: "discogs_marketplace", + urlPattern: #"discogs\.com/(sell/list|sell/mywants|seller/[^/]+/profile)"#, + rowSelector: "tr.shortcut_navigable", + fields: [ + ("item_id", "a.cart-button@data-item-id"), + ("release_id", "tr@data-release-id"), + ("title", "a.item_description_title"), + ("listing_url", "a.item_description_title@href"), + ("media_condition", ".item_condition span[!class]"), + ("sleeve_condition", "span.item_sleeve_condition"), + ("seller_username", ".seller_block a"), + ("seller_id", "li.seller_mywants@data-seller-id"), + // Co-located with seller_id on the same element — a reliable + // username fallback so a seller is never referenced unmapped. + ("seller_username_alt", "li.seller_mywants@data-username"), + ("price_value", "span.price@data-pricevalue"), + ("price_currency", "span.price@data-currency"), + ("shipping", "span.item_shipping"), + ] + ) + + static let builtIn: [ScrapeProfile] = [discogsMarketplace] + + static func matching(url: String?) -> ScrapeProfile? { + guard let url else { return nil } + return builtIn.first { $0.matchesURL(url) } + } +} + +struct DetectedPage { + let url: String? + let html: String +} + +enum PageDetector { + /// Recognizes the "URL on the first line, then pasted HTML" convention + /// (URL optional). Returns nil when the document doesn't look like HTML. + static func detect(in text: String) -> DetectedPage? { + var url: String? + var html = String(text.drop(while: { $0.isWhitespace })) + if html.hasPrefix("http://") || html.hasPrefix("https://") { + if let newline = html.firstIndex(of: "\n") { + let candidate = String(html[..) are parsed and stored: page + selector summary, and Discogs marketplace pages also upsert discogs_sellers / marketplace_snapshots. \(psql)" + } + + private func runPGTest() { + pgTesting = true + pgMessage = nil + let uri = pgURI.trimmingCharacters(in: .whitespaces) + PostgresStore.test(uri: uri.isEmpty ? PGSettings.defaultURI : uri) { result in + pgTesting = false + switch result { + case .success(let message): + pgSucceeded = true + pgMessage = message + case .failure(let error): + pgSucceeded = false + pgMessage = error.message + } + } + } + + private func invalidateTest() { + testGeneration += 1 + testMessage = nil + testing = false + } + + private func runTest() { + testGeneration += 1 + let generation = testGeneration + testing = true + testMessage = nil + SyncEngine.testConnection(remote: trimmedRemote, + port: portValue ?? 22, + dir: dir) { result in + guard generation == testGeneration else { return } + testing = false + switch result { + case .success(let message): + testSucceeded = true + testMessage = message + case .failure(let error): + testSucceeded = false + testMessage = error.message + } + } + } +} diff --git a/Sources/SyncEngine.swift b/Sources/SyncEngine.swift new file mode 100644 index 0000000..5cdd321 --- /dev/null +++ b/Sources/SyncEngine.swift @@ -0,0 +1,209 @@ +import Foundation + +enum SyncSettings { + static let enabledKey = "syncEnabled" + static let remoteKey = "syncRemote" + static let dirKey = "syncRemoteDir" + static let portKey = "syncPort" + + static var enabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } + static var remote: String { + (UserDefaults.standard.string(forKey: remoteKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + static var dir: String { + (UserDefaults.standard.string(forKey: dirKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + static var port: Int { + let raw = (UserDefaults.standard.string(forKey: portKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let p = Int(raw), (1...65535).contains(p) else { return 22 } + return p + } + static var isConfigured: Bool { !remote.isEmpty } +} + +struct SyncError: Error { + let message: String +} + +enum SyncEngine { + /// Main-thread-only count of scp uploads that haven't finished yet. + private(set) static var inFlightUploads = 0 + /// Called on the main thread when the last in-flight upload finishes. + static var onIdle: (() -> Void)? + + private static let sshOptions = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "StrictHostKeyChecking=accept-new", + ] + + private static let watchdogSeconds: TimeInterval = 120 + + // All invocations run serially: rapid saves of the same file must never + // have two scp processes writing the same remote path at once. + private static let workQueue = DispatchQueue(label: "textplus.sync") + + /// Copy the file to the configured remote directory in the background. + /// Call from the main thread; the completion handler runs on the main queue. + static func upload(fileURL: URL, completion: @escaping (Result) -> Void) { + dispatchPrecondition(condition: .onQueue(.main)) + inFlightUploads += 1 + let destination = scpDestination(remote: SyncSettings.remote, dir: SyncSettings.dir) + let args = sshOptions + ["-P", String(SyncSettings.port), fileURL.path, destination] + run(tool: "/usr/bin/scp", args: args) { status, output, timedOut in + if status == 0 && !timedOut { + completion(.success(())) + } else { + completion(.failure(SyncError(message: friendlyMessage(tool: "scp", status: status, output: output, timedOut: timedOut)))) + } + inFlightUploads -= 1 + if inFlightUploads == 0 { onIdle?() } + } + } + + /// Check that we can reach the remote and that the directory exists. + static func testConnection(remote: String, port: Int, dir: String, + completion: @escaping (Result) -> Void) { + let trimmedDir = dir.trimmingCharacters(in: .whitespacesAndNewlines) + let probe = trimmedDir.isEmpty + ? "echo __OK__" + : "if [ -d \(probePath(trimmedDir)) ]; then echo __OK__; else echo __NODIR__; fi" + let args = sshOptions + ["-p", String(port), remote, probe] + run(tool: "/usr/bin/ssh", args: args) { status, output, timedOut in + if output.contains("__OK__") { + completion(.success("Connected — folder exists")) + } else if output.contains("__NODIR__") { + completion(.failure(SyncError(message: "Connected, but the remote folder doesn't exist"))) + } else { + completion(.failure(SyncError(message: friendlyMessage(tool: "ssh", status: status, output: output, timedOut: timedOut)))) + } + } + } + + // MARK: - Destination / path handling + + /// scp splits the remote spec at the first colon, so IPv6 literals + /// (and other hosts containing ':') must be bracketed. + private static func scpDestination(remote: String, dir: String) -> String { + var d = dir + if !d.isEmpty && !d.hasSuffix("/") { d += "/" } + var user = "" + var host = remote + if let at = remote.lastIndex(of: "@") { + user = String(remote[...at]) + host = String(remote[remote.index(after: at)...]) + } + if host.contains(":") && !host.hasPrefix("[") { + host = "[\(host)]" + } + return "\(user)\(host):\(d)" + } + + /// Quote the directory for the remote shell, but leave a leading ~ + /// unquoted so it expands — matching scp's SFTP-mode ~ expansion on upload. + private static func probePath(_ dir: String) -> String { + if dir == "~" { return "~" } + if dir.hasPrefix("~/") { return "~/" + shellQuoted(String(dir.dropFirst(2))) } + return shellQuoted(dir) + } + + private static func shellQuoted(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + // MARK: - Error reporting + + private static func friendlyMessage(tool: String, status: Int32, output: String, timedOut: Bool) -> String { + if timedOut { + return "Timed out after \(Int(watchdogSeconds)) seconds" + } + var message = output.isEmpty ? "\(tool) exited with status \(status)" : output + if message.contains("Permission denied") { + // The window subtitle shows only the first line, so the hint goes there. + let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let hint = " — SSH key not available? Run 'ssh-add --apple-load-keychain' in Terminal," + + " or add 'UseKeychain yes' and 'AddKeysToAgent yes' to ~/.ssh/config" + message = (lines.first ?? message) + hint + if lines.count > 1 { + message += "\n" + lines.dropFirst().joined(separator: "\n") + } + } + return message + } + + // MARK: - Process plumbing + + private static func run(tool: String, args: [String], + completion: @escaping (Int32, String, Bool) -> Void) { + workQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: tool) + process.arguments = args + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + } catch { + DispatchQueue.main.async { completion(-1, error.localizedDescription, false) } + return + } + + // Watchdog: unstick a wedged transfer. Weak capture so the canceled + // work item doesn't pin the Process and pipe FDs until its deadline. + let timedOutFlag = AtomicFlag() + let watchdog = DispatchWorkItem { [weak process] in + guard let process, process.isRunning else { return } + timedOutFlag.set() + process.terminate() + } + DispatchQueue.global().asyncAfter(deadline: .now() + watchdogSeconds, execute: watchdog) + + // Drain both pipes concurrently so a full stderr buffer can't + // stall the child while we block on stdout. + var errData = Data() + let errDone = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .utility).async { + errData = errPipe.fileHandleForReading.readDataToEndOfFile() + errDone.signal() + } + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + errDone.wait() + process.waitUntilExit() + watchdog.cancel() + + let combined = (String(decoding: outData, as: UTF8.self) + "\n" + + String(decoding: errData, as: UTF8.self)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let status = process.terminationStatus + let timedOut = timedOutFlag.get() + DispatchQueue.main.async { completion(status, combined, timedOut) } + } + } +} + +private final class AtomicFlag { + private let lock = NSLock() + private var value = false + + func set() { + lock.lock() + value = true + lock.unlock() + } + + func get() -> Bool { + lock.lock() + defer { lock.unlock() } + return value + } +} diff --git a/Sources/TextMarkup.swift b/Sources/TextMarkup.swift new file mode 100644 index 0000000..d07961b --- /dev/null +++ b/Sources/TextMarkup.swift @@ -0,0 +1,39 @@ +import AppKit + +/// Selection-aware Markdown insertion used by both the toolbar and the Format +/// menu. Wraps the selection (or inserts a placeholder), keeping the change on +/// the text view's own undo stack. +enum TextMarkup { + static func wrap(_ textView: NSTextView, prefix: String, suffix: String, placeholder: String) { + let range = textView.selectedRange() + let ns = textView.string as NSString + let selected = ns.substring(with: range) + let body = selected.isEmpty ? placeholder : selected + let replacement = prefix + body + suffix + guard textView.shouldChangeText(in: range, replacementString: replacement) else { return } + textView.replaceCharacters(in: range, with: replacement) + textView.didChangeText() + // Re-select the body so the user can keep typing or toggle again. + let bodyStart = range.location + (prefix as NSString).length + textView.setSelectedRange(NSRange(location: bodyStart, length: (body as NSString).length)) + } + + /// Prefix every line touched by the selection (headings, lists, quotes). + static func linePrefix(_ textView: NSTextView, prefix: String) { + let ns = textView.string as NSString + let lineRange = ns.lineRange(for: textView.selectedRange()) + let block = ns.substring(with: lineRange) + let prefixed = block + .components(separatedBy: "\n") + .enumerated() + .map { idx, line in + // Don't add a stray prefix to a trailing empty line from the split. + (line.isEmpty && idx > 0) ? line : prefix + line + } + .joined(separator: "\n") + guard textView.shouldChangeText(in: lineRange, replacementString: prefixed) else { return } + textView.replaceCharacters(in: lineRange, with: prefixed) + textView.didChangeText() + textView.setSelectedRange(NSRange(location: lineRange.location, length: (prefixed as NSString).length)) + } +} diff --git a/Sources/WorkspaceController.swift b/Sources/WorkspaceController.swift new file mode 100644 index 0000000..765b2f2 --- /dev/null +++ b/Sources/WorkspaceController.swift @@ -0,0 +1,564 @@ +import AppKit + +/// Owns a document window's chrome: the toolbar, the editor / split / canvas +/// mode switching, the Markdown preview, and the bottom status bar. One per +/// document window; created in Document.makeWindowControllers. +final class WorkspaceController: NSObject, NSToolbarDelegate, NSTextViewDelegate { + enum Mode: Int { case editor, split, board } + + weak var document: Document? + let containerView = NSView() + + let editorScrollView: NSScrollView + let textView: NSTextView // the document's main page (== the .txt) + private var editorTV: NSTextView // the text view currently shown in the editor + private weak var activeEditorCard: CardView? // which board page the editor shows + private let ruler: LineNumberRulerView + + private let splitView = NSSplitView() + private let previewScroll = NSScrollView() + private let previewTextView = NSTextView() + let boardContainer = BoardContainerView(frame: .zero) + + private let statusBar = NSView() + private let leftStatus = NSTextField(labelWithString: "") + private let centerStatus = NSTextField(labelWithString: "") + private let rightStatus = NSTextField(labelWithString: "") + + private var modeSegment: NSSegmentedControl? + private(set) var mode: Mode = .editor + + init(document: Document, editorScrollView: NSScrollView, textView: NSTextView) { + self.document = document + self.editorScrollView = editorScrollView + self.textView = textView + self.editorTV = textView + self.ruler = LineNumberRulerView(textView: textView) + super.init() + buildLayout() + hookBoard() + NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged), + name: AppSettings.didChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(textChanged), + name: NSText.didChangeNotification, object: textView) + NotificationCenter.default.addObserver(self, selector: #selector(selectionChanged), + name: NSTextView.didChangeSelectionNotification, object: textView) + applyAppearance() + recomputeMetrics() + refreshStatus() + } + + deinit { NotificationCenter.default.removeObserver(self) } + + // MARK: - Layout + + private func buildLayout() { + editorScrollView.hasVerticalRuler = true + editorScrollView.rulersVisible = AppSettings.lineNumbers + editorScrollView.verticalRulerView = ruler + + // Preview pane + previewScroll.hasVerticalScroller = true + previewScroll.borderType = .noBorder + previewScroll.documentView = previewTextView + previewTextView.isEditable = false + previewTextView.isSelectable = true + previewTextView.textContainerInset = NSSize(width: 16, height: 14) + previewTextView.autoresizingMask = [.width] + previewTextView.minSize = NSSize(width: 0, height: 0) + previewTextView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + previewTextView.isVerticallyResizable = true + previewTextView.isHorizontallyResizable = false + previewTextView.textContainer?.widthTracksTextView = true + + splitView.isVertical = true + splitView.dividerStyle = .thin + splitView.translatesAutoresizingMaskIntoConstraints = false + splitView.addArrangedSubview(editorScrollView) + + boardContainer.translatesAutoresizingMaskIntoConstraints = false + boardContainer.isHidden = true + + statusBar.translatesAutoresizingMaskIntoConstraints = false + statusBar.wantsLayer = true + buildStatusBar() + + containerView.addSubview(splitView) + containerView.addSubview(boardContainer) + containerView.addSubview(statusBar) + + NSLayoutConstraint.activate([ + statusBar.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + statusBar.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + statusBar.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + statusBar.heightAnchor.constraint(equalToConstant: 24), + + splitView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + splitView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + splitView.topAnchor.constraint(equalTo: containerView.topAnchor), + splitView.bottomAnchor.constraint(equalTo: statusBar.topAnchor), + + boardContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + boardContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + boardContainer.topAnchor.constraint(equalTo: containerView.topAnchor), + boardContainer.bottomAnchor.constraint(equalTo: statusBar.topAnchor), + ]) + } + + private func buildStatusBar() { + for label in [leftStatus, centerStatus, rightStatus] { + label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + label.translatesAutoresizingMaskIntoConstraints = false + statusBar.addSubview(label) + } + centerStatus.alignment = .center + rightStatus.alignment = .right + NSLayoutConstraint.activate([ + leftStatus.leadingAnchor.constraint(equalTo: statusBar.leadingAnchor, constant: 12), + leftStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor), + centerStatus.centerXAnchor.constraint(equalTo: statusBar.centerXAnchor), + centerStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor), + rightStatus.trailingAnchor.constraint(equalTo: statusBar.trailingAnchor, constant: -12), + rightStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor), + ]) + } + + private func hookBoard() { + boardContainer.document = document + boardContainer.onStatus = { [weak self] msg in self?.centerStatus.stringValue = msg } + boardContainer.onSendToTailnet = { [weak self] data in self?.document?.sendSketchToTailnet(pngData: data) } + boardContainer.boardView.onChange = { [weak self] in + self?.document?.updateChangeCount(.changeDone); self?.refreshStatus() + } + boardContainer.onStatusChange = { [weak self] in self?.refreshStatus() } + } + + // MARK: - Mode switching + + func setMode(_ newMode: Mode) { + let leavingBoard = (mode == .board && newMode != .board) + mode = newMode + modeSegment?.selectedSegment = newMode.rawValue + switch newMode { + case .editor: + if leavingBoard { showPageInEditor() } + boardContainer.isHidden = true + splitView.isHidden = false + if previewScroll.superview != nil { + previewScroll.removeFromSuperview() + } + applyWrap(AppSettings.softWrap) + containerView.window?.makeFirstResponder(editorTV) + case .split: + if leavingBoard { showPageInEditor() } + boardContainer.isHidden = true + splitView.isHidden = false + if previewScroll.superview == nil { + // Editor stays at index 0; append preview. (Editor was never removed.) + splitView.addArrangedSubview(previewScroll) + } + applyWrap(AppSettings.softWrap) + refreshPreview() + DispatchQueue.main.async { + let w = self.splitView.bounds.width + if w > 0 { self.splitView.setPosition(w * 0.52, ofDividerAt: 0) } + } + containerView.window?.makeFirstResponder(editorTV) + case .board: + returnPageToBoard() + splitView.isHidden = true + boardContainer.isHidden = false + DispatchQueue.main.async { self.boardContainer.boardView.centerOnMain() } + containerView.window?.makeFirstResponder(boardContainer.boardView) + } + refreshStatus() + } + + // MARK: - Editor page binding (the editor follows the selected board page) + + /// Called once after the board loads: the document text view (in the editor) + /// belongs to the main page card. + func bindInitialEditorPage() { + boardContainer.boardView.assignMainTextView(textView) + activeEditorCard = boardContainer.boardView.mainCardView + } + + /// Leaving the board: drop the active page's text view back into its card. + private func returnPageToBoard() { + boardContainer.boardView.unfocusCard(commit: true) + activeEditorCard?.mountTextView() + editorScrollView.rulersVisible = false + } + + /// Entering editor/split from the board: host the selected page's text view + /// (or the main page) in the editor and re-point all editor chrome to it. + private func showPageInEditor() { + let bv = boardContainer.boardView + bv.unfocusCard(commit: true) + guard let card = bv.editorPageCandidate(), let tv = card.unmountTextView() else { return } + activeEditorCard = card + tv.isEditable = true // idle board cards become editable in the editor + tv.isSelectable = true + editorScrollView.documentView = tv + editorScrollView.rulersVisible = AppSettings.lineNumbers + retargetEditor(to: tv) + } + + /// Move the editor's observers, ruler, wrap, theme and metrics onto `tv`. + private func retargetEditor(to tv: NSTextView) { + if editorTV !== tv { + let c = NotificationCenter.default + c.removeObserver(self, name: NSText.didChangeNotification, object: editorTV) + c.removeObserver(self, name: NSTextView.didChangeSelectionNotification, object: editorTV) + editorTV = tv + c.addObserver(self, selector: #selector(textChanged), name: NSText.didChangeNotification, object: tv) + c.addObserver(self, selector: #selector(selectionChanged), name: NSTextView.didChangeSelectionNotification, object: tv) + } + themeEditorTextView() + ruler.retarget(to: tv) + ruler.updateThickness() + applyWrap(AppSettings.softWrap) + recomputeMetrics() + refreshPreview() + } + + @objc private func cycleMode(_ sender: NSSegmentedControl) { + setMode(Mode(rawValue: sender.selectedSegment) ?? .editor) + } + + // MARK: - Appearance + + @objc private func appearanceChanged() { applyAppearance() } + + /// Apply the current theme/font to whichever text view the editor is showing. + private func themeEditorTextView() { + let theme = AppSettings.theme + let font = AppSettings.editorFont + let tv = editorTV + tv.font = font + tv.backgroundColor = theme.background + tv.drawsBackground = false // the card / editor draws the background + tv.textColor = theme.foreground + tv.insertionPointColor = theme.caret + tv.selectedTextAttributes = [.backgroundColor: theme.selection] + tv.typingAttributes = [.font: font, .foregroundColor: theme.foreground] + let full = NSRange(location: 0, length: (tv.string as NSString).length) + tv.textStorage?.addAttributes([.font: font, .foregroundColor: theme.foreground], range: full) + } + + func applyAppearance() { + let theme = AppSettings.theme + let font = AppSettings.editorFont + + containerView.window?.appearance = theme.appearance + + themeEditorTextView() + // Keep on-board (idle) text cards themed too, so they read correctly + // whether shown on the board or lifted into the editor. + boardContainer.boardView.applyTextTheme(font: font, fg: theme.foreground, caret: theme.caret) + + editorScrollView.backgroundColor = theme.background + editorScrollView.drawsBackground = true + ruler.apply(theme: theme) + // Editor-only affordances: skip while the text view is reparented into + // the board (rulersVisible / applyWrap both read editorScrollView state). + if mode != .board { + editorScrollView.rulersVisible = AppSettings.lineNumbers + ruler.updateThickness() + applyWrap(AppSettings.softWrap) + } + + previewScroll.backgroundColor = theme.background + previewTextView.backgroundColor = theme.background + previewTextView.drawsBackground = true + refreshPreview() + + boardContainer.apply(theme: theme) + + statusBar.layer?.backgroundColor = theme.gutterBackground.cgColor + for label in [leftStatus, centerStatus, rightStatus] { label.textColor = theme.secondaryText } + } + + private func applyWrap(_ wrap: Bool) { + let tv = editorTV + guard let container = tv.textContainer else { return } + if wrap { + container.widthTracksTextView = true + container.size = NSSize(width: editorScrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) + tv.isHorizontallyResizable = false + tv.autoresizingMask = [.width] + editorScrollView.hasHorizontalScroller = false + } else { + container.widthTracksTextView = false + container.size = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + tv.isHorizontallyResizable = true + tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + tv.autoresizingMask = [.width, .height] + editorScrollView.hasHorizontalScroller = true + } + tv.needsDisplay = true + } + + // MARK: - Preview + status + + @objc func textChanged() { + if mode == .split { refreshPreview() } + ruler.updateThickness() + recomputeMetrics() + refreshStatus() + } + + @objc func selectionChanged() { refreshStatus() } + + // Word/char/line counts are cached so the board's pan/zoom status updates + // don't re-scan a large pasted document on every notification. + private var metrics: (words: Int, chars: Int, lines: Int) = (0, 0, 1) + + private func recomputeMetrics() { + let s = editorTV.string + metrics.chars = (s as NSString).length + metrics.words = s.split { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }.count + metrics.lines = s.isEmpty ? 1 : s.reduce(into: 1) { c, ch in if ch == "\n" { c += 1 } } + } + + /// Called after the document sets text programmatically (load / openResults). + func refreshTextMetrics() { recomputeMetrics(); refreshStatus() } + + private func refreshPreview() { + guard mode == .split else { return } + let theme = AppSettings.theme + let attributed = MarkdownRenderer.render(editorTV.string, theme: theme, baseSize: AppSettings.fontSize + 1) + previewTextView.textStorage?.setAttributedString(attributed) + previewTextView.textColor = theme.foreground + } + + private func refreshStatus() { + let words = metrics.words, chars = metrics.chars, lines = metrics.lines + let theme = AppSettings.theme + + if mode == .board { + let bv = boardContainer.boardView + if let card = bv.selectedCardView { + let f = card.frame + leftStatus.stringValue = "\(cardKindLabel(card.kind)) · \(Int(f.width.rounded()))×\(Int(f.height.rounded())) pt" + } else { + leftStatus.stringValue = "\(boardContainer.currentTool.name) tool · " + + "\(bv.mainPageSize.shortLabel) \(bv.mainOrientation == .landscape ? "Landscape" : "Portrait")" + } + centerStatus.stringValue = "Board · \(theme.name) · \(boardContainer.zoomPercent)%" + let n = bv.cards.count + rightStatus.stringValue = "\(n) card\(n == 1 ? "" : "s") · \(words) words · \(chars) chars" + return + } + + // editor / split — line:col needs the live string + let ns = editorTV.string as NSString + let sel = editorTV.selectedRange() + var line = 1, col = 1, i = 0 + while i < sel.location && i < ns.length { + if ns.character(at: i) == 10 { line += 1; col = 1 } else { col += 1 } + i += 1 + } + var left = "Ln \(line), Col \(col)" + if sel.length > 0 { left += " · \(sel.length) selected" } + leftStatus.stringValue = left + + let modeName = mode == .split ? "Split" : "Editor" + // Make it obvious when the editor is showing a board note page, not the .txt. + let pageNote = (activeEditorCard?.kind == .text) ? "Board note · " : "" + centerStatus.stringValue = "\(pageNote)\(modeName) · \(theme.name) · \(AppSettings.fontName) \(Int(AppSettings.fontSize))pt" + + let mins = max(1, Int((Double(words) / 200.0).rounded(.up))) + rightStatus.stringValue = "\(words) words · \(chars) chars · \(lines) lines · ~\(mins) min read" + } + + private func cardKindLabel(_ k: BoardCardKind) -> String { + switch k { + case .mainText: return "Main page" + case .text: return "Text page" + case .image: return "Image" + case .box: return "Box" + } + } + + func setStatus(_ message: String) { centerStatus.stringValue = message } + + // MARK: - Toolbar + + private enum ID { + static let mode = NSToolbarItem.Identifier("mode") + static let font = NSToolbarItem.Identifier("font") + static let fontSize = NSToolbarItem.Identifier("fontSize") + static let theme = NSToolbarItem.Identifier("theme") + static let markup = NSToolbarItem.Identifier("markup") + static let wrap = NSToolbarItem.Identifier("wrap") + static let sync = NSToolbarItem.Identifier("sync") + static let postgres = NSToolbarItem.Identifier("postgres") + } + + func installToolbar(on window: NSWindow) { + let toolbar = NSToolbar(identifier: "TextPlusToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = true + toolbar.autosavesConfiguration = true + window.toolbar = toolbar + window.toolbarStyle = .unified + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [ID.mode, .flexibleSpace, ID.markup, .space, ID.font, ID.fontSize, ID.theme, + ID.wrap, .flexibleSpace, ID.sync, ID.postgres] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [ID.mode, ID.markup, ID.font, ID.fontSize, ID.theme, ID.wrap, ID.sync, ID.postgres, + .flexibleSpace, .space] + } + + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier id: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + switch id { + case ID.mode: return modeItem() + case ID.markup: return markupItem() + case ID.font: return fontItem() + case ID.fontSize: return fontSizeItem() + case ID.theme: return themeItem() + case ID.wrap: return wrapItem() + case ID.sync: return buttonItem(id: id, symbol: "arrow.up.circle", label: "Sync", + tip: "Sync Now (⌥⌘S)", action: #selector(toolbarSync)) + case ID.postgres: return buttonItem(id: id, symbol: "cylinder.split.1x2", label: "Postgres", + tip: "Send Page to Postgres (⌥⌘P)", action: #selector(toolbarPostgres)) + default: return nil + } + } + + private func modeItem() -> NSToolbarItem { + let seg = NSSegmentedControl(labels: ["Editor", "Split", "Board"], + trackingMode: .selectOne, target: self, action: #selector(cycleMode(_:))) + seg.selectedSegment = mode.rawValue + seg.segmentStyle = .texturedRounded + modeSegment = seg + let item = NSToolbarItem(itemIdentifier: ID.mode) + item.view = seg + item.label = "View" + item.visibilityPriority = .high + return item + } + + private func markupItem() -> NSToolbarItem { + let symbols = ["bold", "italic", "chevron.left.forwardslash.chevron.right", + "number", "list.bullet", "link"] + let seg = NSSegmentedControl() + seg.segmentCount = symbols.count + seg.trackingMode = .momentary + seg.segmentStyle = .texturedRounded + for (i, sym) in symbols.enumerated() { + seg.setImage(NSImage(systemSymbolName: sym, accessibilityDescription: sym), forSegment: i) + seg.setWidth(30, forSegment: i) + } + seg.target = self + seg.action = #selector(markupTapped(_:)) + let item = NSToolbarItem(itemIdentifier: ID.markup) + item.view = seg + item.label = "Markup" + return item + } + + private func fontItem() -> NSToolbarItem { + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 130, height: 24), pullsDown: false) + popup.addItems(withTitles: AppSettings.fontChoices) + popup.selectItem(withTitle: AppSettings.fontName) + popup.target = self + popup.action = #selector(fontChanged(_:)) + let item = NSToolbarItem(itemIdentifier: ID.font) + item.view = popup + item.label = "Font" + return item + } + + private func fontSizeItem() -> NSToolbarItem { + let seg = NSSegmentedControl(labels: ["A−", "A+"], trackingMode: .momentary, + target: self, action: #selector(fontSizeTapped(_:))) + seg.segmentStyle = .texturedRounded + let item = NSToolbarItem(itemIdentifier: ID.fontSize) + item.view = seg + item.label = "Size" + return item + } + + private func themeItem() -> NSToolbarItem { + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 120, height: 24), pullsDown: false) + popup.addItems(withTitles: EditorTheme.all.map { $0.name }) + if let idx = EditorTheme.all.firstIndex(where: { $0.id == AppSettings.theme.id }) { + popup.selectItem(at: idx) + } + popup.target = self + popup.action = #selector(themeChanged(_:)) + let item = NSToolbarItem(itemIdentifier: ID.theme) + item.view = popup + item.label = "Theme" + return item + } + + private func wrapItem() -> NSToolbarItem { + let button = NSButton(image: NSImage(systemSymbolName: "text.alignleft", accessibilityDescription: "Wrap")!, + target: self, action: #selector(toggleWrap)) + button.setButtonType(.pushOnPushOff) + button.bezelStyle = .texturedRounded + button.state = AppSettings.softWrap ? .on : .off + let item = NSToolbarItem(itemIdentifier: ID.wrap) + item.view = button + item.label = "Wrap" + item.toolTip = "Toggle soft wrap" + return item + } + + private func buttonItem(id: NSToolbarItem.Identifier, symbol: String, label: String, + tip: String, action: Selector) -> NSToolbarItem { + let button = NSButton(image: NSImage(systemSymbolName: symbol, accessibilityDescription: label)!, + target: self, action: action) + button.bezelStyle = .texturedRounded + let item = NSToolbarItem(itemIdentifier: id) + item.view = button + item.label = label + item.toolTip = tip + return item + } + + // MARK: - Toolbar actions + + @objc private func markupTapped(_ sender: NSSegmentedControl) { + switch sender.selectedSegment { + case 0: document?.markupBold(sender) + case 1: document?.markupItalic(sender) + case 2: document?.markupCode(sender) + case 3: document?.markupHeading(sender) + case 4: document?.markupList(sender) + case 5: document?.markupLink(sender) + default: break + } + } + + @objc private func fontChanged(_ sender: NSPopUpButton) { + AppSettings.set(AppSettings.fontNameKey, sender.titleOfSelectedItem ?? "SF Mono") + } + + @objc private func fontSizeTapped(_ sender: NSSegmentedControl) { + let delta: CGFloat = sender.selectedSegment == 1 ? 1 : -1 + let size = max(8, min(48, AppSettings.fontSize + delta)) + AppSettings.set(AppSettings.fontSizeKey, Double(size)) + } + + @objc private func themeChanged(_ sender: NSPopUpButton) { + let theme = EditorTheme.all[sender.indexOfSelectedItem] + AppSettings.set(AppSettings.themeKey, theme.id) + } + + @objc private func toggleWrap(_ sender: NSButton) { + AppSettings.set(AppSettings.softWrapKey, sender.state == .on) + } + + @objc private func toolbarSync() { document?.syncNow(nil) } + @objc private func toolbarPostgres() { document?.sendToPostgres(nil) } +} diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..cb90593 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,10 @@ +import AppKit + +// Subprocess pipes (scp, psql) can break mid-write; don't let SIGPIPE kill the app. +signal(SIGPIPE, SIG_IGN) + +let app = NSApplication.shared +let appDelegate = AppDelegate() +app.delegate = appDelegate +app.setActivationPolicy(.regular) +app.run() diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..3fd062d --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Build TextPlus.app — run: ./build.sh +set -euo pipefail +cd "$(dirname "$0")" + +APP="TextPlus.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +swiftc -O -module-name TextPlus \ + Sources/*.swift \ + -o "$APP/Contents/MacOS/TextPlus" + +cp Info.plist "$APP/Contents/Info.plist" +if [ -f Resources/AppIcon.icns ]; then + cp Resources/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +fi + +codesign --force --sign - "$APP" +echo "Built $APP" diff --git a/demo.md b/demo.md new file mode 100644 index 0000000..a150175 --- /dev/null +++ b/demo.md @@ -0,0 +1,23 @@ +# TextPlus + +A **plaintext** editor that grew *teeth*: themes, live Markdown preview, a +paint canvas, and a pipeline that parses HTML and ships it to Postgres. + +## What's in the box + +- Five editor themes with a line-number gutter +- Live Markdown preview (you're looking at the source) +- A vector **paint canvas** — pen, shapes, arrows, text +- `scp`-on-save to any Mac on your tailnet +- HTML selector parsing → `marketplace_snapshots` + +> Toggle the view with the segmented control top-left, or ⌃⌘1 / ⌃⌘2 / ⌃⌘3. + +### A code sample + +```swift +let canvas = DrawCanvasView() +canvas.tool = .arrow +``` + +Visit [the repo](https://example.com) and press ⌘B to **bold** a selection. diff --git a/docs/BOARD_DESIGN.md b/docs/BOARD_DESIGN.md new file mode 100644 index 0000000..c5eb0e0 --- /dev/null +++ b/docs/BOARD_DESIGN.md @@ -0,0 +1,75 @@ +# Freeform Board — implementation blueprint + +Vetted by a 13-agent design panel (winner: reuse-robust, with freeform-ux box-cards +and the editorScrollView-nesting fix grafted in). + +## Core decisions +- **Board = 4th WorkspaceController.Mode** (`editor`, `split`, `board`). Canvas mode is + ABSORBED into the board — the board's shapes layer is the drawing surface. +- **The magnifying `boardScroll` is the only scroll view.** Cards are bare views + (NSTextView / NSImageView / CALayer box). NEVER nest `editorScrollView` inside it. +- **Main card hosts the document's real `textView`** (reparented bare, no scroll view), + so dirty/undo/find/line-col all keep working. Its text = the `.txt` bytes (never + serialized to the sidecar — no second copy can diverge). +- **Secondary text cards** are board-only annotations; their text lives in the sidecar, + not the `.txt`. +- **Clean boxes** = `BoardCard(kind: .box)` CALayer cards (fill `accent@0.18`, stroke + `accent`, cornerRadius 8). Freehand pen/shapes stay on the reused `DrawCanvasView`. + +## View hierarchy (Sources/BoardContainer.swift) +``` +BoardContainerView : NSView (sibling of splitView; same 4 pins; isHidden=true) + ├─ palette : NSStackView (left, fixed width) Select/Box/Pen/Line/Arrow/Oval/Eraser, + │ stroke well, width slider, New Text Card, Add Image…, Portrait/Landscape, + │ zoom-to-fit, undo/redo, export PNG, send-to-tailnet (pinned, NOT zoomed) + └─ boardScroll : NSScrollView allowsMagnification, min .15 / max 4 + └─ boardView : BoardView (NSView, isFlipped, wantsLayer, world 8000×6000, dot grid) + ├─ shapes : DrawCanvasView (reused, canvasBackground=.clear, bottom) + ├─ cardViews : [CardView] by z (TextCardView / ImageCardView / BoxCardView) + └─ selectionOverlay : SelectionOverlayView (8 handles, top, forwards events) +``` + +## Data model (Sources/Board.swift), Codable +- `BoardCardKind { mainText, text, image, box }` +- `PageOrientation { portrait(612×792), landscape(792×612) }` +- `BoardCard { id, kind, frame(world), z, text?, orientation?, imageFile?, fillHex?, strokeHex?, cornerRadius?, title? }` +- `BoardDoc { version, cards, shapes: DrawCanvasView.CanvasDoc, magnification, scrollOrigin, boardBackgroundHex? }` +- Hand-written `init(from:)` that `try?`-decodes each field with a default → missing/old + sidecar never throws. + +## Persistence: bundle dir `foo.txt.board/` +- `board.json` (atomic write via replaceItemAt, pretty+sortedKeys) +- `img-.png` assets (referenced by filename; bytes never in JSON; GC orphans on save) +- `preview.png` (derived, for send-to-tailnet) +- Load after Document.swift:41; save after Document.swift:121, all `try?`-guarded so a + sidecar failure NEVER fails the plaintext save. Main card text is never written here. +- Migration: if `foo.txt.board/` absent but `foo.txt.canvas.json` exists, import its + CanvasDoc into `BoardDoc.shapes` (one-way, leaves the old file). + +## Focus transition +- Enter: double-click text card OR Return on selection → record preFocus mag/visibleRect, + raise z, animate `boardScroll.animator().setMagnification(clamp 1.0–1.5, centeredAt:)`, + textView editable+selectable, makeFirstResponder(textView). +- Exit: Esc / click empty / select other / leave mode → breakUndoCoalescing(), copy + secondary card text back + updateChangeCount, editable=false, makeFirstResponder(boardView), + animate back. +- **Mandatory:** CardView.hitTest returns self while unfocused (a non-selectable NSTextView + still returns self for a plain click). Shapes layer hit-tests only when a draw tool active. + +## Must-handle pitfalls +1. Reparent only the bare textView, never editorScrollView (the #1 sleeper bug). +2. rulersVisible=false in board; restore on exit; keep editorScrollView alive. +3. Gate applyWrap when mode==.board (it reads editorScrollView.contentSize). +4. Return editor to splitView via insertArrangedSubview(at:0), never add (pane order). +5. NSScrollView.allowsMagnification only — no manual CGAffineTransform. convert(from:nil) keeps working. +6. CardView hitTest override (above). +7. Centralize textView reparenting in attachTextViewToBoard()/detachTextViewToEditor(); one superview owns it; never recreate it. Test editor→board→editor→split round-trip FIRST. +8. shapes DrawCanvasView canvasBackground=.clear (it opaque-fills bounds otherwise). +9. Two undo managers (text=document, structural=boardView); every structural mutation also updateChangeCount(.changeDone). +10. Board PNG export at magnification 1.0 over the union of content, not the 8000×6000 world. +11. Re-resolve CALayer cgColors in applyAppearance() (they don't auto-update on appearance change). +12. saveBoardSidecar wrapped in try?, after super.save succeeds. + +## MVP cuts (defer) +connectors, marquee multi-select, alignment guides (keep grid-snap), card snapshotting, +interleaved shape/card z, multi-page-into-one-.txt, JSON forward-merge, QuickLook plugin.