diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/404.html b/404.html new file mode 100644 index 0000000..04f8555 --- /dev/null +++ b/404.html @@ -0,0 +1 @@ +Error 404 — Michael Toohig

Error 404

Sorry, we couldn't find this page.

But dont worry, you can find plenty of other things on our homepage.

Back to homepage
\ No newline at end of file diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..28de07f --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +michaeltoohig.com diff --git a/_astro/2-1-split.LGQISXb-.jpg b/_astro/2-1-split.LGQISXb-.jpg new file mode 100644 index 0000000..7bd0703 Binary files /dev/null and b/_astro/2-1-split.LGQISXb-.jpg differ diff --git a/_astro/Counter.LqWelQlT.js b/_astro/Counter.LqWelQlT.js new file mode 100644 index 0000000..9af4883 --- /dev/null +++ b/_astro/Counter.LqWelQlT.js @@ -0,0 +1 @@ +import{r as c,c as l,a as r,t as d,b as _,F as i,o as p}from"./runtime-core.esm-bundler.QcTl7tfJ.js";const f=(t,e)=>{const r=t.__vccOpts||t;for(const[t,s]of e)r[t]=s;return r},v={__name:"Counter",setup(t,{expose:e}){e();let r=c(0);const s={get count(){return r},set count(t){r=t},add:()=>r.value=r.value+1,subtract:()=>r.value=r.value-1,ref:c};return Object.defineProperty(s,"__isScriptSetup",{enumerable:!1,value:!0}),s}},m={class:"counter"},b={class:"counter-message"};function g(t,e,s,n,u,c){return p(),l(i,null,[r("div",m,[r("button",{onClick:e[0]||(e[0]=t=>n.subtract())},"-"),r("pre",null,d(n.count),1),r("button",{onClick:e[1]||(e[1]=t=>n.add())},"+")]),r("div",b,[_(t.$slots,"default")])],64)}const S=f(v,[["render",g]]);export{S as default}; \ No newline at end of file diff --git a/_astro/Counter_vue_vue_type_style_index_0_lang.Xc8wNQS-.css b/_astro/Counter_vue_vue_type_style_index_0_lang.Xc8wNQS-.css new file mode 100644 index 0000000..7e5bf96 --- /dev/null +++ b/_astro/Counter_vue_vue_type_style_index_0_lang.Xc8wNQS-.css @@ -0,0 +1 @@ +.counter{display:grid;font-size:2em;grid-template-columns:repeat(3,minmax(0,1fr));margin-top:2em;place-items:center}.counter-message{text-align:center} \ No newline at end of file diff --git a/_astro/advertisement-campaign.bG9wJuyh_Z29in5.webp b/_astro/advertisement-campaign.bG9wJuyh_Z29in5.webp new file mode 100644 index 0000000..e5c98ba Binary files /dev/null and b/_astro/advertisement-campaign.bG9wJuyh_Z29in5.webp differ diff --git a/_astro/api-docs.ESuMSfAc_Z2s1G8E.webp b/_astro/api-docs.ESuMSfAc_Z2s1G8E.webp new file mode 100644 index 0000000..26d13c8 Binary files /dev/null and b/_astro/api-docs.ESuMSfAc_Z2s1G8E.webp differ diff --git a/_astro/astro-is-not-mature.QiHHGqLH.png b/_astro/astro-is-not-mature.QiHHGqLH.png new file mode 100644 index 0000000..f041d18 Binary files /dev/null and b/_astro/astro-is-not-mature.QiHHGqLH.png differ diff --git a/_astro/astro-is-not-mature.QiHHGqLH_ZgGxiq.webp b/_astro/astro-is-not-mature.QiHHGqLH_ZgGxiq.webp new file mode 100644 index 0000000..9dc8670 Binary files /dev/null and b/_astro/astro-is-not-mature.QiHHGqLH_ZgGxiq.webp differ diff --git a/_astro/astro-rehype-pretty-code.ztt2EPt-.jpg b/_astro/astro-rehype-pretty-code.ztt2EPt-.jpg new file mode 100644 index 0000000..2e0ef46 Binary files /dev/null and b/_astro/astro-rehype-pretty-code.ztt2EPt-.jpg differ diff --git a/_astro/astro-rehype-pretty-code.ztt2EPt-_1evpRH.webp b/_astro/astro-rehype-pretty-code.ztt2EPt-_1evpRH.webp new file mode 100644 index 0000000..5c7429e Binary files /dev/null and b/_astro/astro-rehype-pretty-code.ztt2EPt-_1evpRH.webp differ diff --git a/_astro/canny.RsIC7uIE_2574ke.webp b/_astro/canny.RsIC7uIE_2574ke.webp new file mode 100644 index 0000000..ca17b40 Binary files /dev/null and b/_astro/canny.RsIC7uIE_2574ke.webp differ diff --git a/_astro/caos.aIOeqRSl_2g8yCN.webp b/_astro/caos.aIOeqRSl_2g8yCN.webp new file mode 100644 index 0000000..491099b Binary files /dev/null and b/_astro/caos.aIOeqRSl_2g8yCN.webp differ diff --git a/_astro/client.Pkzb8lI9.js b/_astro/client.Pkzb8lI9.js new file mode 100644 index 0000000..53011f3 --- /dev/null +++ b/_astro/client.Pkzb8lI9.js @@ -0,0 +1 @@ +import{i as T,d as m,e as x,f as B,g as D,h as $,j as q,k as L,l as P,m as z,n as G,p as W,q as R,s as j,u as K,v as d,S as F}from"./runtime-core.esm-bundler.QcTl7tfJ.js";const U="http://www.w3.org/2000/svg",X="http://www.w3.org/1998/Math/MathML",u=typeof document<"u"?document:null,b=u&&u.createElement("template"),V={insert:(t,e,n)=>{e.insertBefore(t,n||null)},remove:t=>{const e=t.parentNode;e&&e.removeChild(t)},createElement:(t,e,n,r)=>{const o="svg"===e?u.createElementNS(U,t):"mathml"===e?u.createElementNS(X,t):u.createElement(t,n?{is:n}:void 0);return"select"===t&&r&&null!=r.multiple&&o.setAttribute("multiple",r.multiple),o},createText:t=>u.createTextNode(t),createComment:t=>u.createComment(t),setText:(t,e)=>{t.nodeValue=e},setElementText:(t,e)=>{t.textContent=e},parentNode:t=>t.parentNode,nextSibling:t=>t.nextSibling,querySelector:t=>u.querySelector(t),setScopeId(t,e){t.setAttribute(e,"")},insertStaticContent(t,e,n,r,o,s){const i=n?n.previousSibling:e.lastChild;if(o&&(o===s||o.nextSibling))for(;e.insertBefore(o.cloneNode(!0),n),o!==s&&(o=o.nextSibling););else{b.innerHTML="svg"===r?`${t}`:"mathml"===r?`${t}`:t;const o=b.content;if("svg"===r||"mathml"===r){const t=o.firstChild;for(;t.firstChild;)o.appendChild(t.firstChild);o.removeChild(t)}e.insertBefore(o,n)}return[i?i.nextSibling:e.firstChild,n?n.previousSibling:e.lastChild]}},J=Symbol("_vtc");function Q(t,e,n){const r=t[J];r&&(e=(e?[e,...r]:[...r]).join(" ")),null==e?t.removeAttribute("class"):n?t.setAttribute("class",e):t.className=e}const Y=Symbol("_vod"),Z=Symbol("");function y(t,e,n){const r=t.style,o=r.display,s=m(n);if(n&&!s){if(e&&!m(e))for(const t in e)null==n[t]&&g(r,t,"");for(const t in n)g(r,t,n[t])}else if(s){if(e!==n){const t=r[Z];t&&(n+=";"+t),r.cssText=n}}else e&&t.removeAttribute("style");Y in t&&(r.display=o)}const A=/\s*!important$/;function g(t,e,n){if(L(n))n.forEach((n=>g(t,e,n)));else if(null==n&&(n=""),e.startsWith("--"))t.setProperty(e,n);else{const r=k(t,e);A.test(n)?t.setProperty(P(r),n.replace(A,""),"important"):t[r]=n}}const v=["Webkit","Moz","ms"],h={};function k(t,e){const n=h[e];if(n)return n;let r=z(e);if("filter"!==r&&r in t)return h[e]=r;r=G(r);for(let n=0;nS||(ot.then((()=>S=0)),S=Date.now());function lt(t,e){const n=t=>{if(t._vts){if(t._vts<=n.attached)return}else t._vts=Date.now();j(ft(t,n.value),e,5,[t])};return n.value=t,n.attached=ct(),n}function ft(t,e){if(L(e)){const n=t.stopImmediatePropagation;return t.stopImmediatePropagation=()=>{n.call(t),t._stopped=!0},e.map((t=>e=>!e._stopped&&t&&t(e)))}return e}const w=t=>111===t.charCodeAt(0)&&110===t.charCodeAt(1)&&t.charCodeAt(2)>96&&t.charCodeAt(2)<123,at=(t,e,n,r,o,s,i,l,a)=>{const u="svg"===o;"class"===e?Q(t,r,u):"style"===e?y(t,n,r):$(e)?q(e)||st(t,e,n,r,i):("."===e[0]?(e=e.slice(1),1):"^"===e[0]?(e=e.slice(1),0):ut(t,e,r,u))?et(t,e,r,s,i,l,a):("true-value"===e?t._trueValue=r:"false-value"===e&&(t._falseValue=r),tt(t,e,r,u))};function ut(t,e,n,r){if(r)return!!("innerHTML"===e||"textContent"===e||e in t&&w(e)&&T(n));if("spellcheck"===e||"draggable"===e||"translate"===e||"form"===e||"list"===e&&"INPUT"===t.tagName||"type"===e&&"TEXTAREA"===t.tagName)return!1;if("width"===e||"height"===e){const e=t.tagName;if("IMG"===e||"VIDEO"===e||"CANVAS"===e||"SOURCE"===e)return!1}return(!w(e)||!m(n))&&e in t}const _=D({patchProp:at},V);let p,M=!1;function pt(){return p||(p=x(_))}function dt(){return p=M?p:B(_),M=!0,p}const mt=(...t)=>{const e=pt().createApp(...t),{mount:n}=e;return e.mount=t=>{const r=O(t);if(!r)return;const o=e._component;!T(o)&&!o.render&&!o.template&&(o.template=r.innerHTML),r.innerHTML="";const s=n(r,!1,H(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),s},e},ht=(...t)=>{const e=dt().createApp(...t),{mount:n}=e;return e.mount=t=>{const e=O(t);if(e)return n(e,!0,H(e))},e};function H(t){return t instanceof SVGElement?"svg":"function"==typeof MathMLElement&&t instanceof MathMLElement?"mathml":void 0}function O(t){return m(t)?document.querySelector(t):t}const St=K({props:{value:String,name:String,hydrate:{type:Boolean,default:!0}},setup({name:t,value:e,hydrate:n}){if(!e)return()=>null;let r=n?"astro-slot":"astro-static-slot";return()=>d(r,{name:t,innerHTML:e})}}),gt=()=>{},vt=t=>async(e,n,r,{client:o})=>{if(!t.hasAttribute("ssr"))return;const s=e.name?`${e.name} Host`:void 0,i={};for(const[t,e]of Object.entries(r))i[t]=()=>d(St,{value:e,name:"default"===t?void 0:t});const l="only"!==o,a=(l?ht:mt)({name:s,render(){let t=d(e,n,i);return bt(e.setup)&&(t=d(F,null,t)),t}});await gt(),a.mount(t,l),t.addEventListener("astro:unmount",(()=>a.unmount()),{once:!0})};function bt(t){const e=t?.constructor;return e&&"AsyncFunction"===e.name}export{vt as default}; \ No newline at end of file diff --git a/_astro/clock.-MT4AnEg_1s9RXd.webp b/_astro/clock.-MT4AnEg_1s9RXd.webp new file mode 100644 index 0000000..870d2f9 Binary files /dev/null and b/_astro/clock.-MT4AnEg_1s9RXd.webp differ diff --git a/_astro/colors.to-cfvwg.jpg b/_astro/colors.to-cfvwg.jpg new file mode 100644 index 0000000..e104e62 Binary files /dev/null and b/_astro/colors.to-cfvwg.jpg differ diff --git a/_astro/contours.Nwzb2GkK_Z1RXh5q.webp b/_astro/contours.Nwzb2GkK_Z1RXh5q.webp new file mode 100644 index 0000000..810e3e4 Binary files /dev/null and b/_astro/contours.Nwzb2GkK_Z1RXh5q.webp differ diff --git a/_astro/cover.-NjYdDj0.png b/_astro/cover.-NjYdDj0.png new file mode 100644 index 0000000..685d011 Binary files /dev/null and b/_astro/cover.-NjYdDj0.png differ diff --git a/_astro/cover.-NjYdDj0_Z1TFQ61.webp b/_astro/cover.-NjYdDj0_Z1TFQ61.webp new file mode 100644 index 0000000..5b56dbb Binary files /dev/null and b/_astro/cover.-NjYdDj0_Z1TFQ61.webp differ diff --git a/_astro/cover.QrZOrreP.jpg b/_astro/cover.QrZOrreP.jpg new file mode 100644 index 0000000..df925f9 Binary files /dev/null and b/_astro/cover.QrZOrreP.jpg differ diff --git a/_astro/cover.QrZOrreP_2ajSn5.webp b/_astro/cover.QrZOrreP_2ajSn5.webp new file mode 100644 index 0000000..df925f9 Binary files /dev/null and b/_astro/cover.QrZOrreP_2ajSn5.webp differ diff --git a/_astro/cover.WKUSothi.jpg b/_astro/cover.WKUSothi.jpg new file mode 100644 index 0000000..088a1b3 Binary files /dev/null and b/_astro/cover.WKUSothi.jpg differ diff --git a/_astro/cover.WKUSothi_13qkBJ.webp b/_astro/cover.WKUSothi_13qkBJ.webp new file mode 100644 index 0000000..088a1b3 Binary files /dev/null and b/_astro/cover.WKUSothi_13qkBJ.webp differ diff --git a/_astro/cover.XL1E_SHx.jpg b/_astro/cover.XL1E_SHx.jpg new file mode 100644 index 0000000..2e7d697 Binary files /dev/null and b/_astro/cover.XL1E_SHx.jpg differ diff --git a/_astro/cover.XL1E_SHx_1DDnDq.webp b/_astro/cover.XL1E_SHx_1DDnDq.webp new file mode 100644 index 0000000..98c3fc8 Binary files /dev/null and b/_astro/cover.XL1E_SHx_1DDnDq.webp differ diff --git a/_astro/cover.YiLQ9uM-.jpg b/_astro/cover.YiLQ9uM-.jpg new file mode 100644 index 0000000..bbe6278 Binary files /dev/null and b/_astro/cover.YiLQ9uM-.jpg differ diff --git a/_astro/cover.YiLQ9uM-_1fnUG7.webp b/_astro/cover.YiLQ9uM-_1fnUG7.webp new file mode 100644 index 0000000..b67616a Binary files /dev/null and b/_astro/cover.YiLQ9uM-_1fnUG7.webp differ diff --git a/_astro/cover.dm9k4rqm.png b/_astro/cover.dm9k4rqm.png new file mode 100644 index 0000000..1657f10 Binary files /dev/null and b/_astro/cover.dm9k4rqm.png differ diff --git a/_astro/cover.dm9k4rqm_2m8HpY.webp b/_astro/cover.dm9k4rqm_2m8HpY.webp new file mode 100644 index 0000000..a083105 Binary files /dev/null and b/_astro/cover.dm9k4rqm_2m8HpY.webp differ diff --git a/_astro/cover.gIYyy5r9.jpg b/_astro/cover.gIYyy5r9.jpg new file mode 100644 index 0000000..27aa2b8 Binary files /dev/null and b/_astro/cover.gIYyy5r9.jpg differ diff --git a/_astro/cover.gIYyy5r9_Z1iVJ9L.webp b/_astro/cover.gIYyy5r9_Z1iVJ9L.webp new file mode 100644 index 0000000..7f748f3 Binary files /dev/null and b/_astro/cover.gIYyy5r9_Z1iVJ9L.webp differ diff --git a/_astro/cover.pJiwn1Jn.png b/_astro/cover.pJiwn1Jn.png new file mode 100644 index 0000000..3da4e0e Binary files /dev/null and b/_astro/cover.pJiwn1Jn.png differ diff --git a/_astro/cover.pJiwn1Jn_ZUHXbV.webp b/_astro/cover.pJiwn1Jn_ZUHXbV.webp new file mode 100644 index 0000000..89d6643 Binary files /dev/null and b/_astro/cover.pJiwn1Jn_ZUHXbV.webp differ diff --git a/_astro/creativity.ABq6L3ez.jpg b/_astro/creativity.ABq6L3ez.jpg new file mode 100644 index 0000000..6a07991 Binary files /dev/null and b/_astro/creativity.ABq6L3ez.jpg differ diff --git a/_astro/default.Th1dKr9c_ZJGkcR.webp b/_astro/default.Th1dKr9c_ZJGkcR.webp new file mode 100644 index 0000000..4366122 Binary files /dev/null and b/_astro/default.Th1dKr9c_ZJGkcR.webp differ diff --git a/_astro/design-of-a-web-scraper.4Mx6lAmU.png b/_astro/design-of-a-web-scraper.4Mx6lAmU.png new file mode 100644 index 0000000..ccedc47 Binary files /dev/null and b/_astro/design-of-a-web-scraper.4Mx6lAmU.png differ diff --git a/_astro/design-of-a-web-scraper.4Mx6lAmU_1vjjTJ.webp b/_astro/design-of-a-web-scraper.4Mx6lAmU_1vjjTJ.webp new file mode 100644 index 0000000..e9d8a8b Binary files /dev/null and b/_astro/design-of-a-web-scraper.4Mx6lAmU_1vjjTJ.webp differ diff --git a/_astro/desktop-heatmap-light.5Fw47z4S.png b/_astro/desktop-heatmap-light.5Fw47z4S.png new file mode 100644 index 0000000..181da7e Binary files /dev/null and b/_astro/desktop-heatmap-light.5Fw47z4S.png differ diff --git a/_astro/desktop-kava-bar-profile-images-dark.6ciTntgl.png b/_astro/desktop-kava-bar-profile-images-dark.6ciTntgl.png new file mode 100644 index 0000000..e2b8a2f Binary files /dev/null and b/_astro/desktop-kava-bar-profile-images-dark.6ciTntgl.png differ diff --git a/_astro/desktop-kava-bar-profile-images-light.erRRdTQz_2iL6GH.webp b/_astro/desktop-kava-bar-profile-images-light.erRRdTQz_2iL6GH.webp new file mode 100644 index 0000000..4292e8c Binary files /dev/null and b/_astro/desktop-kava-bar-profile-images-light.erRRdTQz_2iL6GH.webp differ diff --git a/_astro/desktop-map-dark.8MX2EwvB_Zz1aLY.webp b/_astro/desktop-map-dark.8MX2EwvB_Zz1aLY.webp new file mode 100644 index 0000000..5b2a102 Binary files /dev/null and b/_astro/desktop-map-dark.8MX2EwvB_Zz1aLY.webp differ diff --git a/_astro/desktop-user-profile-dark.GGV2KznZ.png b/_astro/desktop-user-profile-dark.GGV2KznZ.png new file mode 100644 index 0000000..c17d9d5 Binary files /dev/null and b/_astro/desktop-user-profile-dark.GGV2KznZ.png differ diff --git a/_astro/desktop-user-profile-light.23mTFfM1.png b/_astro/desktop-user-profile-light.23mTFfM1.png new file mode 100644 index 0000000..7e6a0e7 Binary files /dev/null and b/_astro/desktop-user-profile-light.23mTFfM1.png differ diff --git a/_astro/directus-insights-are-not-mature.hv_fdda1.jpg b/_astro/directus-insights-are-not-mature.hv_fdda1.jpg new file mode 100644 index 0000000..7677bbd Binary files /dev/null and b/_astro/directus-insights-are-not-mature.hv_fdda1.jpg differ diff --git a/_astro/directus-insights-are-not-mature.hv_fdda1_ZgJFJy.webp b/_astro/directus-insights-are-not-mature.hv_fdda1_ZgJFJy.webp new file mode 100644 index 0000000..471ca62 Binary files /dev/null and b/_astro/directus-insights-are-not-mature.hv_fdda1_ZgJFJy.webp differ diff --git a/_astro/do-more.oOEX6txK.jpg b/_astro/do-more.oOEX6txK.jpg new file mode 100644 index 0000000..6ede197 Binary files /dev/null and b/_astro/do-more.oOEX6txK.jpg differ diff --git a/_astro/fastapi-is-not-mature.a-JkHT7u.jpg b/_astro/fastapi-is-not-mature.a-JkHT7u.jpg new file mode 100644 index 0000000..f4d5277 Binary files /dev/null and b/_astro/fastapi-is-not-mature.a-JkHT7u.jpg differ diff --git a/_astro/fastapi-is-not-mature.a-JkHT7u_ZqIc2P.webp b/_astro/fastapi-is-not-mature.a-JkHT7u_ZqIc2P.webp new file mode 100644 index 0000000..a2550d7 Binary files /dev/null and b/_astro/fastapi-is-not-mature.a-JkHT7u_ZqIc2P.webp differ diff --git a/_astro/fields.shCx0lc3_2foAsx.webp b/_astro/fields.shCx0lc3_2foAsx.webp new file mode 100644 index 0000000..e46dd3e Binary files /dev/null and b/_astro/fields.shCx0lc3_2foAsx.webp differ diff --git a/_astro/hero.9Pvf-M2C_Mstom.webp b/_astro/hero.9Pvf-M2C_Mstom.webp new file mode 100644 index 0000000..128be98 Binary files /dev/null and b/_astro/hero.9Pvf-M2C_Mstom.webp differ diff --git a/_astro/homepage.CoD8bUjv.png b/_astro/homepage.CoD8bUjv.png new file mode 100644 index 0000000..3b63cb4 Binary files /dev/null and b/_astro/homepage.CoD8bUjv.png differ diff --git a/_astro/homepage.CoD8bUjv_ZyFl3M.webp b/_astro/homepage.CoD8bUjv_ZyFl3M.webp new file mode 100644 index 0000000..0402e51 Binary files /dev/null and b/_astro/homepage.CoD8bUjv_ZyFl3M.webp differ diff --git a/_astro/honeymoon-beach.uN8VdZ2A_1R65Qp.webp b/_astro/honeymoon-beach.uN8VdZ2A_1R65Qp.webp new file mode 100644 index 0000000..fe36b29 Binary files /dev/null and b/_astro/honeymoon-beach.uN8VdZ2A_1R65Qp.webp differ diff --git a/_astro/integrating-a-new-sms.tW-uRlQb.jpg b/_astro/integrating-a-new-sms.tW-uRlQb.jpg new file mode 100644 index 0000000..ba74644 Binary files /dev/null and b/_astro/integrating-a-new-sms.tW-uRlQb.jpg differ diff --git a/_astro/integrating-a-new-sms.tW-uRlQb_1KE0Mf.webp b/_astro/integrating-a-new-sms.tW-uRlQb_1KE0Mf.webp new file mode 100644 index 0000000..fce7d4b Binary files /dev/null and b/_astro/integrating-a-new-sms.tW-uRlQb_1KE0Mf.webp differ diff --git a/_astro/inter-cyrillic-400-normal.EPgtxUdt.woff2 b/_astro/inter-cyrillic-400-normal.EPgtxUdt.woff2 new file mode 100644 index 0000000..c1c5768 Binary files /dev/null and b/_astro/inter-cyrillic-400-normal.EPgtxUdt.woff2 differ diff --git a/_astro/inter-cyrillic-400-normal.ZlUXyOJU.woff b/_astro/inter-cyrillic-400-normal.ZlUXyOJU.woff new file mode 100644 index 0000000..3dcb4ec Binary files /dev/null and b/_astro/inter-cyrillic-400-normal.ZlUXyOJU.woff differ diff --git a/_astro/inter-cyrillic-ext-400-normal.1Lq01TVb.woff b/_astro/inter-cyrillic-ext-400-normal.1Lq01TVb.woff new file mode 100644 index 0000000..4017466 Binary files /dev/null and b/_astro/inter-cyrillic-ext-400-normal.1Lq01TVb.woff differ diff --git a/_astro/inter-cyrillic-ext-400-normal.hbwVqd76.woff2 b/_astro/inter-cyrillic-ext-400-normal.hbwVqd76.woff2 new file mode 100644 index 0000000..da834bb Binary files /dev/null and b/_astro/inter-cyrillic-ext-400-normal.hbwVqd76.woff2 differ diff --git a/_astro/inter-greek-400-normal.0FfgwSak.woff b/_astro/inter-greek-400-normal.0FfgwSak.woff new file mode 100644 index 0000000..d293f1f Binary files /dev/null and b/_astro/inter-greek-400-normal.0FfgwSak.woff differ diff --git a/_astro/inter-greek-400-normal.YZIAb8Pm.woff2 b/_astro/inter-greek-400-normal.YZIAb8Pm.woff2 new file mode 100644 index 0000000..143a941 Binary files /dev/null and b/_astro/inter-greek-400-normal.YZIAb8Pm.woff2 differ diff --git a/_astro/inter-greek-ext-400-normal.Ofy3y2SD.woff2 b/_astro/inter-greek-ext-400-normal.Ofy3y2SD.woff2 new file mode 100644 index 0000000..5f4ae92 Binary files /dev/null and b/_astro/inter-greek-ext-400-normal.Ofy3y2SD.woff2 differ diff --git a/_astro/inter-greek-ext-400-normal.Y3s2DYXm.woff b/_astro/inter-greek-ext-400-normal.Y3s2DYXm.woff new file mode 100644 index 0000000..1fe9408 Binary files /dev/null and b/_astro/inter-greek-ext-400-normal.Y3s2DYXm.woff differ diff --git a/_astro/inter-latin-400-normal.GLYHyz0Z.woff2 b/_astro/inter-latin-400-normal.GLYHyz0Z.woff2 new file mode 100644 index 0000000..c659f5e Binary files /dev/null and b/_astro/inter-latin-400-normal.GLYHyz0Z.woff2 differ diff --git a/_astro/inter-latin-400-normal.uQMDTJ3r.woff b/_astro/inter-latin-400-normal.uQMDTJ3r.woff new file mode 100644 index 0000000..b3db306 Binary files /dev/null and b/_astro/inter-latin-400-normal.uQMDTJ3r.woff differ diff --git a/_astro/inter-latin-ext-400-normal.94UIUsAk.woff2 b/_astro/inter-latin-ext-400-normal.94UIUsAk.woff2 new file mode 100644 index 0000000..b0d0894 Binary files /dev/null and b/_astro/inter-latin-ext-400-normal.94UIUsAk.woff2 differ diff --git a/_astro/inter-latin-ext-400-normal.qQxCe4FO.woff b/_astro/inter-latin-ext-400-normal.qQxCe4FO.woff new file mode 100644 index 0000000..f719731 Binary files /dev/null and b/_astro/inter-latin-ext-400-normal.qQxCe4FO.woff differ diff --git a/_astro/inter-vietnamese-400-normal.H22tX1kw.woff b/_astro/inter-vietnamese-400-normal.H22tX1kw.woff new file mode 100644 index 0000000..4f2b3b5 Binary files /dev/null and b/_astro/inter-vietnamese-400-normal.H22tX1kw.woff differ diff --git a/_astro/local-first-pwa.rK_Y9eoW.jpg b/_astro/local-first-pwa.rK_Y9eoW.jpg new file mode 100644 index 0000000..f20c81c Binary files /dev/null and b/_astro/local-first-pwa.rK_Y9eoW.jpg differ diff --git a/_astro/local-first-pwa.rK_Y9eoW_Z2gijJ2.webp b/_astro/local-first-pwa.rK_Y9eoW_Z2gijJ2.webp new file mode 100644 index 0000000..142b954 Binary files /dev/null and b/_astro/local-first-pwa.rK_Y9eoW_Z2gijJ2.webp differ diff --git a/_astro/media-management.L-uSppJL_1Orlf9.webp b/_astro/media-management.L-uSppJL_1Orlf9.webp new file mode 100644 index 0000000..440a25b Binary files /dev/null and b/_astro/media-management.L-uSppJL_1Orlf9.webp differ diff --git a/_astro/melebay.2mLTdMb6_13UtHw.webp b/_astro/melebay.2mLTdMb6_13UtHw.webp new file mode 100644 index 0000000..ee11369 Binary files /dev/null and b/_astro/melebay.2mLTdMb6_13UtHw.webp differ diff --git a/_astro/migrating-to-astro.uqXH8hFx.jpg b/_astro/migrating-to-astro.uqXH8hFx.jpg new file mode 100644 index 0000000..dcb7343 Binary files /dev/null and b/_astro/migrating-to-astro.uqXH8hFx.jpg differ diff --git a/_astro/migrating-to-astro.uqXH8hFx_bpkS3.webp b/_astro/migrating-to-astro.uqXH8hFx_bpkS3.webp new file mode 100644 index 0000000..dcb7343 Binary files /dev/null and b/_astro/migrating-to-astro.uqXH8hFx_bpkS3.webp differ diff --git a/_astro/mobile-kava-bar-profile-dark.Anxk9LwO.png b/_astro/mobile-kava-bar-profile-dark.Anxk9LwO.png new file mode 100644 index 0000000..cb79f74 Binary files /dev/null and b/_astro/mobile-kava-bar-profile-dark.Anxk9LwO.png differ diff --git a/_astro/mobile-kava-bar-profile-light.1X7uuKFy_ZKfTM8.webp b/_astro/mobile-kava-bar-profile-light.1X7uuKFy_ZKfTM8.webp new file mode 100644 index 0000000..0dcf99b Binary files /dev/null and b/_astro/mobile-kava-bar-profile-light.1X7uuKFy_ZKfTM8.webp differ diff --git a/_astro/og-tags-facebook.JsgkA1Gs.jpg b/_astro/og-tags-facebook.JsgkA1Gs.jpg new file mode 100644 index 0000000..3e5acbd Binary files /dev/null and b/_astro/og-tags-facebook.JsgkA1Gs.jpg differ diff --git a/_astro/og-tags-facebook.JsgkA1Gs_1WjTY.webp b/_astro/og-tags-facebook.JsgkA1Gs_1WjTY.webp new file mode 100644 index 0000000..3e5acbd Binary files /dev/null and b/_astro/og-tags-facebook.JsgkA1Gs_1WjTY.webp differ diff --git a/_astro/pdf-hyperlinks.9EualOjN.jpg b/_astro/pdf-hyperlinks.9EualOjN.jpg new file mode 100644 index 0000000..73e8d42 Binary files /dev/null and b/_astro/pdf-hyperlinks.9EualOjN.jpg differ diff --git a/_astro/pdf-hyperlinks.9EualOjN_Z1BPRca.webp b/_astro/pdf-hyperlinks.9EualOjN_Z1BPRca.webp new file mode 100644 index 0000000..73e8d42 Binary files /dev/null and b/_astro/pdf-hyperlinks.9EualOjN_Z1BPRca.webp differ diff --git a/_astro/portvila.VN5X9rDs_15pANX.webp b/_astro/portvila.VN5X9rDs_15pANX.webp new file mode 100644 index 0000000..acf5cc9 Binary files /dev/null and b/_astro/portvila.VN5X9rDs_15pANX.webp differ diff --git a/_astro/privacy.RCwE9MyX.css b/_astro/privacy.RCwE9MyX.css new file mode 100644 index 0000000..ac01f83 --- /dev/null +++ b/_astro/privacy.RCwE9MyX.css @@ -0,0 +1 @@ +*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content:""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--aw-font-sans),ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*{scrollbar-color:initial;scrollbar-width:initial}*,:before,:after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-left:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-left:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-left-width:.25rem;border-left-color:var(--tw-prose-quote-borders);quotes:"“""”""‘""’";margin-top:1.6em;margin-bottom:1.6em;padding-left:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px rgb(var(--tw-prose-kbd-shadows)/10%),0 3px rgb(var(--tw-prose-kbd-shadows)/10%);font-size:.875em;border-radius:.3125rem;padding:.1875em .375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding:.8571429em 1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;text-align:left;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-right:.5714286em;padding-bottom:.5714286em;padding-left:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:17 24 39;--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:255 255 255;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgb(0 0 0 / 50%);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-left:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-lg{font-size:1.125rem;line-height:1.7777778}.prose-lg :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em;margin-bottom:1.3333333em}.prose-lg :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.2222222em;line-height:1.4545455;margin-top:1.0909091em;margin-bottom:1.0909091em}.prose-lg :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.6666667em;margin-bottom:1.6666667em;padding-left:1em}.prose-lg :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:2.6666667em;margin-top:0;margin-bottom:.8333333em;line-height:1}.prose-lg :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.6666667em;margin-top:1.8666667em;margin-bottom:1.0666667em;line-height:1.3333333}.prose-lg :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.3333333em;margin-top:1.6666667em;margin-bottom:.6666667em;line-height:1.5}.prose-lg :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.7777778em;margin-bottom:.4444444em;line-height:1.5555556}.prose-lg :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.7777778em;margin-bottom:1.7777778em}.prose-lg :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.7777778em;margin-bottom:1.7777778em}.prose-lg :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-lg :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.7777778em;margin-bottom:1.7777778em}.prose-lg :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;border-radius:.3125rem;padding:.2222222em .4444444em}.prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em}.prose-lg :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8666667em}.prose-lg :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em}.prose-lg :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;line-height:1.75;margin-top:2em;margin-bottom:2em;border-radius:.375rem;padding:1em 1.5em}.prose-lg :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em;margin-bottom:1.3333333em;padding-left:1.5555556em}.prose-lg :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em;margin-bottom:1.3333333em;padding-left:1.5555556em}.prose-lg :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.6666667em;margin-bottom:.6666667em}.prose-lg :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.4444444em}.prose-lg :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.4444444em}.prose-lg :where(.prose-lg>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.8888889em;margin-bottom:.8888889em}.prose-lg :where(.prose-lg>ul>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose-lg>ul>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(.prose-lg>ol>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose-lg>ol>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.8888889em;margin-bottom:.8888889em}.prose-lg :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em;margin-bottom:1.3333333em}.prose-lg :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.6666667em;padding-left:1.5555556em}.prose-lg :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:3.1111111em;margin-bottom:3.1111111em}.prose-lg :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;line-height:1.5}.prose-lg :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:.75em;padding-bottom:.75em;padding-left:.75em}.prose-lg :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.prose-lg :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.prose-lg :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding:.75em}.prose-lg :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.prose-lg :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.prose-lg :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.7777778em;margin-bottom:1.7777778em}.prose-lg :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-lg :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;line-height:1.5;margin-top:1em}.prose-lg :where(.prose-lg>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(.prose-lg>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.text-page{color:var(--aw-color-text-page)}.text-muted{color:var(--aw-color-text-muted)}.bg-light{background-color:var(--aw-color-bg-page)}.btn{color:var(--aw-color-text-page);display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;border-width:1px;--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity));background-color:transparent;padding:.875rem 1.5rem;text-align:center;font-size:1rem;line-height:1.5rem;font-weight:500;line-height:1.375;--tw-shadow:0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,1,1)}.btn:hover{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.btn:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity:1;--tw-ring-color:rgb(227 125 58 / var(--tw-ring-opacity));--tw-ring-offset-width:2px;--tw-ring-offset-color:#F2C4A5}:is(.dark .btn){--tw-border-opacity:1;border-color:rgb(160 151 144/var(--tw-border-opacity));--tw-text-opacity:1;color:rgb(198 192 188/var(--tw-text-opacity))}:is(.dark .btn:hover){--tw-border-opacity:1;border-color:rgb(73 67 63/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(73 67 63/var(--tw-bg-opacity))}@media (min-width:768px){.btn{padding-left:2rem;padding-right:2rem}}.btn-ghost{color:var(--aw-color-text-muted);border-style:none;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.btn-ghost:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}:is(.dark .btn-ghost){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}:is(.dark .btn-ghost:hover){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.btn-primary{border-color:var(--aw-color-primary);background-color:var(--aw-color-primary);font-weight:600;--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.btn-primary:hover{border-color:var(--aw-color-secondary);background-color:var(--aw-color-secondary);--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}:is(.dark .btn-primary){border-color:var(--aw-color-primary);background-color:var(--aw-color-primary);--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}:is(.dark .btn-primary:hover){border-color:var(--aw-color-secondary);background-color:var(--aw-color-secondary)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-0{left:0}.top-0{top:0}.z-40{z-index:40}.col-span-12{grid-column:span 12/span 12}.col-span-6{grid-column:span 6/span 6}.float-right{float:right}.m-auto{margin:auto}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-12{margin-top:3rem;margin-bottom:3rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mb-12{margin-bottom:-3rem}.-mb-6{margin-bottom:-1.5rem}.-ml-1{margin-left:-.25rem}.-ml-1\.5{margin-left:-.375rem}.-ml-2{margin-left:-.5rem}.-mt-0{margin-top:0}.-mt-0\.5{margin-top:-.125rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-32{margin-bottom:8rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-0\.5{margin-left:.125rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mr-5{margin-right:1.25rem}.mr-auto{margin-right:auto}.mt-0{margin-top:0}.mt-10{margin-top:2.5rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-\[1px\]{margin-top:1px}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-0{height:0}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-96{height:24rem}.h-\[calc\(100vh-72px\)\]{height:calc(100vh - 72px)}.h-full{height:100%}.h-screen{height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-7{width:1.75rem}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-screen-lg{max-width:1024px}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-none{flex:none}.flex-shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.basis-1\/2{flex-basis:50%}.scroll-mt-16{scroll-margin-top:4rem}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.gap-y-8{row-gap:2rem}.space-y-10>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2.5rem*calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2.5rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dotted{border-style:dotted}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-orange-200{--tw-border-opacity:1;border-color:rgb(242 196 165/var(--tw-border-opacity))}.border-orange-500{--tw-border-opacity:1;border-color:rgb(227 125 58/var(--tw-border-opacity))}.border-primary{border-color:var(--aw-color-primary)}.border-slate-500{--tw-border-opacity:1;border-color:rgb(160 151 144/var(--tw-border-opacity))}.border-transparent{border-color:transparent}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-orange-200{--tw-bg-opacity:1;background-color:rgb(242 196 165/var(--tw-bg-opacity))}.bg-primary{background-color:var(--aw-color-primary)}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-opacity-50{--tw-bg-opacity:.5}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-cyan-200{--tw-gradient-from:#a5f3fc var(--tw-gradient-from-position);--tw-gradient-to:rgb(165 243 252 / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-orange-500{--tw-gradient-from:#E37D3A var(--tw-gradient-from-position);--tw-gradient-to:rgb(227 125 58 / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.to-cyan-200{--tw-gradient-to:#a5f3fc var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.object-top{-o-object-position:top;object-position:top}.p-16{padding:4rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-10{padding-bottom:2.5rem}.pb-12{padding-bottom:3rem}.pb-2{padding-bottom:.5rem}.pb-8{padding-bottom:2rem}.pb-\[56\.25\%\]{padding-bottom:56.25%}.pl-4{padding-left:1rem}.pr-4{padding-right:1rem}.pt-0{padding-top:0}.pt-1{padding-top:.25rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-justify{text-align:justify}.align-middle{vertical-align:middle}.align-text-bottom{vertical-align:text-bottom}.align-super{vertical-align:super}.font-heading{font-family:var(--aw-font-heading),ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-9xl{font-size:8rem;line-height:1}.text-\[2\.6rem\]{font-size:2.6rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.leading-6{line-height:1.5rem}.leading-none{line-height:1}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-tighter{letter-spacing:-.05em}.tracking-wide{letter-spacing:.025em}.tracking-widest{letter-spacing:.1em}.text-accent{color:var(--aw-color-accent)}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-50{--tw-text-opacity:1;color:rgb(249 250 251/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-primary{color:var(--aw-color-primary)}.text-slate-400{--tw-text-opacity:1;color:rgb(179 172 166/var(--tw-text-opacity))}.text-slate-900{--tw-text-opacity:1;color:rgb(43 39 37/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.mix-blend-multiply{mix-blend-mode:multiply}.mix-blend-screen{mix-blend-mode:screen}.shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring-orange-500{--tw-ring-opacity:1;--tw-ring-color:rgb(227 125 58 / var(--tw-ring-opacity))}.ring-opacity-50{--tw-ring-opacity:.5}.drop-shadow-xl{--tw-drop-shadow:drop-shadow(0 20px 13px rgb(0 0 0 / .03)) drop-shadow(0 8px 5px rgb(0 0 0 / .08));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}#header.scroll{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));--tw-shadow:0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}:is(.dark #header.scroll){--tw-bg-opacity:1;background-color:rgb(43 39 37/var(--tw-bg-opacity))}@media (min-width:768px){#header.scroll{background-color:#ffffffe6;--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}:is(.dark #header.scroll){background-color:#2b2725e6}}.dropdown:hover .dropdown-menu{display:block}[data-icon].icon-light>*{stroke-width:1.2}[data-icon].icon-bold>*{stroke-width:2.4}[data-aw-toggle-menu] path{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}[data-aw-toggle-menu].expanded g>path:first-child{--tw-translate-y:15px;--tw-translate-x:-3px;--tw-rotate:-45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[data-aw-toggle-menu].expanded g>path:last-child{--tw-translate-y:-8px;--tw-translate-x:14px;--tw-rotate:45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[data-rehype-pretty-code-figure]{position:relative;border-radius:.5rem;--tw-bg-opacity:1;background-color:rgb(216 213 210/var(--tw-bg-opacity))}:is(.dark [data-rehype-pretty-code-figure]){--tw-bg-opacity:1;background-color:rgb(103 95 88/var(--tw-bg-opacity))}[data-rehype-pretty-code-figure] :is(:where(pre):not(:where([class~=not-prose],[class~=not-prose] *))){--tw-bg-opacity:1;background-color:rgb(245 244 243/var(--tw-bg-opacity))}:is(.dark [data-rehype-pretty-code-figure] :is(:where(pre):not(:where([class~=not-prose],[class~=not-prose] *)))){--tw-bg-opacity:1;background-color:rgb(43 39 37/var(--tw-bg-opacity))}[data-rehype-pretty-code-title]{padding-top:.25rem;padding-bottom:.25rem;padding-inline-start:.5rem;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:.875rem;line-height:1.25rem;font-weight:400;--tw-text-opacity:1;color:rgb(43 39 37/var(--tw-text-opacity))}:is(.dark [data-rehype-pretty-code-title]){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}[data-rehype-pretty-code-title]+pre{margin:0;border-radius:0;border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem;padding-left:0;padding-right:0;padding-top:.25rem;padding-bottom:.25rem}pre{margin-left:auto;margin-right:auto;overflow:auto;padding:1rem}pre::-webkit-scrollbar-track{background-color:var(--scrollbar-track);border-radius:var(--scrollbar-track-radius)}pre::-webkit-scrollbar-track:hover{background-color:var(--scrollbar-track-hover, var(--scrollbar-track))}pre::-webkit-scrollbar-track:active{background-color:var(--scrollbar-track-active, var(--scrollbar-track-hover, var(--scrollbar-track)))}pre::-webkit-scrollbar-thumb{background-color:var(--scrollbar-thumb);border-radius:var(--scrollbar-thumb-radius)}pre::-webkit-scrollbar-thumb:hover{background-color:var(--scrollbar-thumb-hover, var(--scrollbar-thumb))}pre::-webkit-scrollbar-thumb:active{background-color:var(--scrollbar-thumb-active, var(--scrollbar-thumb-hover, var(--scrollbar-thumb)))}pre::-webkit-scrollbar-corner{background-color:var(--scrollbar-corner);border-radius:var(--scrollbar-corner-radius)}pre::-webkit-scrollbar-corner:hover{background-color:var(--scrollbar-corner-hover, var(--scrollbar-corner))}pre::-webkit-scrollbar-corner:active{background-color:var(--scrollbar-corner-active, var(--scrollbar-corner-hover, var(--scrollbar-corner)))}pre{scrollbar-width:auto;scrollbar-color:var(--scrollbar-thumb, initial) var(--scrollbar-track, initial)}pre::-webkit-scrollbar{display:block;width:var(--scrollbar-width, 16px);height:var(--scrollbar-height, 16px)}pre{--scrollbar-thumb:#D8D5D2;--scrollbar-track-radius:.375rem;--scrollbar-thumb-radius:.125rem;--scrollbar-height:.5rem}:is(.dark pre){--scrollbar-thumb:#49433F}pre [data-line]{margin-left:0;margin-right:0;padding-left:.5rem;padding-right:.5rem;line-height:1.25}pre [data-highlighted-line]{border-left-width:2px;border-left-color:rgb(227 125 58/var(--tw-border-opacity));--tw-border-opacity:.5;background-color:#e37d3a33;padding-inline-start:.375rem}:is(.dark pre [data-highlighted-line]){background-color:#e37d3a1a}pre [data-highlighted-chars]{border-radius:.375rem;border-bottom-width:2px;--tw-border-opacity:.75;--tw-bg-opacity:.3;padding-top:0;padding-bottom:0;padding-left:.375rem;padding-right:.375rem}:is(.dark pre [data-highlighted-chars]){--tw-bg-opacity:.2}pre mark{--tw-border-opacity:1;border-bottom-color:rgb(227 125 58/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(227 125 58/var(--tw-bg-opacity));color:inherit}[data-chars-id="1"]{--tw-border-opacity:1;border-bottom-color:rgb(236 72 153/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(236 72 153/var(--tw-bg-opacity))}[data-chars-id="2"]{--tw-border-opacity:1;border-bottom-color:rgb(16 185 129/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity))}[data-chars-id="3"]{--tw-border-opacity:1;border-bottom-color:rgb(59 130 246/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}[data-rehype-pretty-code-figure] code{display:grid;gap:0;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}[data-rehype-pretty-code-figure] code[data-line-numbers]{counter-reset:line}[data-rehype-pretty-code-figure] code[data-line-numbers]>[data-line]:before{counter-increment:line;content:counter(line);margin-left:.5rem;margin-right:1rem;display:inline-block;width:1rem;text-align:right;--tw-text-opacity:1;color:rgb(179 172 166/var(--tw-text-opacity))}:is(.dark [data-rehype-pretty-code-figure] code[data-line-numbers]>[data-line]):before{--tw-text-opacity:1;color:rgb(103 95 88/var(--tw-text-opacity))}html pre span{color:var(--shiki-light)}html.dark pre span{color:var(--shiki-dark)}:is(.dark .dark\:prose-invert){--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--tw-prose-invert-pre-bg);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}:is(.dark .dark\:bg-dark){--tw-bg-opacity:1;background-color:rgb(43 39 37/var(--tw-bg-opacity))}@media (min-width:768px){:is(.dark .dark\:md\:bg-dark){--tw-bg-opacity:1;background-color:rgb(43 39 37/var(--tw-bg-opacity))}}@media (min-width:1024px){.lg\:prose-xl{font-size:1.25rem;line-height:1.8}.lg\:prose-xl :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em;margin-bottom:1.2em}.lg\:prose-xl :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.2em;line-height:1.5;margin-top:1em;margin-bottom:1em}.lg\:prose-xl :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.6em;margin-bottom:1.6em;padding-left:1.0666667em}.lg\:prose-xl :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:2.8em;margin-top:0;margin-bottom:.8571429em;line-height:1}.lg\:prose-xl :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.8em;margin-top:1.5555556em;margin-bottom:.8888889em;line-height:1.1111111}.lg\:prose-xl :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.5em;margin-top:1.6em;margin-bottom:.6666667em;line-height:1.3333333}.lg\:prose-xl :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.8em;margin-bottom:.6em;line-height:1.6}.lg\:prose-xl :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.lg\:prose-xl :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.lg\:prose-xl :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.lg\:prose-xl :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.lg\:prose-xl :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em;border-radius:.3125rem;padding:.25em .4em}.lg\:prose-xl :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em}.lg\:prose-xl :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8611111em}.lg\:prose-xl :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em}.lg\:prose-xl :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em;line-height:1.7777778;margin-top:2em;margin-bottom:2em;border-radius:.5rem;padding:1.1111111em 1.3333333em}.lg\:prose-xl :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em;margin-bottom:1.2em;padding-left:1.6em}.lg\:prose-xl :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em;margin-bottom:1.2em;padding-left:1.6em}.lg\:prose-xl :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.6em;margin-bottom:.6em}.lg\:prose-xl :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.4em}.lg\:prose-xl :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:.4em}.lg\:prose-xl :where(.lg\:prose-xl>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.8em;margin-bottom:.8em}.lg\:prose-xl :where(.lg\:prose-xl>ul>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em}.lg\:prose-xl :where(.lg\:prose-xl>ul>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.2em}.lg\:prose-xl :where(.lg\:prose-xl>ol>li>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em}.lg\:prose-xl :where(.lg\:prose-xl>ol>li>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.2em}.lg\:prose-xl :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.8em;margin-bottom:.8em}.lg\:prose-xl :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em;margin-bottom:1.2em}.lg\:prose-xl :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.2em}.lg\:prose-xl :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.6em;padding-left:1.6em}.lg\:prose-xl :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2.8em;margin-bottom:2.8em}.lg\:prose-xl :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.lg\:prose-xl :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.lg\:prose-xl :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.lg\:prose-xl :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.lg\:prose-xl :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em;line-height:1.5555556}.lg\:prose-xl :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:.6666667em;padding-bottom:.8888889em;padding-left:.6666667em}.lg\:prose-xl :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.lg\:prose-xl :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.lg\:prose-xl :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding:.8888889em .6666667em}.lg\:prose-xl :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-left:0}.lg\:prose-xl :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-right:0}.lg\:prose-xl :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.lg\:prose-xl :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.lg\:prose-xl :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em;line-height:1.5555556;margin-top:1em}.lg\:prose-xl :where(.lg\:prose-xl>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.lg\:prose-xl :where(.lg\:prose-xl>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}}.first\:rounded-t:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.last\:rounded-b:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.prose-headings\:font-heading :is(:where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *))){font-family:var(--aw-font-heading),ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}.prose-headings\:font-bold :is(:where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *))){font-weight:700}.prose-headings\:tracking-tighter :is(:where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *))){letter-spacing:-.05em}.prose-a\:text-orange-600 :is(:where(a):not(:where([class~=not-prose],[class~=not-prose] *))){--tw-text-opacity:1;color:rgb(200 97 28/var(--tw-text-opacity))}.prose-table\:max-w-10 :is(:where(table):not(:where([class~=not-prose],[class~=not-prose] *))){max-width:2.5rem}.prose-table\:table-auto :is(:where(table):not(:where([class~=not-prose],[class~=not-prose] *))){table-layout:auto}.prose-table\:overflow-x-scroll :is(:where(table):not(:where([class~=not-prose],[class~=not-prose] *))){overflow-x:scroll}.prose-img\:rounded-md :is(:where(img):not(:where([class~=not-prose],[class~=not-prose] *))){border-radius:.375rem}.prose-img\:shadow-lg :is(:where(img):not(:where([class~=not-prose],[class~=not-prose] *))){--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\:text-black:hover{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.hover\:text-primary:hover{color:var(--aw-color-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-4:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-gray-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(229 231 235 / var(--tw-ring-opacity))}@media (prefers-reduced-motion:no-preference){.motion-safe\:scroll-smooth{scroll-behavior:smooth}}:is(.dark .dark\:border){border-width:1px}:is(.dark .dark\:border-blue-700){--tw-border-opacity:1;border-color:rgb(29 78 216/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-500){--tw-border-opacity:1;border-color:rgb(160 151 144/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-600){--tw-border-opacity:1;border-color:rgb(134 123 114/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-700){--tw-border-opacity:1;border-color:rgb(103 95 88/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-800){--tw-border-opacity:1;border-color:rgb(73 67 63/var(--tw-border-opacity))}:is(.dark .dark\:bg-blue-700){--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity))}:is(.dark .dark\:bg-dark){--tw-bg-opacity:1;background-color:rgb(21 22 16/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-500){--tw-bg-opacity:1;background-color:rgb(160 151 144/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-700){--tw-bg-opacity:1;background-color:rgb(103 95 88/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-800){--tw-bg-opacity:1;background-color:rgb(73 67 63/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-900){--tw-bg-opacity:1;background-color:rgb(43 39 37/var(--tw-bg-opacity))}:is(.dark .dark\:text-blue-200){--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity))}:is(.dark .dark\:text-blue-600){--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}:is(.dark .dark\:text-gray-200){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}:is(.dark .dark\:text-gray-300){--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}:is(.dark .dark\:text-gray-400){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-200){--tw-text-opacity:1;color:rgb(216 213 210/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-300){--tw-text-opacity:1;color:rgb(198 192 188/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-400){--tw-text-opacity:1;color:rgb(179 172 166/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-500){--tw-text-opacity:1;color:rgb(160 151 144/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-600){--tw-text-opacity:1;color:rgb(134 123 114/var(--tw-text-opacity))}:is(.dark .dark\:text-white){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}:is(.dark .dark\:shadow-none){--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}:is(.dark .dark\:prose-headings\:text-slate-300 :is(:where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *)))){--tw-text-opacity:1;color:rgb(198 192 188/var(--tw-text-opacity))}:is(.dark .dark\:prose-headings\:text-slate-500 :is(:where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *)))){--tw-text-opacity:1;color:rgb(160 151 144/var(--tw-text-opacity))}:is(.dark .dark\:prose-a\:text-orange-400 :is(:where(a):not(:where([class~=not-prose],[class~=not-prose] *)))){--tw-text-opacity:1;color:rgb(232 149 94/var(--tw-text-opacity))}:is(.dark .dark\:hover\:bg-gray-700:hover){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}:is(.dark .dark\:hover\:bg-gray-800:hover){--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}:is(.dark .dark\:hover\:text-blue-700:hover){--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}:is(.dark .dark\:hover\:text-gray-200:hover){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}:is(.dark .dark\:hover\:text-primary:hover){color:var(--aw-color-primary)}:is(.dark .dark\:hover\:text-slate-300:hover){--tw-text-opacity:1;color:rgb(198 192 188/var(--tw-text-opacity))}:is(.dark .dark\:hover\:text-white:hover){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}:is(.dark .dark\:focus\:ring-gray-700:focus){--tw-ring-opacity:1;--tw-ring-color:rgb(55 65 81 / var(--tw-ring-opacity))}@media (min-width:640px){.sm\:mx-auto{margin-left:auto;margin-right:auto}.sm\:mb-0{margin-bottom:0}.sm\:mt-1{margin-top:.25rem}.sm\:inline{display:inline}.sm\:hidden{display:none}.sm\:w-auto{width:auto}.sm\:max-w-md{max-width:28rem}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-center{justify-content:center}.sm\:gap-8{gap:2rem}.sm\:whitespace-nowrap{white-space:nowrap}.sm\:rounded-md{border-radius:.375rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:px-8{padding-left:2rem;padding-right:2rem}.sm\:py-16{padding-top:4rem;padding-bottom:4rem}.sm\:py-6{padding-top:1.5rem;padding-bottom:1.5rem}.sm\:pl-4{padding-left:1rem}.sm\:text-center{text-align:center}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:leading-none{line-height:1}}@media (min-width:768px){.md\:absolute{position:absolute}.md\:order-1{order:1}.md\:col-span-3{grid-column:span 3/span 3}.md\:-mx-4{margin-left:-1rem;margin-right:-1rem}.md\:-mx-8{margin-left:-2rem;margin-right:-2rem}.md\:mx-5{margin-left:1.25rem;margin-right:1.25rem}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:my-20{margin-top:5rem;margin-bottom:5rem}.md\:-mt-\[76px\]{margin-top:-76px}.md\:mb-0{margin-bottom:0}.md\:mb-12{margin-bottom:3rem}.md\:mb-16{margin-bottom:4rem}.md\:mb-20{margin-bottom:5rem}.md\:ml-4{margin-left:1rem}.md\:mt-0{margin-top:0}.md\:mt-3{margin-top:.75rem}.md\:block{display:block}.md\:inline-block{display:inline-block}.md\:inline{display:inline}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-64{height:16rem}.md\:h-72{height:18rem}.md\:h-auto{height:auto}.md\:h-full{height:100%}.md\:w-auto{width:auto}.md\:min-w-\[200px\]{min-width:200px}.md\:max-w-none{max-width:none}.md\:max-w-sm{max-width:24rem}.md\:basis-1\/2{flex-basis:50%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:flex-row-reverse{flex-direction:row-reverse}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:gap-16{gap:4rem}.md\:gap-8{gap:2rem}.md\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.md\:self-center{align-self:center}.md\:overflow-visible{overflow:visible}.md\:border-r{border-right-width:1px}.md\:bg-white\/90{background-color:#ffffffe6}.md\:object-cover{-o-object-fit:cover;object-fit:cover}.md\:px-24{padding-left:6rem;padding-right:6rem}.md\:px-3{padding-left:.75rem;padding-right:.75rem}.md\:px-4{padding-left:1rem;padding-right:1rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:py-12{padding-top:3rem;padding-bottom:3rem}.md\:py-16{padding-top:4rem;padding-bottom:4rem}.md\:py-20{padding-top:5rem;padding-bottom:5rem}.md\:py-3{padding-top:.75rem;padding-bottom:.75rem}.md\:py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:py-8{padding-top:2rem;padding-bottom:2rem}.md\:pb-16{padding-bottom:4rem}.md\:pb-20{padding-bottom:5rem}.md\:pb-\[75\%\]{padding-bottom:75%}.md\:pl-0{padding-left:0}.md\:pr-16{padding-right:4rem}.md\:pt-0{padding-top:0}.md\:pt-4{padding-top:1rem}.md\:pt-\[76px\]{padding-top:76px}.md\:text-center{text-align:center}.md\:text-2xl{font-size:1.5rem;line-height:2rem}.md\:text-3xl{font-size:1.875rem;line-height:2.25rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-5xl{font-size:3rem;line-height:1}.md\:text-6xl{font-size:3.75rem;line-height:1}.md\:text-base{font-size:1rem;line-height:1.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}.md\:tracking-tight{letter-spacing:-.025em}.md\:backdrop-blur-md{--tw-backdrop-blur:blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.md\:last\:border-none:last-child{border-style:none}.md\:hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}:is(.dark .dark\:md\:border-slate-500){--tw-border-opacity:1;border-color:rgb(160 151 144/var(--tw-border-opacity))}:is(.dark .dark\:md\:bg-dark){--tw-bg-opacity:1;background-color:rgb(21 22 16/var(--tw-bg-opacity))}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-4{grid-column:span 4/span 4}.lg\:m-0{margin:0}.lg\:mb-0{margin-bottom:0}.lg\:mb-20{margin-bottom:5rem}.lg\:inline{display:inline}.lg\:flex{display:flex}.lg\:h-screen{height:100vh}.lg\:w-1\/2{width:50%}.lg\:max-w-2xl{max-width:42rem}.lg\:max-w-6xl{max-width:72rem}.lg\:max-w-7xl{max-width:80rem}.lg\:max-w-md{max-width:28rem}.lg\:max-w-none{max-width:none}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-start{justify-content:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-8{gap:2rem}.lg\:gap-x-16{-moz-column-gap:4rem;column-gap:4rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-0{padding-top:0;padding-bottom:0}.lg\:py-20{padding-top:5rem;padding-bottom:5rem}.lg\:py-8{padding-top:2rem;padding-bottom:2rem}.lg\:pb-\[56\.25\%\]{padding-bottom:56.25%}.lg\:pt-12{padding-top:3rem}.lg\:text-left{text-align:left}.lg\:text-4xl{font-size:2.25rem;line-height:2.5rem}.lg\:text-5xl{font-size:3rem;line-height:1}.lg\:text-base{font-size:1rem;line-height:1.5rem}.lg\:text-sm{font-size:.875rem;line-height:1.25rem}.lg\:shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.lg\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}:is(.dark .dark\:lg\:shadow-orange-200){--tw-shadow-color:#F2C4A5;--tw-shadow:var(--tw-shadow-colored)}}@media (min-width:1280px){.xl\:text-6xl{font-size:3.75rem;line-height:1}}@media (min-width:1536px){.\32xl\:inline-block{display:inline-block}.\32xl\:hidden{display:none}.\32xl\:text-\[20px\]{font-size:20px}}@font-face{font-family:Inter;font-style:normal;font-display:swap;font-weight:400;src:url(/_astro/inter-cyrillic-ext-400-normal.hbwVqd76.woff2)format("woff2"),url(/_astro/inter-cyrillic-ext-400-normal.1Lq01TVb.woff)format("woff");unicode-range:U+0460-052F,U+1C80-1C88,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-display:swap;font-weight:400;src:url(/_astro/inter-cyrillic-400-normal.EPgtxUdt.woff2)format("woff2"),url(/_astro/inter-cyrillic-400-normal.ZlUXyOJU.woff)format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-display:swap;font-weight:400;src:url(/_astro/inter-greek-ext-400-normal.Ofy3y2SD.woff2)format("woff2"),url(/_astro/inter-greek-ext-400-normal.Y3s2DYXm.woff)format("woff");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-display:swap;font-weight:400;src:url(/_astro/inter-greek-400-normal.YZIAb8Pm.woff2)format("woff2"),url(/_astro/inter-greek-400-normal.0FfgwSak.woff)format("woff");unicode-range:U+0370-03FF}@font-face{font-family:Inter;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,d09GMgABAAAAAA6AABAAAAAAIkQAAA4gAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGmQbh2YcKgZgP1NUQVRIAII4EQgKqWikUQuCEgABNgIkA4QgBCAFhBYHIAwHG4MdUZSQ1lKR/ThwyuwFjhaNSE1IHRJNhJc5TuaR//PSZ8crHuzH0Jf8Mrh2rHaeUc1YQsvoawes0Agkpbb49Hxu/rkvLy+P9wKERUgh2LhB2IMECoUvqLijgAvsNWwhYY0QaiBUE0gt7vlfujMtdnWhdGMqbdMxrbjTymiEWNOKxfmfzqe7S1yDKlwKwBedgP/PtU/7cy87s5/KvLMFEpbBkaqsMPm5zWVz2RwUt0S8RVQ352pbRQy2qqwJHaAwFUbUVWhX33xV7wGS3G71+AEvWGYYGEaocUz2X/cqwACA5Jf+HkQAKSKMDDFXDCKOCpEqHZFhFWKNtYhcmxCFihGlNAgtPaJVO2KHHQgC+kjJUZtW5m5G2CoNzQhD9tS2NkOC8BMMCJBYlEBr4UtpB3EODaZ7MJSfdHeOaqZy0H0jpaZeE+c12p9MDWjIF22KxAab1AC5ysrOmOAkfhh/5FbnHE0ItZbUuUVQ4/A7PIJPEkuueg6WEtLbzQcClKKUNVBpUDZfqnOVbXn5ohbjQWMl35l1EfjgLIT1SQyhIkVRiBYjjlKSxZKpLJVjjTyFihSr0KhJsxaUImrF1Ajt2wwJoBdIL4heML059ELohdKT0gunJ6O3nFo+tXdFgUJ6hdRIj/U8n5oTIAKBgkQwozkiJKFQJEXhSCaJpyVF/IEBgC/MASksggS5+KMCWbAK1kIRjZGSACQUYUDhwCOytzgi8RJxlFREkcAChZiWhfzat3WxUqX8ldEQxAONfJRfGqcKcCCUgkgQTY/+gLcsxAd7fT7mwMSzpIVBYMMQY7jHMZYXHd0JYdMJ3Uu63sBHyd1+cLQeSdjQ6fQ8OfCd8X2w+9ZvgiNS/pC7968Y+u7lPWIOurDAPGIEwaLABAosQp2o0xqIP3BkzQYiQdCCCUBGaNjIt2iI1qFz3aUqatQmiaSmY2H3MRICnL2ABER06MjheDZzf95Dr5dIIglFaMlAPJ0ZmI4/buXvnBAoWEib/gsnMxeQMuywRphC8bTmKhVHR2Al2ipCSzGWE8nBWsHHMpxMvCXEsvjK5udfAuQKtF6QDYLlmSNfiAKhNpLaJFwRmWJPKyNXbp4K8z1nga0WUlukUqQqUaop1IhWK4ZGgnqJGig1StJksWbJWqjopWr1jDZpDNK1I0iVYcBFiBGwFtoYdA4AnXdpSwGdqELyGsJsIJ7KI6h1oNhuUsDzE6s4G3LH3BPHPOKfZZsQUpP3StMuS4d87yamJRJ2SAjhqGdkQKgk6sLn0z6UCw0u9+8j9ZOI/WjKLfELFdGjS0RSukJoojuTMsrV9xWbfDvEnZwxCIEBYuK7d/o6sfz4C+dH/PffvEZ1u6Y6OziTkQSANwVBQgdAmKbClB9evO+XHwU916d9bYnFPbe4e6Zc9pft2k1+7/R1E999S0z2TnZYEjrMRZ3bE0uO7tWrPItJ0CuCYCaqSq5vB582ZWQ4UdOeiQlimRxFEYYnxDLB7XFuLjwRi32VRwTDEe4Tp7PwBpe85oRQpQ4wf+64WwvL9WlD93heyHfsYJCunt7BdvG03/sRrgGBJMAy8UBrJzAPt2iLc+yPN/EdnXv+0IqNYNbj3LaSN6/B/G3qvnR90d0sGR81jIjv4ASc03lhT4CcNWLMT5rKxJutmyfhQVun3Miiq9KqqKizUYSP3hmETmIq860xaXxLTKXFHKnja2EqtIA6WpAt5ntErzASTk+NCB/Vo5Mjk5OtlUXrm2uzcasTMiVTVcYXT2pXWMqXzMsVBk2goMoWEOm8mNkIpjq+Vly4uWX2dN+PuK6qhNPsVoqrUnN1TUtj15y6+8z1w4Uevt82CcC9nbABbD3ZsuB06P6RSyeyQLqLdORKu0S+vF/qlkI/63Y/WX/qXd0Dz2HNUseftDs45sPbN88tyZ4e+/xubC/AwgMWQaOZI/qR446dbvdwv7t/ZNaNp9nSpmCyIufXyVe9DENEvDmnYf9Yzn3SFPf1V18RIS9Hr1xsZ/tm/cSL+NH+g9/SPf/17qK6Fx4Icy+Prq61//UXExaXzyOXn+20V3pmbluPH79j7Z55BZZtXTOjBo7qdY7ue6Ms8/PrIsdwdC9kC3rs0vSPJUvIGc715OLNhf/5rXnv5Vesde99VHE5od53b8BQYH58Ots38O67R08eitx0Iy9j77n+35cY1jrmDzdsv3bi9uy4eFvLti/zKq/sczRVfaR23ayRdQd3+m0u499SqbWlpi+6kOSQOxZ9937N7b/fr4769rtXhqrKu14Nvj0LFrdmZa9v3fFpVduVA6f6atmhwWrqlM32ypnXquh33qvhzvQjxSH/OXt0sGp2dlCbemHc+Gveyc+2LZqsGRj7pFtz0d5u3j+2p+mnnb1Dxj8nuw5WvKJqqo/Im85a0HZ06L4aLjCYBQPRKqyoqli2rKpihfDrm4bKLB9POz1U/fufQ7WIHT+5tX/+rgl2cenKWxcPV4reeKPS5+K7b53sdyUCWzT9hEXZLoP71hn+/pEadcHcQ1HMYnePtZRh3+zT/17RkhTD9DtDths7dmJ0fNZxxH72UPEXJV1foobJGX8tb+ysZvi9Lm1mSHeYQPv402jJyqwIlGZc2jKnh9doYwyx2xfsYhFmPSdJpwTFmanY5y39x9bhkTsfcGMBM/7PpeBnLiFx73vG90+3FGbkUuivblOS5W+7+j9ZCaRnRk6IXh/6zpJeFJmXtDkyNX1Dcdbn/v1DJywrSiJXJK2PTE9fW4zkc3C/c+N7xzc/f/+Oe0Nhu05X2o64XYOjLq2wMF6Z+kJSqejVS86DRo2lYj0PFXn0XAybfNY5tWPq8KlTiXx/zllF5Y260vKg5w75T93wz6f657kb6FDxq5yuHROHv/nqWj+urC3lGg1d0rxSu+DjKqo4IiqmY14p33b+QmPRhq6ctSySX3K6Ldc/cHzz8wcWd1ahWqdLl7Do8vYFGsu/NWtLkvKSMuOX5CSbwqst/Zr4NRE5UasjYuOX5UCw0v5qg7tz5dNXuu2Z/jlVtzC0DstWiye3Tn6o4f//nGwYC29A0mqFqcHj/5B/6DE1nDh+ZGSTME8er6yPKhD1DJ/o0JQ25+ewSOkVv5vn4a/A9sipFm1tebqgZ5c6eYWsdjA6Zk3Wwobwgp171DHLw69kh0dd9MiAWGD9ano6KhIEgAgmkk0p8wXGA5hdzAy10OwJM0WbVqBk5osRR2aMXewxtgpR6CBpVKzYmeFUIcxLW0JJfbtDRSmCajSRbJAQqH1sLRlsFoxODCToBkCBJcE6giVBTk0CNVgSxpTcp1s9hUfBAe8nBFZVnX6Pq9wv0MMAjgiJ9JZJF6kp2ILAgyOCjDgiGO/hiGCocESIfMJiYW6S7aSPovRGuHlNDrOT6CnEBKjYPGzWYtNs9t5igBwVVwuMWpzXobiJx3FwqBQPbFYpLybMaCng0lJgBp5NYoKdyrgTwzIqbwV5tWVsZmxaDJKZ3T6h6NvjEWiqVQeGTWt1QWa86EjN9GWwa9nYwB1sM4EawOreL2WvAAqiKOd+HhNehVrnBaO2DrSUkjDoNGbNngDoNNaTMrYsGMTr0CoCtinPSLwPgS2ZuTufjAFqy9s6YIa2GWyqxKN6lbUsMKoSDkjglCwl6qZWAnZcErULl8yLc5FQ2nY7BlUXBsbbA6vGaZ5OOGL8Tl2TpOHEstgw/ouhdHXgNaxTUzwBC48qUedssys702wW0ZjXztyQjbf58k+bgT+Q9WTnl97/Pbzhbfnn7Scn7RQAYDUgUnCiP6EVqxvxYvTM5GVQQclyKpecNGuY006PhjaqbBYX8CC10M3qQIgXBzjVQMwtXuZ156RMt87mc9lJ84zAaWEFuty6S4Aa5MUYe0JTMhY4IplEI6xMr1thXlwymZbrpCHAaZNhy7kBEC/TsSoLwQhsHzxet0hZM+HxjDRmQcRCqTbsI24gJmdZCRqxXS1Iy3IDim0tsJQKVMFWbmF5tOg+NLGMpxlpo1G2zsyIj4zSar2fRMmyM8E+uU5aiUzYIMRkT3n8LFSuEAKXPQUKigCrGoix+8i8wzkp01IaGQHDkbhYrXcABEZWBRdA9fqIW9WBeNhshBiEGBTAjPnIvKMKUVgRYqAziNbL6WMHhDAjqcLwfYwBLu+scB98LiKNGYBYKNVGJGEHYnKWlWAYJgfqxw6ho2RJiAGxML4XVF47iLJZgAAQ0D/9kN91eKt/5h02nAcAuP/+OhwAftqSP3Fv6lGXrF4MAAwoAAAB/leYEGEu6LdvLwcE49r19v0Jj0iP1QmpSn91g8x47V198MzjdesRqBl46ClYJUIFjrFqHOmxlq66VNZcCnh8rAQMTE8QtRXlY3U9MirY5u/9ZOp1opTaebzDaJ/9OIIeLoHEOyBUu1025EUfl2Cb1ztKM+pD2hq4kIufJoaayqEujvZ4H3UaKeURkhEhMC40w0n/CTfwqBx4JH3ZIlo5Cd3S3UJNhaz6bfpZOUAA9Oojhh6it44U0v54Fns0pMMHcJOTQXBsBsXPG4aA0hGDFsZlCKX622CEpcjnR9WyIog/xPG8IcFFtwyNPtqNWuh1aKVTR8tAbjFlai63eYJqj3aNZga1Wsnla9WiXq1q496Xameg1aJVGzlFAIHQqxGflShRHV1xL9pVSVCtRZNEIiLaKF4X7I2u8i1uVKtOu0aVWiVLoJQkXYaNNllvkwzjdOJ342ZLJpeFWF0bnRbN5KFskR/SeEqVUiRRSlVLqaoK2ygbWrapbqPQD6KNaQ3RKLQxoq40dp6VchWpbbXctCQa3EhgrN9/JYcBX0ade0ZfAwAAAA==)format("woff2"),url(/_astro/inter-vietnamese-400-normal.H22tX1kw.woff)format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-display:swap;font-weight:400;src:url(/_astro/inter-latin-ext-400-normal.94UIUsAk.woff2)format("woff2"),url(/_astro/inter-latin-ext-400-normal.qQxCe4FO.woff)format("woff");unicode-range:U+0100-02AF,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-display:swap;font-weight:400;src:url(/_astro/inter-latin-400-normal.GLYHyz0Z.woff2)format("woff2"),url(/_astro/inter-latin-400-normal.uQMDTJ3r.woff)format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}img{content-visibility:auto} \ No newline at end of file diff --git a/_astro/profile.wjofgnUW_1K14HU.webp b/_astro/profile.wjofgnUW_1K14HU.webp new file mode 100644 index 0000000..db558a2 Binary files /dev/null and b/_astro/profile.wjofgnUW_1K14HU.webp differ diff --git a/_astro/pwa-outbox.aZH5qVf2.jpg b/_astro/pwa-outbox.aZH5qVf2.jpg new file mode 100644 index 0000000..af4f1cc Binary files /dev/null and b/_astro/pwa-outbox.aZH5qVf2.jpg differ diff --git a/_astro/pwa-outbox.aZH5qVf2_1IPPFz.webp b/_astro/pwa-outbox.aZH5qVf2_1IPPFz.webp new file mode 100644 index 0000000..af4f1cc Binary files /dev/null and b/_astro/pwa-outbox.aZH5qVf2_1IPPFz.webp differ diff --git a/_astro/renewable-energy-plot.Ect2AIbQ_Z19PwNC.webp b/_astro/renewable-energy-plot.Ect2AIbQ_Z19PwNC.webp new file mode 100644 index 0000000..6b9342c Binary files /dev/null and b/_astro/renewable-energy-plot.Ect2AIbQ_Z19PwNC.webp differ diff --git a/_astro/report-samples.oxUMioBf_Z1J6zh5.webp b/_astro/report-samples.oxUMioBf_Z1J6zh5.webp new file mode 100644 index 0000000..3a8f63c Binary files /dev/null and b/_astro/report-samples.oxUMioBf_Z1J6zh5.webp differ diff --git a/_astro/results.otBIbosP.webm b/_astro/results.otBIbosP.webm new file mode 100644 index 0000000..1151d26 Binary files /dev/null and b/_astro/results.otBIbosP.webm differ diff --git a/_astro/roi.wE8OWjIy_1QKTaO.webp b/_astro/roi.wE8OWjIy_1QKTaO.webp new file mode 100644 index 0000000..ea395dc Binary files /dev/null and b/_astro/roi.wE8OWjIy_1QKTaO.webp differ diff --git a/_astro/runtime-core.esm-bundler.QcTl7tfJ.js b/_astro/runtime-core.esm-bundler.QcTl7tfJ.js new file mode 100644 index 0000000..d4838b4 --- /dev/null +++ b/_astro/runtime-core.esm-bundler.QcTl7tfJ.js @@ -0,0 +1 @@ +function ws(e,t){const n=new Set(e.split(","));return t?e=>n.has(e.toLowerCase()):e=>n.has(e)}const z={},Qe=[],be=()=>{},xr=()=>!1,Kt=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),_n=e=>e.startsWith("onUpdate:"),ie=Object.assign,Es=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},wr=Object.prototype.hasOwnProperty,W=(e,t)=>wr.call(e,t),$=Array.isArray,Xe=e=>"[object Map]"===Dt(e),mn=e=>"[object Set]"===Dt(e),U=e=>"function"==typeof e,ue=e=>"string"==typeof e,ot=e=>"symbol"==typeof e,ee=e=>null!==e&&"object"==typeof e,yn=e=>(ee(e)||U(e))&&U(e.then)&&U(e.catch),bn=Object.prototype.toString,Dt=e=>bn.call(e),Er=e=>Dt(e).slice(8,-1),xn=e=>"[object Object]"===Dt(e),Ts=e=>ue(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,ut=ws(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Wt=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},Tr=/-(\w)/g,ht=Wt((e=>e.replace(Tr,((e,t)=>t?t.toUpperCase():"")))),Cr=/\B([A-Z])/g,qt=Wt((e=>e.replace(Cr,"-$1").toLowerCase())),vr=Wt((e=>e.charAt(0).toUpperCase()+e.slice(1))),ts=Wt((e=>e?`on${vr(e)}`:"")),Se=(e,t)=>!Object.is(e,t),ss=(e,t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,value:n})},Fr=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Ir=e=>{const t=ue(e)?Number(e):NaN;return isNaN(t)?e:t};let Gs;const wn=()=>Gs||(Gs=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Cs(e){if($(e)){const t={};for(let n=0;n{if(e){const n=e.split(Pr);n.length>1&&(t[n[0].trim()]=n[1].trim())}})),t}function vs(e){let t="";if(ue(e))t=e;else if($(e))for(let n=0;nue(e)?e:null==e?"":$(e)||ee(e)&&(e.toString===bn||!U(e.toString))?JSON.stringify(e,En,2):String(e),En=(e,t)=>t&&t.__v_isRef?En(e,t.value):Xe(t)?{[`Map(${t.size})`]:[...t.entries()].reduce(((e,[t,n],s)=>(e[ns(t,s)+" =>"]=n,e)),{})}:mn(t)?{[`Set(${t.size})`]:[...t.values()].map((e=>ns(e)))}:ot(t)?ns(t):!ee(t)||$(t)||xn(t)?t:String(t),ns=(e,t="")=>{var n;return ot(e)?`Symbol(${null!=(n=e.description)?n:t})`:e};let Ee,De;class Br{constructor(e=!1){this.detached=e,this._active=!0,this.effects=[],this.cleanups=[],this.parent=Ee,!e&&Ee&&(this.index=(Ee.scopes||(Ee.scopes=[])).push(this)-1)}get active(){return this._active}run(e){if(this._active){const t=Ee;try{return Ee=this,e()}finally{Ee=t}}}on(){Ee=this}off(){Ee=this.parent}stop(e){if(this._active){let t,n;for(t=0,n=this.effects.length;t=2))break}this._dirtyLevel<2&&(this._dirtyLevel=0),Ge()}return this._dirtyLevel>=2}set dirty(e){this._dirtyLevel=e?2:0}run(){if(this._dirtyLevel=0,!this.active)return this.fn();let e=je,t=De;try{return je=!0,De=this,this._runnings++,Js(this),this.fn()}finally{Ys(this),this._runnings--,De=t,je=e}}stop(){var e;this.active&&(Js(this),Ys(this),null==(e=this.onStop)||e.call(this),this.active=!1)}}function Nr(e){return e.value}function Js(e){e._trackId++,e._depsLength=0}function Ys(e){if(e.deps&&e.deps.length>e._depsLength){for(let t=e._depsLength;t{const n=new Map;return n.cleanup=e,n.computed=t,n},us=new WeakMap,We=Symbol(""),as=Symbol("");function he(e,t,n){if(je&&De){let t=us.get(e);t||us.set(e,t=new Map);let s=t.get(n);s||t.set(n,s=On((()=>t.delete(n)))),vn(De,s)}}function Re(e,t,n,s,r,o){const l=us.get(e);if(!l)return;let i=[];if("clear"===t)i=[...l.values()];else if("length"===n&&$(e)){const e=Number(s);l.forEach(((t,n)=>{("length"===n||!ot(n)&&n>=e)&&i.push(t)}))}else switch(void 0!==n&&i.push(l.get(n)),t){case"add":$(e)?Ts(n)&&i.push(l.get("length")):(i.push(l.get(We)),Xe(e)&&i.push(l.get(as)));break;case"delete":$(e)||(i.push(l.get(We)),Xe(e)&&i.push(l.get(as)));break;case"set":Xe(e)&&i.push(l.get(We))}Is();for(const e of i)e&&Fn(e,2);Os()}const $r=ws("__proto__,__v_isRef,__isVue"),Pn=new Set(Object.getOwnPropertyNames(Symbol).filter((e=>"arguments"!==e&&"caller"!==e)).map((e=>Symbol[e])).filter(ot)),Zs=jr();function jr(){const e={};return["includes","indexOf","lastIndexOf"].forEach((t=>{e[t]=function(...e){const n=q(this);for(let e=0,t=this.length;e{e[t]=function(...e){qe(),Is();const n=q(this)[t].apply(this,e);return Os(),Ge(),n}})),e}function Ur(e){const t=q(this);return he(t,"has",e),t.hasOwnProperty(e)}class Rn{constructor(e=!1,t=!1){this._isReadonly=e,this._shallow=t}get(e,t,n){const s=this._isReadonly,r=this._shallow;if("__v_isReactive"===t)return!s;if("__v_isReadonly"===t)return s;if("__v_isShallow"===t)return r;if("__v_raw"===t)return n===(s?r?Xr:Hn:r?Bn:An).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;const o=$(e);if(!s){if(o&&W(Zs,t))return Reflect.get(Zs,t,n);if("hasOwnProperty"===t)return Ur}const l=Reflect.get(e,t,n);return(ot(t)?Pn.has(t):$r(t))||(s||he(e,"get",t),r)?l:ge(l)?o&&Ts(t)?l:l.value:ee(l)?s?Ln(l):Ms(l):l}}class Mn extends Rn{constructor(e=!1){super(!1,e)}set(e,t,n,s){let r=e[t];if(!this._shallow){const t=nt(r);if(!Nt(n)&&!nt(n)&&(r=q(r),n=q(n)),!$(e)&&ge(r)&&!ge(n))return!t&&(r.value=n,!0)}const o=$(e)&&Ts(t)?Number(t)e,Gt=e=>Reflect.getPrototypeOf(e);function Tt(e,t,n=!1,s=!1){const r=q(e=e.__v_raw),o=q(t);n||(Se(t,o)&&he(r,"get",t),he(r,"get",o));const{has:l}=Gt(r),i=s?Ps:n?Bs:gt;return l.call(r,t)?i(e.get(t)):l.call(r,o)?i(e.get(o)):void(e!==r&&e.get(t))}function Ct(e,t=!1){const n=this.__v_raw,s=q(n),r=q(e);return t||(Se(e,r)&&he(s,"has",e),he(s,"has",r)),e===r?n.has(e):n.has(e)||n.has(r)}function vt(e,t=!1){return e=e.__v_raw,!t&&he(q(e),"iterate",We),Reflect.get(e,"size",e)}function Qs(e){e=q(e);const t=q(this);return Gt(t).has.call(t,e)||(t.add(e),Re(t,"add",e,e)),this}function Xs(e,t){t=q(t);const n=q(this),{has:s,get:r}=Gt(n);let o=s.call(n,e);o||(e=q(e),o=s.call(n,e));const l=r.call(n,e);return n.set(e,t),o?Se(t,l)&&Re(n,"set",e,t):Re(n,"add",e,t),this}function zs(e){const t=q(this),{has:n,get:s}=Gt(t);let r=n.call(t,e);r||(e=q(e),r=n.call(t,e)),s&&s.call(t,e);const o=t.delete(e);return r&&Re(t,"delete",e,void 0),o}function en(){const e=q(this),t=0!==e.size,n=e.clear();return t&&Re(e,"clear",void 0,void 0),n}function Ft(e,t){return function(n,s){const r=this,o=r.__v_raw,l=q(o),i=t?Ps:e?Bs:gt;return!e&&he(l,"iterate",We),o.forEach(((e,t)=>n.call(s,i(e),i(t),r)))}}function It(e,t,n){return function(...s){const r=this.__v_raw,o=q(r),l=Xe(o),i="entries"===e||e===Symbol.iterator&&l,c="keys"===e&&l,a=r[e](...s),u=n?Ps:t?Bs:gt;return!t&&he(o,"iterate",c?as:We),{next(){const{value:e,done:t}=a.next();return t?{value:e,done:t}:{value:i?[u(e[0]),u(e[1])]:u(e),done:t}},[Symbol.iterator](){return this}}}}function Be(e){return function(...t){return"delete"!==e&&("clear"===e?void 0:this)}}function Dr(){const e={get(e){return Tt(this,e)},get size(){return vt(this)},has:Ct,add:Qs,set:Xs,delete:zs,clear:en,forEach:Ft(!1,!1)},t={get(e){return Tt(this,e,!1,!0)},get size(){return vt(this)},has:Ct,add:Qs,set:Xs,delete:zs,clear:en,forEach:Ft(!1,!0)},n={get(e){return Tt(this,e,!0)},get size(){return vt(this,!0)},has(e){return Ct.call(this,e,!0)},add:Be("add"),set:Be("set"),delete:Be("delete"),clear:Be("clear"),forEach:Ft(!0,!1)},s={get(e){return Tt(this,e,!0,!0)},get size(){return vt(this,!0)},has(e){return Ct.call(this,e,!0)},add:Be("add"),set:Be("set"),delete:Be("delete"),clear:Be("clear"),forEach:Ft(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach((r=>{e[r]=It(r,!1,!1),n[r]=It(r,!0,!1),t[r]=It(r,!1,!0),s[r]=It(r,!0,!0)})),[e,n,t,s]}const[Wr,qr,Gr,Jr]=Dr();function Rs(e,t){const n=t?e?Jr:Gr:e?qr:Wr;return(t,s,r)=>"__v_isReactive"===s?!e:"__v_isReadonly"===s?e:"__v_raw"===s?t:Reflect.get(W(n,s)&&s in t?n:t,s,r)}const Yr={get:Rs(!1,!1)},Zr={get:Rs(!1,!0)},Qr={get:Rs(!0,!1)},An=new WeakMap,Bn=new WeakMap,Hn=new WeakMap,Xr=new WeakMap;function zr(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function el(e){return e.__v_skip||!Object.isExtensible(e)?0:zr(Er(e))}function Ms(e){return nt(e)?e:As(e,!1,kr,Yr,An)}function tl(e){return As(e,!1,Kr,Zr,Bn)}function Ln(e){return As(e,!0,Vr,Qr,Hn)}function As(e,t,n,s,r){if(!ee(e)||e.__v_raw&&(!t||!e.__v_isReactive))return e;const o=r.get(e);if(o)return o;const l=el(e);if(0===l)return e;const i=new Proxy(e,2===l?s:n);return r.set(e,i),i}function ze(e){return nt(e)?ze(e.__v_raw):!(!e||!e.__v_isReactive)}function nt(e){return!(!e||!e.__v_isReadonly)}function Nt(e){return!(!e||!e.__v_isShallow)}function Nn(e){return ze(e)||nt(e)}function q(e){const t=e&&e.__v_raw;return t?q(t):e}function $n(e){return Lt(e,"__v_skip",!0),e}const gt=e=>ee(e)?Ms(e):e,Bs=e=>ee(e)?Ln(e):e;class jn{constructor(e,t,n,s){this._setter=t,this.dep=void 0,this.__v_isRef=!0,this.__v_isReadonly=!1,this.effect=new Fs((()=>e(this._value)),(()=>Mt(this,1)),(()=>this.dep&&In(this.dep))),this.effect.computed=this,this.effect.active=this._cacheable=!s,this.__v_isReadonly=n}get value(){const e=q(this);return(!e._cacheable||e.effect.dirty)&&Se(e._value,e._value=e.effect.run())&&Mt(e,2),Un(e),e.effect._dirtyLevel>=1&&Mt(e,1),e._value}set value(e){this._setter(e)}get _dirty(){return this.effect.dirty}set _dirty(e){this.effect.dirty=e}}function sl(e,t,n=!1){let s,r;const o=U(e);return o?(s=e,r=be):(s=e.get,r=e.set),new jn(s,r,o||!r,n)}function Un(e){je&&De&&(e=q(e),vn(De,e.dep||(e.dep=On((()=>e.dep=void 0),e instanceof jn?e:void 0))))}function Mt(e,t=2,n){const s=(e=q(e)).dep;s&&Fn(s,t)}function ge(e){return!(!e||!0!==e.__v_isRef)}function Ti(e){return nl(e,!1)}function nl(e,t){return ge(e)?e:new rl(e,t)}class rl{constructor(e,t){this.__v_isShallow=t,this.dep=void 0,this.__v_isRef=!0,this._rawValue=t?e:q(e),this._value=t?e:gt(e)}get value(){return Un(this),this._value}set value(e){const t=this.__v_isShallow||Nt(e)||nt(e);e=t?e:q(e),Se(e,this._rawValue)&&(this._rawValue=e,this._value=t?e:gt(e),Mt(this,2))}}function ll(e){return ge(e)?e.value:e}const il={get:(e,t,n)=>ll(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const r=e[t];return ge(r)&&!ge(n)?(r.value=n,!0):Reflect.set(e,t,n,s)}};function Sn(e){return ze(e)?e:new Proxy(e,il)}function Ue(e,t,n,s){let r;try{r=s?e(...s):e()}catch(e){bt(e,t,n)}return r}function Ie(e,t,n,s){if(U(e)){const r=Ue(e,t,n,s);return r&&yn(r)&&r.catch((e=>{bt(e,t,n)})),r}const r=[];for(let o=0;o>>1,r=re[s],o=_t(r);oFe&&re.splice(t,1)}function hs(e){$(e)?et.push(...e):(!Le||!Le.includes(e,e.allowRecurse?Ke+1:Ke))&&et.push(e),Vn()}function tn(e,t,n=(pt?Fe+1:0)){for(;n_t(e)-_t(t)));if(et.length=0,Le)return void Le.push(...e);for(Le=e,Ke=0;Kenull==e.id?1/0:e.id,al=(e,t)=>{const n=_t(e)-_t(t);if(0===n){if(e.pre&&!t.pre)return-1;if(t.pre&&!e.pre)return 1}return n};function Kn(e){ds=!1,pt=!0,re.sort(al);try{for(Fe=0;Feue(e)?e.trim():e))),t&&(r=n.map(Fr))}let i,c=s[i=ts(t)]||s[i=ts(ht(t))];!c&&o&&(c=s[i=ts(qt(t))]),c&&Ie(c,e,6,r);const a=s[i+"Once"];if(a){if(e.emitted){if(e.emitted[i])return}else e.emitted={};e.emitted[i]=!0,Ie(a,e,6,r)}}function Dn(e,t,n=!1){const s=t.emitsCache,r=s.get(e);if(void 0!==r)return r;const o=e.emits;let l={},i=!1;if(!U(e)){const s=e=>{const n=Dn(e,t,!0);n&&(i=!0,ie(l,n))};!n&&t.mixins.length&&t.mixins.forEach(s),e.extends&&s(e.extends),e.mixins&&e.mixins.forEach(s)}return o||i?($(o)?o.forEach((e=>l[e]=null)):ie(l,o),ee(e)&&s.set(e,l),l):(ee(e)&&s.set(e,null),null)}function Jt(e,t){return!(!e||!Kt(t))&&(t=t.slice(2).replace(/Once$/,""),W(e,t[0].toLowerCase()+t.slice(1))||W(e,qt(t))||W(e,t))}let de=null,Wn=null;function jt(e){const t=de;return de=e,Wn=e&&e.type.__scopeId||null,t}function hl(e,t=de,n){if(!t||e._n)return e;const s=(...n)=>{s._d&&hn(-1);const r=jt(t);let o;try{o=e(...n)}finally{jt(r),s._d&&hn(1)}return o};return s._n=!0,s._c=!0,s._d=!0,s}function rs(e){const{type:t,vnode:n,proxy:s,withProxy:r,props:o,propsOptions:[l],slots:i,attrs:c,emit:a,render:u,renderCache:d,data:p,setupState:f,ctx:h,inheritAttrs:g}=e;let v,_;const m=jt(e);try{if(4&n.shapeFlag){const e=r||s,t=e;v=ye(u.call(t,e,d,o,f,p,h)),_=c}else{const e=t;v=ye(e.length>1?e(o,{attrs:c,slots:i,emit:a}):e(o,null)),_=t.props?c:pl(c)}}catch(t){dt.length=0,bt(t,e,1),v=le(Me)}let y=v;if(_&&!1!==g){const e=Object.keys(_),{shapeFlag:t}=y;e.length&&7&t&&(l&&e.some(_n)&&(_=_l(_,l)),y=it(y,_))}return n.dirs&&(y=it(y),y.dirs=y.dirs?y.dirs.concat(n.dirs):n.dirs),n.transition&&(y.transition=n.transition),v=y,jt(m),v}function gl(e,t=!0){let n;for(let t=0;t{let t;for(const n in e)("class"===n||"style"===n||Kt(n))&&((t||(t={}))[n]=e[n]);return t},_l=(e,t)=>{const n={};for(const s in e)(!_n(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function ml(e,t,n){const{props:s,children:r,component:o}=e,{props:l,children:i,patchFlag:c}=t,a=o.emitsOptions;if(t.dirs||t.transition)return!0;if(!(n&&c>=0))return!(!r&&!i||i&&i.$stable)||s!==l&&(s?!l||sn(s,l,a):!!l);if(1024&c)return!0;if(16&c)return s?sn(s,l,a):!!l;if(8&c){const e=t.dynamicProps;for(let t=0;te.__isSuspense;let gs=0;const xl={name:"Suspense",__isSuspense:!0,process(e,t,n,s,r,o,l,i,c,a){if(null==e)wl(t,n,s,r,o,l,i,c,a);else{if(o&&o.deps>0)return void(t.suspense=e.suspense);El(e,t,n,s,r,l,i,c,a)}},hydrate:Tl,create:$s,normalize:Cl},Ci=xl;function mt(e,t){const n=e.props&&e.props[t];U(n)&&n()}function wl(e,t,n,s,r,o,l,i,c){const{p:a,o:{createElement:u}}=c,d=u("div"),p=e.suspense=$s(e,r,s,t,d,n,o,l,i,c);a(null,p.pendingBranch=e.ssContent,d,null,s,p,o,l),p.deps>0?(mt(e,"onPending"),mt(e,"onFallback"),a(null,e.ssFallback,t,n,s,null,o,l),tt(p,e.ssFallback)):p.resolve(!1,!0)}function El(e,t,n,s,r,o,l,i,{p:c,um:a,o:{createElement:u}}){const d=t.suspense=e.suspense;d.vnode=t,t.el=e.el;const p=t.ssContent,f=t.ssFallback,{activeBranch:h,pendingBranch:g,isInFallback:v,isHydrating:_}=d;if(g)d.pendingBranch=p,$e(p,g)?(c(g,p,d.hiddenContainer,null,r,d,o,l,i),d.deps<=0?d.resolve():v&&(_||(c(h,f,n,s,r,null,o,l,i),tt(d,f)))):(d.pendingId=gs++,_?(d.isHydrating=!1,d.activeBranch=g):a(g,r,d),d.deps=0,d.effects.length=0,d.hiddenContainer=u("div"),v?(c(null,p,d.hiddenContainer,null,r,d,o,l,i),d.deps<=0?d.resolve():(c(h,f,n,s,r,null,o,l,i),tt(d,f))):h&&$e(p,h)?(c(h,p,n,s,r,d,o,l,i),d.resolve(!0)):(c(null,p,d.hiddenContainer,null,r,d,o,l,i),d.deps<=0&&d.resolve()));else if(h&&$e(p,h))c(h,p,n,s,r,d,o,l,i),tt(d,p);else if(mt(t,"onPending"),d.pendingBranch=p,512&p.shapeFlag?d.pendingId=p.component.suspenseId:d.pendingId=gs++,c(null,p,d.hiddenContainer,null,r,d,o,l,i),d.deps<=0)d.resolve();else{const{timeout:e,pendingId:t}=d;e>0?setTimeout((()=>{d.pendingId===t&&d.fallback(f)}),e):0===e&&d.fallback(f)}}function $s(e,t,n,s,r,o,l,i,c,a,u=!1){const{p:d,m:p,um:f,n:h,o:{parentNode:g,remove:v}}=a;let _;const m=vl(e);m&&t?.pendingBranch&&(_=t.pendingId,t.deps++);const y=e.props?Ir(e.props.timeout):void 0,b=o,x={vnode:e,parent:t,parentComponent:n,namespace:l,container:s,hiddenContainer:r,deps:0,pendingId:gs++,timeout:"number"==typeof y?y:-1,activeBranch:null,pendingBranch:null,isInFallback:!u,isHydrating:u,isUnmounted:!1,effects:[],resolve(e=!1,n=!1){const{vnode:s,activeBranch:r,pendingBranch:l,pendingId:i,effects:c,parentComponent:a,container:u}=x;let d=!1;x.isHydrating?x.isHydrating=!1:e||(d=r&&l.transition&&"out-in"===l.transition.mode,d&&(r.transition.afterLeave=()=>{i===x.pendingId&&(p(l,u,o===b?h(r):o,0),hs(c))}),r&&(g(r.el)!==x.hiddenContainer&&(o=h(r)),f(r,a,x,!0)),d||p(l,u,o,0)),tt(x,l),x.pendingBranch=null,x.isInFallback=!1;let v=x.parent,y=!1;for(;v;){if(v.pendingBranch){v.effects.push(...c),y=!0;break}v=v.parent}!y&&!d&&hs(c),x.effects=[],m&&t&&t.pendingBranch&&_===t.pendingId&&(t.deps--,0===t.deps&&!n&&t.resolve()),mt(s,"onResolve")},fallback(e){if(!x.pendingBranch)return;const{vnode:t,activeBranch:n,parentComponent:s,container:r,namespace:o}=x;mt(t,"onFallback");const l=h(n),a=()=>{x.isInFallback&&(d(null,e,r,l,s,null,o,i,c),tt(x,e))},u=e.transition&&"out-in"===e.transition.mode;u&&(n.transition.afterLeave=a),x.isInFallback=!0,f(n,s,null,!0),u||a()},move(e,t,n){x.activeBranch&&p(x.activeBranch,e,t,n),x.container=e},next:()=>x.activeBranch&&h(x.activeBranch),registerDep(e,t){const n=!!x.pendingBranch;n&&x.deps++;const s=e.vnode.el;e.asyncDep.catch((t=>{bt(t,e,0)})).then((r=>{if(e.isUnmounted||x.isUnmounted||x.pendingId!==e.suspenseId)return;e.asyncResolved=!0;const{vnode:o}=e;xs(e,r,!1),s&&(o.el=s);const i=!s&&e.subTree.el;t(e,o,g(s||e.subTree.el),s?null:h(e.subTree),x,l,c),i&&v(i),Ns(e,o.el),n&&0==--x.deps&&x.resolve()}))},unmount(e,t){x.isUnmounted=!0,x.activeBranch&&f(x.activeBranch,n,e,t),x.pendingBranch&&f(x.pendingBranch,n,e,t)}};return x}function Tl(e,t,n,s,r,o,l,i,c){const a=t.suspense=$s(t,s,n,e.parentNode,document.createElement("div"),null,r,o,l,i,!0),u=c(e,a.pendingBranch=t.ssContent,n,a,o,l);return 0===a.deps&&a.resolve(!1,!0),u}function Cl(e){const{shapeFlag:t,children:n}=e,s=32&t;e.ssContent=nn(s?n.default:n),e.ssFallback=s?nn(n.fallback):le(Me)}function nn(e){let t;if(U(e)){const n=lt&&e._c;n&&(e._d=!1,ur()),e=e(),n&&(e._d=!0,t=xe,ar())}return $(e)&&(e=gl(e)),e=ye(e),t&&!e.dynamicChildren&&(e.dynamicChildren=t.filter((t=>t!==e))),e}function qn(e,t){t&&t.pendingBranch?$(e)?t.effects.push(...e):t.effects.push(e):hs(e)}function tt(e,t){e.activeBranch=t;const{vnode:n,parentComponent:s}=e;let r=t.el;for(;!r&&t.component;)r=(t=t.component.subTree).el;n.el=r,s&&s.subTree===n&&(s.vnode.el=r,Ns(s,r))}function vl(e){var t;return null!=(null==(t=e.props)?void 0:t.suspensible)&&!1!==e.props.suspensible}const Fl=Symbol.for("v-scx"),Il=()=>At(Fl),Ot={};function ls(e,t,n){return Gn(e,t,n)}function Gn(e,t,{immediate:n,deep:s,flush:r,once:o,onTrack:l,onTrigger:i}=z){if(t&&o){const e=t;t=(...t)=>{e(...t),x()}}const c=ce,a=e=>!0===s?e:Ze(e,!1===s?1:void 0);let u,d=!1,p=!1;if(ge(e)?(u=()=>e.value,d=Nt(e)):ze(e)?(u=()=>a(e),d=!0):$(e)?(p=!0,d=e.some((e=>ze(e)||Nt(e))),u=()=>e.map((e=>ge(e)?e.value:ze(e)?a(e):U(e)?Ue(e,c,2):void 0))):u=U(e)?t?()=>Ue(e,c,2):()=>(f&&f(),Ie(e,c,3,[g])):be,t&&s){const e=u;u=()=>Ze(e())}let f,h,g=e=>{f=y.onStop=()=>{Ue(e,c,4),f=y.onStop=void 0}};if(Qt){if(g=be,t?n&&Ie(t,c,3,[u(),p?[]:void 0,g]):u(),"sync"!==r)return be;{const e=Il();h=e.__watcherHandles||(e.__watcherHandles=[])}}let v=p?new Array(e.length).fill(Ot):Ot;const _=()=>{if(y.active&&y.dirty)if(t){const e=y.run();(s||d||(p?e.some(((e,t)=>Se(e,v[t]))):Se(e,v)))&&(f&&f(),Ie(t,c,3,[e,v===Ot?void 0:p&&v[0]===Ot?[]:v,g]),v=e)}else y.run()};let m;_.allowRecurse=!!t,"sync"===r?m=_:"post"===r?m=()=>ae(_,c&&c.suspense):(_.pre=!0,c&&(_.id=c.uid),m=()=>Ls(_));const y=new Fs(u,be,m),b=Lr(),x=()=>{y.stop(),b&&Es(b.effects,y)};return t?n?_():v=y.run():"post"===r?ae(y.run.bind(y),c&&c.suspense):y.run(),h&&h.push(x),x}function Ol(e,t,n){const s=this.proxy,r=ue(e)?e.includes(".")?Jn(s,e):()=>s[e]:e.bind(s,s);let o;U(t)?o=t:(o=t.handler,n=t);const l=xt(this),i=Gn(r,o.bind(s),n);return l(),i}function Jn(e,t){const n=t.split(".");return()=>{let t=e;for(let e=0;e0){if(n>=t)return e;n++}if((s=s||new Set).has(e))return e;if(s.add(e),ge(e))Ze(e.value,t,n,s);else if($(e))for(let r=0;r{Ze(e,t,n,s)}));else if(xn(e))for(const r in e)Ze(e[r],t,n,s);return e}function ve(e,t,n,s){const r=e.dirs,o=t&&t.dirs;for(let l=0;l!!e.type.__asyncLoader,Yn=e=>e.type.__isKeepAlive;function Pl(e,t){Zn(e,"a",t)}function Rl(e,t){Zn(e,"da",t)}function Zn(e,t,n=ce){const s=e.__wdc||(e.__wdc=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()});if(Yt(t,s,n),n){let e=n.parent;for(;e&&e.parent;)Yn(e.parent.vnode)&&Ml(s,t,n,e),e=e.parent}}function Ml(e,t,n,s){const r=Yt(t,e,s,!0);Qn((()=>{Es(s[t],r)}),n)}function Yt(e,t,n=ce,s=!1){if(n){const r=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...s)=>{if(n.isUnmounted)return;qe();const r=xt(n),o=Ie(t,n,e,s);return r(),Ge(),o});return s?r.unshift(o):r.push(o),o}}const Ae=e=>(t,n=ce)=>(!Qt||"sp"===e)&&Yt(e,((...e)=>t(...e)),n),Al=Ae("bm"),Bl=Ae("m"),Hl=Ae("bu"),Ll=Ae("u"),Nl=Ae("bum"),Qn=Ae("um"),$l=Ae("sp"),jl=Ae("rtg"),Ul=Ae("rtc");function Sl(e,t=ce){Yt("ec",e,t)}function Fi(e,t,n={},s,r){if(de.isCE||de.parent&&st(de.parent)&&de.parent.isCE)return"default"!==t&&(n.name=t),le("slot",n,s&&s());let o=e[t];o&&o._c&&(o._d=!1),ur();const l=o&&Xn(o(n)),i=ii(_e,{key:n.key||l&&l.key||`_${t}`},l||(s?s():[]),l&&1===e._?64:-2);return!r&&i.scopeId&&(i.slotScopeIds=[i.scopeId+"-s"]),o&&o._c&&(o._d=!0),i}function Xn(e){return e.some((e=>!yt(e)||!(e.type===Me||e.type===_e&&!Xn(e.children))))?e:null}const ps=e=>e?_r(e)?ks(e)||e.proxy:ps(e.parent):null,at=ie(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>ps(e.parent),$root:e=>ps(e.root),$emit:e=>e.emit,$options:e=>js(e),$forceUpdate:e=>e.f||(e.f=()=>{e.effect.dirty=!0,Ls(e.update)}),$nextTick:e=>e.n||(e.n=fl.bind(e.proxy)),$watch:e=>Ol.bind(e)}),is=(e,t)=>e!==z&&!e.__isScriptSetup&&W(e,t),kl={get({_:e},t){const{ctx:n,setupState:s,data:r,props:o,accessCache:l,type:i,appContext:c}=e;let a;if("$"!==t[0]){const i=l[t];if(void 0!==i)switch(i){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return o[t]}else{if(is(s,t))return l[t]=1,s[t];if(r!==z&&W(r,t))return l[t]=2,r[t];if((a=e.propsOptions[0])&&W(a,t))return l[t]=3,o[t];if(n!==z&&W(n,t))return l[t]=4,n[t];_s&&(l[t]=0)}}const u=at[t];let d,p;return u?("$attrs"===t&&he(e,"get",t),u(e)):(d=i.__cssModules)&&(d=d[t])?d:n!==z&&W(n,t)?(l[t]=4,n[t]):(p=c.config.globalProperties,W(p,t)?p[t]:void 0)},set({_:e},t,n){const{data:s,setupState:r,ctx:o}=e;return is(r,t)?(r[t]=n,!0):s!==z&&W(s,t)?(s[t]=n,!0):!(W(e.props,t)||"$"===t[0]&&t.slice(1)in e)&&(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,propsOptions:o}},l){let i;return!!n[l]||e!==z&&W(e,l)||is(t,l)||(i=o[0])&&W(i,l)||W(s,l)||W(at,l)||W(r.config.globalProperties,l)},defineProperty(e,t,n){return null!=n.get?e._.accessCache[t]=0:W(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function rn(e){return $(e)?e.reduce(((e,t)=>(e[t]=null,e)),{}):e}let _s=!0;function Vl(e){const t=js(e),n=e.proxy,s=e.ctx;_s=!1,t.beforeCreate&&ln(t.beforeCreate,e,"bc");const{data:r,computed:o,methods:l,watch:i,provide:c,inject:a,created:u,beforeMount:d,mounted:p,beforeUpdate:f,updated:h,activated:g,deactivated:v,beforeDestroy:_,beforeUnmount:m,destroyed:y,unmounted:b,render:x,renderTracked:C,renderTriggered:k,errorCaptured:w,serverPrefetch:S,expose:F,inheritAttrs:I,components:O,directives:R,filters:E}=t;if(a&&Kl(a,s,null),l)for(const e in l){const t=l[e];U(t)&&(s[e]=t.bind(n))}if(r){const t=r.call(n,n);ee(t)&&(e.data=Ms(t))}if(_s=!0,o)for(const e in o){const t=o[e],r=U(t)?t.bind(n,n):U(t.get)?t.get.bind(n,n):be,l=!U(t)&&U(t.set)?t.set.bind(n):be,i=yi({get:r,set:l});Object.defineProperty(s,e,{enumerable:!0,configurable:!0,get:()=>i.value,set:e=>i.value=e})}if(i)for(const e in i)zn(i[e],s,n,e);if(c){const e=U(c)?c.call(n):c;Reflect.ownKeys(e).forEach((t=>{Yl(t,e[t])}))}function W(e,t){$(t)?t.forEach((t=>e(t.bind(n)))):t&&e(t.bind(n))}if(u&&ln(u,e,"c"),W(Al,d),W(Bl,p),W(Hl,f),W(Ll,h),W(Pl,g),W(Rl,v),W(Sl,w),W(Ul,C),W(jl,k),W(Nl,m),W(Qn,b),W($l,S),$(F))if(F.length){const t=e.exposed||(e.exposed={});F.forEach((e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t})}))}else e.exposed||(e.exposed={});x&&e.render===be&&(e.render=x),null!=I&&(e.inheritAttrs=I),O&&(e.components=O),R&&(e.directives=R)}function Kl(e,t,n=be){$(e)&&(e=ms(e));for(const n in e){const s=e[n];let r;r=ee(s)?"default"in s?At(s.from||n,s.default,!0):At(s.from||n):At(s),ge(r)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>r.value,set:e=>r.value=e}):t[n]=r}}function ln(e,t,n){Ie($(e)?e.map((e=>e.bind(t.proxy))):e.bind(t.proxy),t,n)}function zn(e,t,n,s){const r=s.includes(".")?Jn(n,s):()=>n[s];if(ue(e)){const n=t[e];U(n)&&ls(r,n)}else if(U(e))ls(r,e.bind(n));else if(ee(e))if($(e))e.forEach((e=>zn(e,t,n,s)));else{const s=U(e.handler)?e.handler.bind(n):t[e.handler];U(s)&&ls(r,s,e)}}function js(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:r,optionsCache:o,config:{optionMergeStrategies:l}}=e.appContext,i=o.get(t);let c;return i?c=i:r.length||n||s?(c={},r.length&&r.forEach((e=>Ut(c,e,l,!0))),Ut(c,t,l)):c=t,ee(t)&&o.set(t,c),c}function Ut(e,t,n,s=!1){const{mixins:r,extends:o}=t;o&&Ut(e,o,n,!0),r&&r.forEach((t=>Ut(e,t,n,!0)));for(const r in t)if(!s||"expose"!==r){const s=Dl[r]||n&&n[r];e[r]=s?s(e[r],t[r]):t[r]}return e}const Dl={data:on,props:fn,emits:fn,methods:ct,computed:ct,beforeCreate:fe,created:fe,beforeMount:fe,mounted:fe,beforeUpdate:fe,updated:fe,beforeDestroy:fe,beforeUnmount:fe,destroyed:fe,unmounted:fe,activated:fe,deactivated:fe,errorCaptured:fe,serverPrefetch:fe,components:ct,directives:ct,watch:ql,provide:on,inject:Wl};function on(e,t){return t?e?function(){return ie(U(e)?e.call(this,this):e,U(t)?t.call(this,this):t)}:t:e}function Wl(e,t){return ct(ms(e),ms(t))}function ms(e){if($(e)){const t={};for(let n=0;n(o.has(e)||(e&&U(e.install)?(o.add(e),e.install(i,...t)):U(e)&&(o.add(e),e(i,...t))),i),mixin:e=>(r.mixins.includes(e)||r.mixins.push(e),i),component:(e,t)=>t?(r.components[e]=t,i):r.components[e],directive:(e,t)=>t?(r.directives[e]=t,i):r.directives[e],mount(o,c,a){if(!l){const u=le(n,s);return u.appContext=r,!0===a?a="svg":!1===a&&(a=void 0),c&&t?t(u,o):e(u,o,a),l=!0,i._container=o,o.__vue_app__=i,ks(u.component)||u.component.proxy}},unmount(){l&&(e(null,i._container),delete i._container.__vue_app__)},provide:(e,t)=>(r.provides[e]=t,i),runWithContext(e){St=i;try{return e()}finally{St=null}}};return i}}let St=null;function Yl(e,t){if(ce){let n=ce.provides;const s=ce.parent&&ce.parent.provides;s===n&&(n=ce.provides=Object.create(s)),n[e]=t}}function At(e,t,n=!1){const s=ce||de;if(s||St){const r=s?null==s.parent?s.vnode.appContext&&s.vnode.appContext.provides:s.parent.provides:St._context.provides;if(r&&e in r)return r[e];if(arguments.length>1)return n&&U(t)?t.call(s&&s.proxy):t}}function Zl(e,t,n,s=!1){const r={},o={};Lt(o,Zt,1),e.propsDefaults=Object.create(null),tr(e,t,r,o);for(const t in e.propsOptions[0])t in r||(r[t]=void 0);n?e.props=s?r:tl(r):e.type.props?e.props=r:e.props=o,e.attrs=o}function Ql(e,t,n,s){const{props:r,attrs:o,vnode:{patchFlag:l}}=e,i=q(r),[c]=e.propsOptions;let a=!1;if(!(s||l>0)||16&l){let s;tr(e,t,r,o)&&(a=!0);for(const o in i)(!t||!W(t,o)&&((s=qt(o))===o||!W(t,s)))&&(c?n&&(void 0!==n[o]||void 0!==n[s])&&(r[o]=ys(c,i,o,void 0,e,!0)):delete r[o]);if(o!==i)for(const e in o)(!t||!W(t,e))&&(delete o[e],a=!0)}else if(8&l){const n=e.vnode.dynamicProps;for(let s=0;s{c=!0;const[n,s]=sr(e,t,!0);ie(l,n),s&&i.push(...s)};!n&&t.mixins.length&&t.mixins.forEach(s),e.extends&&s(e.extends),e.mixins&&e.mixins.forEach(s)}if(!o&&!c)return ee(e)&&s.set(e,Qe),Qe;if($(o))for(let e=0;e-1,s[1]=n<0||e-1||W(s,"default"))&&i.push(t)}}}const a=[l,i];return ee(e)&&s.set(e,a),a}function cn(e){return"$"!==e[0]}function un(e){const t=e&&e.toString().match(/^\s*(function|class) (\w+)/);return t?t[2]:null===e?"null":""}function an(e,t){return un(e)===un(t)}function dn(e,t){return $(t)?t.findIndex((t=>an(t,e))):U(t)&&an(t,e)?0:-1}const nr=e=>"_"===e[0]||"$stable"===e,Us=e=>$(e)?e.map(ye):[ye(e)],Xl=(e,t,n)=>{if(t._n)return t;const s=hl(((...e)=>Us(t(...e))),n);return s._c=!1,s},rr=(e,t,n)=>{const s=e._ctx;for(const n in e){if(nr(n))continue;const r=e[n];if(U(r))t[n]=Xl(0,r,s);else if(null!=r){const e=Us(r);t[n]=()=>e}}},lr=(e,t)=>{const n=Us(t);e.slots.default=()=>n},zl=(e,t)=>{if(32&e.vnode.shapeFlag){const n=t._;n?(e.slots=q(t),Lt(t,"_",n)):rr(t,e.slots={})}else e.slots={},t&&lr(e,t);Lt(e.slots,Zt,1)},ei=(e,t,n)=>{const{vnode:s,slots:r}=e;let o=!0,l=z;if(32&s.shapeFlag){const e=t._;e?n&&1===e?o=!1:(ie(r,t),!n&&1===e&&delete r._):(o=!t.$stable,rr(t,r)),l=t}else t&&(lr(e,t),l={default:1});if(o)for(const e in r)!nr(e)&&null==l[e]&&delete r[e]};function kt(e,t,n,s,r=!1){if($(e))return void e.forEach(((e,o)=>kt(e,t&&($(t)?t[o]:t),n,s,r)));if(st(s)&&!r)return;const o=4&s.shapeFlag?ks(s.component)||s.component.proxy:s.el,l=r?null:o,{i:i,r:c}=e,a=t&&t.r,u=i.refs===z?i.refs={}:i.refs,d=i.setupState;if(null!=a&&a!==c&&(ue(a)?(u[a]=null,W(d,a)&&(d[a]=null)):ge(a)&&(a.value=null)),U(c))Ue(c,i,12,[l,u]);else{const t=ue(c),s=ge(c),i=e.f;if(t||s){const a=()=>{if(i){const n=t?W(d,c)?d[c]:u[c]:c.value;r?$(n)&&Es(n,o):$(n)?n.includes(o)||n.push(o):t?(u[c]=[o],W(d,c)&&(d[c]=u[c])):(c.value=[o],e.k&&(u[e.k]=c.value))}else t?(u[c]=l,W(d,c)&&(d[c]=l)):s&&(c.value=l,e.k&&(u[e.k]=l))};r||i?a():(a.id=-1,ae(a,n))}}}let He=!1;const ti=e=>e.namespaceURI.includes("svg")&&"foreignObject"!==e.tagName,si=e=>e.namespaceURI.includes("MathML"),Pt=e=>ti(e)?"svg":si(e)?"mathml":void 0,Rt=e=>8===e.nodeType;function ni(e){const{mt:t,p:n,o:{patchProp:s,createText:r,nextSibling:o,parentNode:l,remove:i,insert:c,createComment:a}}=e,u=(n,s,i,a,m,y=!1)=>{const b=Rt(n)&&"["===n.data,x=()=>h(n,s,i,a,m,b),{type:C,ref:k,shapeFlag:w,patchFlag:S}=s;let U=n.nodeType;s.el=n,-2===S&&(y=!1,s.dynamicChildren=null);let $=null;switch(C){case rt:3!==U?""===s.children?(c(s.el=r(""),l(n),n),$=n):$=x():(n.data!==s.children&&(He=!0,n.data=s.children),$=o(n));break;case Me:_(n)?($=o(n),v(s.el=n.content.firstChild,n,i)):$=8!==U||b?x():o(n);break;case Bt:if(b&&(U=(n=o(n)).nodeType),1===U||3===U){$=n;const e=!s.children.length;for(let t=0;t{l=l||!!t.dynamicChildren;const{type:c,props:a,patchFlag:u,shapeFlag:d,dirs:f,transition:h}=t,g="input"===c||"option"===c;if(g||-1!==u){f&&ve(t,null,n,"created");let c,m=!1;if(_(e)){m=or(r,h)&&n&&n.vnode.props&&n.vnode.props.appear;const s=e.content.firstChild;m&&h.beforeEnter(s),v(s,e,n),t.el=e=s}if(16&d&&(!a||!a.innerHTML&&!a.textContent)){let s=p(e.firstChild,t,e,n,r,o,l);for(;s;){He=!0;const e=s;s=s.nextSibling,i(e)}}else 8&d&&e.textContent!==t.children&&(He=!0,e.textContent=t.children);if(a)if(g||!l||48&u)for(const t in a)(g&&(t.endsWith("value")||"indeterminate"===t)||Kt(t)&&!ut(t)||"."===t[0])&&s(e,t,null,a[t],void 0,void 0,n);else a.onClick&&s(e,"onClick",null,a.onClick,void 0,void 0,n);(c=a&&a.onVnodeBeforeMount)&&me(c,n,t),f&&ve(t,null,n,"beforeMount"),((c=a&&a.onVnodeMounted)||f||m)&&qn((()=>{c&&me(c,n,t),m&&h.enter(e),f&&ve(t,null,n,"mounted")}),r)}return e.nextSibling},p=(e,t,s,r,o,l,i)=>{i=i||!!t.dynamicChildren;const c=t.children,a=c.length;for(let t=0;t{const{slotScopeIds:u}=t;u&&(r=r?r.concat(u):u);const d=l(e),f=p(o(e),t,d,n,s,r,i);return f&&Rt(f)&&"]"===f.data?o(t.anchor=f):(He=!0,c(t.anchor=a("]"),d,f),f)},h=(e,t,s,r,c,a)=>{if(He=!0,t.el=null,a){const t=g(e);for(;;){const n=o(e);if(!n||n===t)break;i(n)}}const u=o(e),d=l(e);return i(e),n(null,t,d,u,s,r,Pt(d),c),u},g=(e,t="[",n="]")=>{let s=0;for(;e;)if((e=o(e))&&Rt(e)&&(e.data===t&&s++,e.data===n)){if(0===s)return o(e);s--}return e},v=(e,t,n)=>{const s=t.parentNode;s&&s.replaceChild(e,t);let r=n;for(;r;)r.vnode.el===t&&(r.vnode.el=r.subTree.el=e),r=r.parent},_=e=>1===e.nodeType&&"template"===e.tagName.toLowerCase();return[(e,t)=>{if(!t.hasChildNodes())return n(null,e,t),$t(),void(t._vnode=e);He=!1,u(t.firstChild,e,null,null,null),$t(),t._vnode=e,He&&console.error("Hydration completed but contains mismatches.")},u]}const ae=qn;function Ii(e){return ir(e)}function Oi(e){return ir(e,ni)}function ir(e,t){wn().__VUE__=!0;const{insert:n,remove:s,patchProp:r,createElement:o,createText:l,createComment:i,setText:c,setElementText:a,parentNode:u,nextSibling:d,setScopeId:p=be,insertStaticContent:f}=e,h=(e,t,n,s=null,r=null,o=null,l=void 0,i=null,c=!!t.dynamicChildren)=>{if(e===t)return;e&&!$e(e,t)&&(s=q(e),T(e,r,o,!0),e=null),-2===t.patchFlag&&(c=!1,t.dynamicChildren=null);const{type:a,ref:u,shapeFlag:d}=t;switch(a){case rt:g(e,t,n,s);break;case Me:v(e,t,n,s);break;case Bt:null==e&&_(t,n,s,l);break;case _e:S(e,t,n,s,r,o,l,i,c);break;default:1&d?m(e,t,n,s,r,o,l,i,c):6&d?U(e,t,n,s,r,o,l,i,c):(64&d||128&d)&&a.process(e,t,n,s,r,o,l,i,c,A)}null!=u&&r&&kt(u,e&&e.ref,o,t||e,!t)},g=(e,t,s,r)=>{if(null==e)n(t.el=l(t.children),s,r);else{const n=t.el=e.el;t.children!==e.children&&c(n,t.children)}},v=(e,t,s,r)=>{null==e?n(t.el=i(t.children||""),s,r):t.el=e.el},_=(e,t,n,s)=>{[e.el,e.anchor]=f(e.children,t,n,s,e.el,e.anchor)},m=(e,t,n,s,r,o,l,i,c)=>{"svg"===t.type?l="svg":"math"===t.type&&(l="mathml"),null==e?y(t,n,s,r,o,l,i,c):C(e,t,r,o,l,i,c)},y=(e,t,s,l,i,c,u,d)=>{let p,f;const{props:h,shapeFlag:g,transition:v,dirs:_}=e;if(p=e.el=o(e.type,c,h&&h.is,h),8&g?a(p,e.children):16&g&&x(e.children,p,null,l,i,os(e,c),u,d),_&&ve(e,null,l,"created"),b(p,e,e.scopeId,u,l),h){for(const t in h)"value"!==t&&!ut(t)&&r(p,t,null,h[t],c,e.children,l,i,P);"value"in h&&r(p,"value",null,h.value,c),(f=h.onVnodeBeforeMount)&&me(f,l,e)}_&&ve(e,null,l,"beforeMount");const m=or(i,v);m&&v.beforeEnter(p),n(p,t,s),((f=h&&h.onVnodeMounted)||m||_)&&ae((()=>{f&&me(f,l,e),m&&v.enter(p),_&&ve(e,null,l,"mounted")}),i)},b=(e,t,n,s,r)=>{if(n&&p(e,n),s)for(let t=0;t{for(let a=c;a{const c=t.el=e.el;let{patchFlag:u,dynamicChildren:d,dirs:p}=t;u|=16&e.patchFlag;const f=e.props||z,h=t.props||z;let g;if(n&&Ve(n,!1),(g=h.onVnodeBeforeUpdate)&&me(g,n,t,e),p&&ve(t,e,n,"beforeUpdate"),n&&Ve(n,!0),d?k(e.dynamicChildren,d,c,n,s,os(t,o),l):i||R(e,t,c,null,n,s,os(t,o),l,!1),u>0){if(16&u)w(c,t,f,h,n,s,o);else if(2&u&&f.class!==h.class&&r(c,"class",null,h.class,o),4&u&&r(c,"style",f.style,h.style,o),8&u){const l=t.dynamicProps;for(let t=0;t{g&&me(g,n,t,e),p&&ve(t,e,n,"updated")}),s)},k=(e,t,n,s,r,o,l)=>{for(let i=0;i{if(n!==s){if(n!==z)for(const c in n)!ut(c)&&!(c in s)&&r(e,c,n[c],null,i,t.children,o,l,P);for(const c in s){if(ut(c))continue;const a=s[c],u=n[c];a!==u&&"value"!==c&&r(e,c,u,a,i,t.children,o,l,P)}"value"in s&&r(e,"value",n.value,s.value,i)}},S=(e,t,s,r,o,i,c,a,u)=>{const d=t.el=e?e.el:l(""),p=t.anchor=e?e.anchor:l("");let{patchFlag:f,dynamicChildren:h,slotScopeIds:g}=t;g&&(a=a?a.concat(g):g),null==e?(n(d,s,r),n(p,s,r),x(t.children||[],s,p,o,i,c,a,u)):f>0&&64&f&&h&&e.dynamicChildren?(k(e.dynamicChildren,h,s,o,i,c,a),(null!=t.key||o&&t===o.subTree)&&fr(e,t,!0)):R(e,t,s,p,o,i,c,a,u)},U=(e,t,n,s,r,o,l,i,c)=>{t.slotScopeIds=i,null==e?512&t.shapeFlag?r.ctx.activate(t,n,s,l,c):$(t,n,s,r,o,l,c):F(e,t,c)},$=(e,t,n,s,r,o,l)=>{const i=e.component=di(e,s,r);if(Yn(e)&&(i.ctx.renderer=A),hi(i),i.asyncDep){if(r&&r.registerDep(i,I),!e.el){const e=i.subTree=le(Me);v(null,e,t,n)}}else I(i,e,t,n,r,o,l)},F=(e,t,n)=>{const s=t.component=e.component;if(ml(e,t,n)){if(s.asyncDep&&!s.asyncResolved)return void O(s,t,n);s.next=t,ul(s.update),s.effect.dirty=!0,s.update()}else t.el=e.el,s.vnode=t},I=(e,t,n,s,r,o,l)=>{const i=()=>{if(e.isMounted){let{next:t,bu:n,u:s,parent:c,vnode:a}=e;{const n=cr(e);if(n)return t&&(t.el=a.el,O(e,t,l)),void n.asyncDep.then((()=>{e.isUnmounted||i()}))}let d,p=t;Ve(e,!1),t?(t.el=a.el,O(e,t,l)):t=a,n&&ss(n),(d=t.props&&t.props.onVnodeBeforeUpdate)&&me(d,c,t,a),Ve(e,!0);const f=rs(e),g=e.subTree;e.subTree=f,h(g,f,u(g.el),q(g),e,r,o),t.el=f.el,null===p&&Ns(e,f.el),s&&ae(s,r),(d=t.props&&t.props.onVnodeUpdated)&&ae((()=>me(d,c,t,a)),r)}else{let l;const{el:i,props:c}=t,{bm:a,m:u,parent:d}=e,p=st(t);if(Ve(e,!1),a&&ss(a),!p&&(l=c&&c.onVnodeBeforeMount)&&me(l,d,t),Ve(e,!0),i&&D){const n=()=>{e.subTree=rs(e),D(i,e.subTree,e,r,null)};p?t.type.__asyncLoader().then((()=>!e.isUnmounted&&n())):n()}else{const l=e.subTree=rs(e);h(null,l,n,s,e,r,o),t.el=l.el}if(u&&ae(u,r),!p&&(l=c&&c.onVnodeMounted)){const e=t;ae((()=>me(l,d,e)),r)}(256&t.shapeFlag||d&&st(d.vnode)&&256&d.vnode.shapeFlag)&&e.a&&ae(e.a,r),e.isMounted=!0,t=n=s=null}},c=e.effect=new Fs(i,be,(()=>Ls(a)),e.scope),a=e.update=()=>{c.dirty&&c.run()};a.id=e.uid,Ve(e,!0),a()},O=(e,t,n)=>{t.component=e;const s=e.vnode.props;e.vnode=t,e.next=null,Ql(e,t.props,s,n),ei(e,t.children,n),qe(),tn(e),Ge()},R=(e,t,n,s,r,o,l,i,c=!1)=>{const u=e&&e.children,d=e?e.shapeFlag:0,p=t.children,{patchFlag:f,shapeFlag:h}=t;if(f>0){if(128&f)return void W(u,p,n,s,r,o,l,i,c);if(256&f)return void E(u,p,n,s,r,o,l,i,c)}8&h?(16&d&&P(u,r,o),p!==u&&a(n,p)):16&d?16&h?W(u,p,n,s,r,o,l,i,c):P(u,r,o,!0):(8&d&&a(n,""),16&h&&x(p,n,s,r,o,l,i,c))},E=(e,t,n,s,r,o,l,i,c)=>{t=t||Qe;const a=(e=e||Qe).length,u=t.length,d=Math.min(a,u);let p;for(p=0;pu?P(e,r,o,!0,!1,d):x(t,n,s,r,o,l,i,c,d)},W=(e,t,n,s,r,o,l,i,c)=>{let a=0;const u=t.length;let d=e.length-1,p=u-1;for(;a<=d&&a<=p;){const s=e[a],u=t[a]=c?Ne(t[a]):ye(t[a]);if(!$e(s,u))break;h(s,u,n,null,r,o,l,i,c),a++}for(;a<=d&&a<=p;){const s=e[d],a=t[p]=c?Ne(t[p]):ye(t[p]);if(!$e(s,a))break;h(s,a,n,null,r,o,l,i,c),d--,p--}if(a>d){if(a<=p){const e=p+1,d=ep)for(;a<=d;)T(e[a],r,o,!0),a++;else{const f=a,g=a,v=new Map;for(a=g;a<=p;a++){const e=t[a]=c?Ne(t[a]):ye(t[a]);null!=e.key&&v.set(e.key,a)}let _,m=0;const y=p-g+1;let b=!1,x=0;const C=new Array(y);for(a=0;a=y){T(s,r,o,!0);continue}let u;if(null!=s.key)u=v.get(s.key);else for(_=g;_<=p;_++)if(0===C[_-g]&&$e(s,t[_])){u=_;break}void 0===u?T(s,r,o,!0):(C[u-g]=a+1,u>=x?x=u:b=!0,h(s,t[u],n,null,r,o,l,i,c),m++)}const k=b?ri(C):Qe;for(_=k.length-1,a=y-1;a>=0;a--){const e=g+a,d=t[e],p=e+1{const{el:l,type:i,transition:c,children:a,shapeFlag:u}=e;if(6&u)B(e.component.subTree,t,s,r);else if(128&u)e.suspense.move(t,s,r);else if(64&u)i.move(e,t,s,A);else if(i!==_e)if(i!==Bt)if(2!==r&&1&u&&c)if(0===r)c.beforeEnter(l),n(l,t,s),ae((()=>c.enter(l)),o);else{const{leave:e,delayLeave:r,afterLeave:o}=c,i=()=>n(l,t,s),a=()=>{e(l,(()=>{i(),o&&o()}))};r?r(l,i,a):a()}else n(l,t,s);else(({el:e,anchor:t},s,r)=>{let o;for(;e&&e!==t;)o=d(e),n(e,s,r),e=o;n(t,s,r)})(e,t,s);else{n(l,t,s);for(let e=0;e{const{type:o,props:l,ref:i,children:c,dynamicChildren:a,shapeFlag:u,patchFlag:d,dirs:p}=e;if(null!=i&&kt(i,null,n,e,!0),256&u)return void t.ctx.deactivate(e);const f=1&u&&p,h=!st(e);let g;if(h&&(g=l&&l.onVnodeBeforeUnmount)&&me(g,t,e),6&u)L(e.component,n,s);else{if(128&u)return void e.suspense.unmount(n,s);f&&ve(e,null,t,"beforeUnmount"),64&u?e.type.remove(e,t,n,r,A,s):a&&(o!==_e||d>0&&64&d)?P(a,t,n,!1,!0):(o===_e&&384&d||!r&&16&u)&&P(c,t,n),s&&M(e)}(h&&(g=l&&l.onVnodeUnmounted)||f)&&ae((()=>{g&&me(g,t,e),f&&ve(e,null,t,"unmounted")}),n)},M=e=>{const{type:t,el:n,anchor:r,transition:o}=e;if(t===_e)return void j(n,r);if(t===Bt)return void(({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=d(e),s(e),e=n;s(t)})(e);const l=()=>{s(n),o&&!o.persisted&&o.afterLeave&&o.afterLeave()};if(1&e.shapeFlag&&o&&!o.persisted){const{leave:t,delayLeave:s}=o,r=()=>t(n,l);s?s(e.el,l,r):r()}else l()},j=(e,t)=>{let n;for(;e!==t;)n=d(e),s(e),e=n;s(t)},L=(e,t,n)=>{const{bum:s,scope:r,update:o,subTree:l,um:i}=e;s&&ss(s),r.stop(),o&&(o.active=!1,T(l,e,t,n)),i&&ae(i,t),ae((()=>{e.isUnmounted=!0}),t),t&&t.pendingBranch&&!t.isUnmounted&&e.asyncDep&&!e.asyncResolved&&e.suspenseId===t.pendingId&&(t.deps--,0===t.deps&&t.resolve())},P=(e,t,n,s=!1,r=!1,o=0)=>{for(let l=o;l6&e.shapeFlag?q(e.component.subTree):128&e.shapeFlag?e.suspense.next():d(e.anchor||e.el);let N=!1;const V=(e,t,n)=>{null==e?t._vnode&&T(t._vnode,null,null,!0):h(t._vnode||null,e,t,null,null,null,n),N||(N=!0,tn(),$t(),N=!1),t._vnode=e},A={p:h,um:T,m:B,r:M,mt:$,mc:x,pc:R,pbc:k,n:q,o:e};let H,D;return t&&([H,D]=t(A)),{render:V,hydrate:H,createApp:Jl(V,H)}}function os({type:e,props:t},n){return"svg"===n&&"foreignObject"===e||"mathml"===n&&"annotation-xml"===e&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Ve({effect:e,update:t},n){e.allowRecurse=t.allowRecurse=n}function or(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function fr(e,t,n=!1){const s=e.children,r=t.children;if($(s)&&$(r))for(let e=0;e>1,e[n[i]]0&&(t[s]=n[o-1]),n[o]=s)}}for(o=n.length,l=n[o-1];o-- >0;)n[o]=l,l=t[l];return n}function cr(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:cr(t)}const li=e=>e.__isTeleport,_e=Symbol.for("v-fgt"),rt=Symbol.for("v-txt"),Me=Symbol.for("v-cmt"),Bt=Symbol.for("v-stc"),dt=[];let xe=null;function ur(e=!1){dt.push(xe=e?null:[])}function ar(){dt.pop(),xe=dt[dt.length-1]||null}let lt=1;function hn(e){lt+=e}function dr(e){return e.dynamicChildren=lt>0?xe||Qe:null,ar(),lt>0&&xe&&xe.push(e),e}function Pi(e,t,n,s,r,o){return dr(gr(e,t,n,s,r,o,!0))}function ii(e,t,n,s,r){return dr(le(e,t,n,s,r,!0))}function yt(e){return!!e&&!0===e.__v_isVNode}function $e(e,t){return e.type===t.type&&e.key===t.key}const Zt="__vInternal",hr=({key:e})=>e??null,Ht=({ref:e,ref_key:t,ref_for:n})=>("number"==typeof e&&(e=""+e),null!=e?ue(e)||ge(e)||U(e)?{i:de,r:e,k:t,f:!!n}:e:null);function gr(e,t=null,n=null,s=0,r=null,o=(e===_e?0:1),l=!1,i=!1){const c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&hr(t),ref:t&&Ht(t),scopeId:Wn,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:s,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:de};return i?(Ss(c,n),128&o&&e.normalize(c)):n&&(c.shapeFlag|=ue(n)?8:16),lt>0&&!l&&xe&&(c.patchFlag>0||6&o)&&32!==c.patchFlag&&xe.push(c),c}const le=oi;function oi(e,t=null,n=null,s=0,r=null,o=!1){if((!e||e===yl)&&(e=Me),yt(e)){const s=it(e,t,!0);return n&&Ss(s,n),lt>0&&!o&&xe&&(6&s.shapeFlag?xe[xe.indexOf(e)]=s:xe.push(s)),s.patchFlag|=-2,s}if(mi(e)&&(e=e.__vccOpts),t){t=fi(t);let{class:e,style:n}=t;e&&!ue(e)&&(t.class=vs(e)),ee(n)&&(Nn(n)&&!$(n)&&(n=ie({},n)),t.style=Cs(n))}return gr(e,t,n,s,r,ue(e)?1:bl(e)?128:li(e)?64:ee(e)?4:U(e)?2:0,o,!0)}function fi(e){return e?Nn(e)||Zt in e?ie({},e):e:null}function it(e,t,n=!1){const{props:s,ref:r,patchFlag:o,children:l}=e,i=t?ci(s||{},t):s;return{__v_isVNode:!0,__v_skip:!0,type:e.type,props:i,key:i&&hr(i),ref:t&&t.ref?n&&r?$(r)?r.concat(Ht(t)):[r,Ht(t)]:Ht(t):r,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==_e?-1===o?16:16|o:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:e.transition,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&it(e.ssContent),ssFallback:e.ssFallback&&it(e.ssFallback),el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce}}function pr(e=" ",t=0){return le(rt,null,e,t)}function ye(e){return null==e||"boolean"==typeof e?le(Me):$(e)?le(_e,null,e.slice()):"object"==typeof e?Ne(e):le(rt,null,String(e))}function Ne(e){return null===e.el&&-1!==e.patchFlag||e.memo?e:it(e)}function Ss(e,t){let n=0;const{shapeFlag:s}=e;if(null==t)t=null;else if($(t))n=16;else if("object"==typeof t){if(65&s){const n=t.default;return void(n&&(n._c&&(n._d=!1),Ss(e,n()),n._c&&(n._d=!0)))}{n=32;const s=t._;s||Zt in t?3===s&&de&&(1===de.slots._?t._=1:(t._=2,e.patchFlag|=1024)):t._ctx=de}}else U(t)?(t={default:t,_ctx:de},n=32):(t=String(t),64&s?(n=16,t=[pr(t)]):n=8);e.children=t,e.shapeFlag|=n}function ci(...e){const t={};for(let n=0;n{let s;return(s=e[t])||(s=e[t]=[]),s.push(n),e=>{s.length>1?s.forEach((t=>t(e))):s[0](e)}};Vt=t("__VUE_INSTANCE_SETTERS__",(e=>ce=e)),bs=t("__VUE_SSR_SETTERS__",(e=>Qt=e))}const xt=e=>{const t=ce;return Vt(e),e.scope.on(),()=>{e.scope.off(),Vt(t)}},gn=()=>{ce&&ce.scope.off(),Vt(null)};function _r(e){return 4&e.vnode.shapeFlag}let pn,Qt=!1;function hi(e,t=!1){t&&bs(t);const{props:n,children:s}=e.vnode,r=_r(e);Zl(e,n,r,t),zl(e,s);const o=r?gi(e,t):void 0;return t&&bs(!1),o}function gi(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=$n(new Proxy(e.ctx,kl));const{setup:s}=n;if(s){const n=e.setupContext=s.length>1?_i(e):null,r=xt(e);qe();const o=Ue(s,e,0,[e.props,n]);if(Ge(),r(),yn(o)){if(o.then(gn,gn),t)return o.then((n=>{xs(e,n,t)})).catch((t=>{bt(t,e,0)}));e.asyncDep=o}else xs(e,o,t)}else mr(e,t)}function xs(e,t,n){U(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ee(t)&&(e.setupState=Sn(t)),mr(e,n)}function mr(e,t,n){const s=e.type;if(!e.render){if(!t&&pn&&!s.render){const t=s.template||js(e).template;if(t){const{isCustomElement:n,compilerOptions:r}=e.appContext.config,{delimiters:o,compilerOptions:l}=s,i=ie(ie({isCustomElement:n,delimiters:o},r),l);s.render=pn(t,i)}}e.render=s.render||be}{const t=xt(e);qe();try{Vl(e)}finally{Ge(),t()}}}function pi(e){return e.attrsProxy||(e.attrsProxy=new Proxy(e.attrs,{get:(t,n)=>(he(e,"get","$attrs"),t[n])}))}function _i(e){return{get attrs(){return pi(e)},slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function ks(e){if(e.exposed)return e.exposeProxy||(e.exposeProxy=new Proxy(Sn($n(e.exposed)),{get:(t,n)=>n in t?t[n]:n in at?at[n](e):void 0,has:(e,t)=>t in e||t in at}))}function mi(e){return U(e)&&"__vccOpts"in e}const yi=(e,t)=>sl(e,t,Qt);function Ri(e,t,n){const s=arguments.length;return 2===s?ee(t)&&!$(t)?yt(t)?le(e,null,[t]):le(e,t):le(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):3===s&&yt(n)&&(n=[n]),le(e,t,n))}const bi="3.4.15";export{_e as F,Ci as S,gr as a,Fi as b,Pi as c,ue as d,Ii as e,Oi as f,ie as g,Kt as h,U as i,_n as j,$ as k,qt as l,ht as m,vr as n,ur as o,xi as p,wi as q,Ti as r,Ie as s,Ei as t,vi as u,Ri as v}; \ No newline at end of file diff --git a/_astro/sabdivisen.odJNBZ1p.png b/_astro/sabdivisen.odJNBZ1p.png new file mode 100644 index 0000000..5f2a9fd Binary files /dev/null and b/_astro/sabdivisen.odJNBZ1p.png differ diff --git a/_astro/sabdivisen.odJNBZ1p_Z1jKNYX.webp b/_astro/sabdivisen.odJNBZ1p_Z1jKNYX.webp new file mode 100644 index 0000000..2e0f211 Binary files /dev/null and b/_astro/sabdivisen.odJNBZ1p_Z1jKNYX.webp differ diff --git a/_astro/segment-covid-full.Y6GZLMi_.webm b/_astro/segment-covid-full.Y6GZLMi_.webm new file mode 100644 index 0000000..8896330 Binary files /dev/null and b/_astro/segment-covid-full.Y6GZLMi_.webm differ diff --git a/_astro/segment-primary-facebook.LGJqi3Jq.webm b/_astro/segment-primary-facebook.LGJqi3Jq.webm new file mode 100644 index 0000000..c52f31c Binary files /dev/null and b/_astro/segment-primary-facebook.LGJqi3Jq.webm differ diff --git a/_astro/segment-primary-news.QAH5SrmD.webm b/_astro/segment-primary-news.QAH5SrmD.webm new file mode 100644 index 0000000..bc35766 Binary files /dev/null and b/_astro/segment-primary-news.QAH5SrmD.webm differ diff --git a/_astro/segment-primary-weather.miKzOWbu.webm b/_astro/segment-primary-weather.miKzOWbu.webm new file mode 100644 index 0000000..bd3b32b Binary files /dev/null and b/_astro/segment-primary-weather.miKzOWbu.webm differ diff --git a/_astro/segment-primary.bJnezNxu.webm b/_astro/segment-primary.bJnezNxu.webm new file mode 100644 index 0000000..e74594d Binary files /dev/null and b/_astro/segment-primary.bJnezNxu.webm differ diff --git a/_astro/segment-upnext.fts5veWg.webm b/_astro/segment-upnext.fts5veWg.webm new file mode 100644 index 0000000..0663649 Binary files /dev/null and b/_astro/segment-upnext.fts5veWg.webm differ diff --git a/_astro/segment-weather-full.SKRqD4-y.webm b/_astro/segment-weather-full.SKRqD4-y.webm new file mode 100644 index 0000000..41d0275 Binary files /dev/null and b/_astro/segment-weather-full.SKRqD4-y.webm differ diff --git a/_astro/segment-weather.jGOz9-E5.webm b/_astro/segment-weather.jGOz9-E5.webm new file mode 100644 index 0000000..ff32d29 Binary files /dev/null and b/_astro/segment-weather.jGOz9-E5.webm differ diff --git a/_astro/ssc-areas.xfAtw9bR_1VNc2n.webp b/_astro/ssc-areas.xfAtw9bR_1VNc2n.webp new file mode 100644 index 0000000..54d20b1 Binary files /dev/null and b/_astro/ssc-areas.xfAtw9bR_1VNc2n.webp differ diff --git a/_astro/ssc-barge.lxPjgWXf.jpg b/_astro/ssc-barge.lxPjgWXf.jpg new file mode 100644 index 0000000..a725a85 Binary files /dev/null and b/_astro/ssc-barge.lxPjgWXf.jpg differ diff --git a/_astro/ssc-container-history.ta2kgau5_Z10EHVw.webp b/_astro/ssc-container-history.ta2kgau5_Z10EHVw.webp new file mode 100644 index 0000000..d89f601 Binary files /dev/null and b/_astro/ssc-container-history.ta2kgau5_Z10EHVw.webp differ diff --git a/_astro/ssc-container-positions.PLqPg-Ov_iqvvi.webp b/_astro/ssc-container-positions.PLqPg-Ov_iqvvi.webp new file mode 100644 index 0000000..efabb66 Binary files /dev/null and b/_astro/ssc-container-positions.PLqPg-Ov_iqvvi.webp differ diff --git a/_astro/ssc-maintenance.xS4uVw9d_1INCAV.webp b/_astro/ssc-maintenance.xS4uVw9d_1INCAV.webp new file mode 100644 index 0000000..a18a923 Binary files /dev/null and b/_astro/ssc-maintenance.xS4uVw9d_1INCAV.webp differ diff --git a/_astro/ssc-retry-queue-failed.Oc4GvwV-.jpg b/_astro/ssc-retry-queue-failed.Oc4GvwV-.jpg new file mode 100644 index 0000000..227121b Binary files /dev/null and b/_astro/ssc-retry-queue-failed.Oc4GvwV-.jpg differ diff --git a/_astro/ssc-truck.2RBYuH1h_1VQEgR.webp b/_astro/ssc-truck.2RBYuH1h_1VQEgR.webp new file mode 100644 index 0000000..762ad3f Binary files /dev/null and b/_astro/ssc-truck.2RBYuH1h_1VQEgR.webp differ diff --git a/_astro/ssc-user-roles.v_2J3N2v.jpg b/_astro/ssc-user-roles.v_2J3N2v.jpg new file mode 100644 index 0000000..33a7957 Binary files /dev/null and b/_astro/ssc-user-roles.v_2J3N2v.jpg differ diff --git a/_astro/stickers.h4ny9W-K.jpg b/_astro/stickers.h4ny9W-K.jpg new file mode 100644 index 0000000..0c7dbd1 Binary files /dev/null and b/_astro/stickers.h4ny9W-K.jpg differ diff --git a/_astro/tariff-rate-plot.jX7Fmiy6_2lnOXf.webp b/_astro/tariff-rate-plot.jX7Fmiy6_2lnOXf.webp new file mode 100644 index 0000000..75e1c88 Binary files /dev/null and b/_astro/tariff-rate-plot.jX7Fmiy6_2lnOXf.webp differ diff --git a/_astro/template.Yan6cP5o_1TAqbS.webp b/_astro/template.Yan6cP5o_1TAqbS.webp new file mode 100644 index 0000000..d9cf7f8 Binary files /dev/null and b/_astro/template.Yan6cP5o_1TAqbS.webp differ diff --git a/_astro/thumbor.619uTGO2.jpg b/_astro/thumbor.619uTGO2.jpg new file mode 100644 index 0000000..6e0549c Binary files /dev/null and b/_astro/thumbor.619uTGO2.jpg differ diff --git a/_astro/thumbor.619uTGO2_ZnWFUd.webp b/_astro/thumbor.619uTGO2_ZnWFUd.webp new file mode 100644 index 0000000..6e0549c Binary files /dev/null and b/_astro/thumbor.619uTGO2_ZnWFUd.webp differ diff --git a/_astro/timeline-checkin-light.wmtlHgMm_Z14hR7U.webp b/_astro/timeline-checkin-light.wmtlHgMm_Z14hR7U.webp new file mode 100644 index 0000000..a91c3ea Binary files /dev/null and b/_astro/timeline-checkin-light.wmtlHgMm_Z14hR7U.webp differ diff --git a/_astro/timeline-image-light.IS0XMz0G_Z9v7rz.webp b/_astro/timeline-image-light.IS0XMz0G_Z9v7rz.webp new file mode 100644 index 0000000..951a3fb Binary files /dev/null and b/_astro/timeline-image-light.IS0XMz0G_Z9v7rz.webp differ diff --git a/_astro/timeline-trip-light.Hda8hbF8_Z1N4rL0.webp b/_astro/timeline-trip-light.Hda8hbF8_Z1N4rL0.webp new file mode 100644 index 0000000..fd33e14 Binary files /dev/null and b/_astro/timeline-trip-light.Hda8hbF8_Z1N4rL0.webp differ diff --git a/_astro/timeline-video-light.WzEmRJ5R_Z1SK5YC.webp b/_astro/timeline-video-light.WzEmRJ5R_Z1SK5YC.webp new file mode 100644 index 0000000..2ab58da Binary files /dev/null and b/_astro/timeline-video-light.WzEmRJ5R_Z1SK5YC.webp differ diff --git a/_astro/tools.2PjWeqOX.jpg b/_astro/tools.2PjWeqOX.jpg new file mode 100644 index 0000000..4af0ee9 Binary files /dev/null and b/_astro/tools.2PjWeqOX.jpg differ diff --git a/_astro/tus-and-uppy.1IuwqCvs.jpg b/_astro/tus-and-uppy.1IuwqCvs.jpg new file mode 100644 index 0000000..3f3cf78 Binary files /dev/null and b/_astro/tus-and-uppy.1IuwqCvs.jpg differ diff --git a/_astro/tus-and-uppy.1IuwqCvs_246lBr.webp b/_astro/tus-and-uppy.1IuwqCvs_246lBr.webp new file mode 100644 index 0000000..3f3cf78 Binary files /dev/null and b/_astro/tus-and-uppy.1IuwqCvs_246lBr.webp differ diff --git a/_astro/vintage.smJk5B2_.jpg b/_astro/vintage.smJk5B2_.jpg new file mode 100644 index 0000000..aa4e5d0 Binary files /dev/null and b/_astro/vintage.smJk5B2_.jpg differ diff --git a/_astro/web-service.C6DjDB_R_3anDL.webp b/_astro/web-service.C6DjDB_R_3anDL.webp new file mode 100644 index 0000000..6bea617 Binary files /dev/null and b/_astro/web-service.C6DjDB_R_3anDL.webp differ diff --git a/_headers b/_headers new file mode 100644 index 0000000..806338c --- /dev/null +++ b/_headers @@ -0,0 +1,2 @@ +/_astro/* + Cache-Control: public, max-age=31536000, immutable \ No newline at end of file diff --git a/about/index.html b/about/index.html new file mode 100644 index 0000000..e41ebfc --- /dev/null +++ b/about/index.html @@ -0,0 +1 @@ +About Me — Michael Toohig
Just Arrived in the USA! Check my resume here.
Mele Bay near sunset

About Me

Last updated: November 17, 2023

I am born and raised in the United States but after university I took a detour and ended up in the south Pacific nation of Vanuatu. I have enjoyed living here for the past decade but most importantly this is where I have married and have had my first daughter. Having said that, I am currently preparing to move back to states to finally see my family again.

I work for a combined local ISP and subscription TV broadcasting service as a manager of the staff and the technical guy to ensure smooth operation of the business’s infrastructure. However, these responsibilities require only minimal time each week so I fill the remaining hours of my days on software development contracts for local government offices and other local businesses.

In my free time I study and build personal projects for fun. But also to develop new skills and explore areas I don’t have the opportunity to in my day job. For example, some personal projects revolving around computer vision, ML and data science which I wouldn’t normally have exposure to in my day job. I usually work with Python, Javascript, Docker, Ansible, Postgres and many other technologies making me a sort of Full-Stack engineer with a preference for the backend side of the stack. I must work full-stack as where I live does not allow for specialization. You can read about some of my projects in the projects section of this website.

You may notice a large percentage of my work leans towards web development and although it may not be the most efficient or hardcore software sub-field it covers so much of what users want and with the development of Progressive Web Apps I’ve found I can rapidily deliver what my clients need. But, I have aspirations to explore deeper into the fields of machine learning, computer vision, automomous drones and software defined radio to name a few.

Thanks for taking the time to visit and you may find other interesting pages to visit on my links page :)

\ No newline at end of file diff --git a/blog/2/index.html b/blog/2/index.html new file mode 100644 index 0000000..576231b --- /dev/null +++ b/blog/2/index.html @@ -0,0 +1 @@ +Blog — Page 2 — Michael Toohig
Just Arrived in the USA! Check my resume here.

My Blog

Mostly I write how-to or lesson-learned stories coming from my larger projects.
\ No newline at end of file diff --git a/blog/3/index.html b/blog/3/index.html new file mode 100644 index 0000000..67b19ff --- /dev/null +++ b/blog/3/index.html @@ -0,0 +1 @@ +Blog — Page 3 — Michael Toohig
Just Arrived in the USA! Check my resume here.

My Blog

Mostly I write how-to or lesson-learned stories coming from my larger projects.
\ No newline at end of file diff --git a/blog/4/index.html b/blog/4/index.html new file mode 100644 index 0000000..b991510 --- /dev/null +++ b/blog/4/index.html @@ -0,0 +1 @@ +Blog — Page 4 — Michael Toohig
Just Arrived in the USA! Check my resume here.

My Blog

Mostly I write how-to or lesson-learned stories coming from my larger projects.
\ No newline at end of file diff --git a/blog/astro-is-not-mature/index.html b/blog/astro-is-not-mature/index.html new file mode 100644 index 0000000..e67ecac --- /dev/null +++ b/blog/astro-is-not-mature/index.html @@ -0,0 +1 @@ +Astro@v1 is not mature — Michael Toohig
Just Arrived in the USA! Check my resume here.

· 2 min read

Astro@v1 is not mature

Astro v1 is more difficult to work with than its beta version.

NOTE: I’ve now migrated to Astro v2 and things were again a bit rough but it finally felt like for the better.

I started migrating my website from VitePress to Astro while it was late in their beta. I thought I had it all sorted out when I navigated their release candidates, but I just couldn’t believe how much dramatic changes they continued taking in their 1.0 release. Every day or two a new release comes to fix the issues from the release before it.

I’ve hit issues with Astro’s use of Vite including build process errors dropping CSS from final files; then a few releases later Astro would be writing CSS links twice to every file.

Images would work in beta then the new @astrojs/image integration would change its internals and appear to work during dev but fail in production.

Another example, somewhere just before the the official 1.0 release, Astro changed their use of Markdown files to the use of Markdownx files which allows importing Javascript components directly into the file. I believe prior to this Astro had its own implementation then decided to use the existing solution.

Then the convient layout tag in the frontmatter section which described which Astro layout to use to build the markdown file into its final HTML output wasn’t available. So all my markdown files needed to manually import the layout component, and I had to wrap the entire document in <Layout content={frontmatter}> tags (which is ugly and cumbersome) so the original functionality returned. About a week later the layout tag was re-introduced into @astrojs/mdx integration so I was back to how it was but I needed to modify all my posts again.

I was quite shocked how unsable Astro was at this point. For example even now in the 1.0.5 release the Astro devs are bumping the patch number to remove their Shiki-Twoslash integration in markdown files for handling code blocks in favor of their own custom Astro Shiki parser. A new @astrojs/shikitwoslash integration will handle the original functionality.

Wow what a mess.

I feel if they have so many drastic jumps and bugs that keep popping up they would still remain in beta. Like I said earlier, the beta releases were rather consistent and didn’t cause myself much trouble but did help fix issues. Now that they are in an official release these dramatic internal changes are wild.

    Share:
    \ No newline at end of file diff --git a/blog/astro-rehype-pretty-code/index.html b/blog/astro-rehype-pretty-code/index.html new file mode 100644 index 0000000..430dafc --- /dev/null +++ b/blog/astro-rehype-pretty-code/index.html @@ -0,0 +1,140 @@ +Astro + Rehype Pretty Code — Michael Toohig
    Just Arrived in the USA! Check my resume here.

    · 4 min read

    Astro + Rehype Pretty Code

    Applying Tailwind styles to your code snippets with light & dark modes.

    This an extension on this other guy’s post so read that first if you need to setup everything from scratch.

    Background

    Astro has been chugging along with new releases at quite a pace and with it came some changes in version 4 regarding plugins. I believe this is what killed my code snippet styling with additional factors from upgrade of shiki to version 1.0 in the background behind rehype-pretty-code so all of my original css has needed changing. This post documents the configuration changes needed to make it work again with the latest Astro 4, Tailwind and rehype-pretty-code plugin.

    Configuring Rehype Pretty Code Plugin

    My webiste supports dark and light mode code snippets. The new way to support multiple themes is shown below with the imported themes I copied from their respective repo and copy/pasted into my project. Also, I had to update my Tailwind integration’s configuration shown below.

    astro.config.mjs
    import dark_theme from './src/assets/themes/remedy-dark.json' assert { type: 'json' };
    +import light_theme from './src/assets/themes/remedy-light.json' assert { type: 'json' };
    + 
    +const prettyCodeOptions = {
    +  theme: {
    +    dark: dark_theme,
    +    light: light_theme,
    +  },
    +  onVisitLine(node) {
    +    // Prevent lines from collapsing in `display: grid` mode and allow empty lines
    +    if (node.children.length === 0) {
    +      node.children = [
    +        {
    +          type: 'text',
    +          value: '',
    +        },
    +      ];
    +    }
    +  },
    +  ...
    +}
    + 
    +export default defineConfig({
    +  ...
    +  integrations: [
    +    tailwind({
    +      applyBaseStyles: false,
    +      nesting: true,
    +    }),
    +    ...
    +  ],
    +  ...
    +});

    I also found I could remove my previous custom onVisitHightlightedLine and onVisitHighlightedWord hooks as this new Shiki version automatically adds data attributes for highlighted lines and characters which I now use in my new css selectors.

    Styling Code Snippets with Tailwind

    Dark Mode

    Again, I followed this blog post becuase it was more clean than my original implementation so I won’t repeat everything but it lacked support for dark mode so I made a few customizations to fit my setup. Firstly, to get dark mode working I added the following to the bottom of my css for code snipets.

    base.css
    html pre span {
    +  color: var(--shiki-light);
    +}
    + 
    +html.dark pre span {
    +  color: var(--shiki-dark);
    +}

    Hightlighted Lines

    Tailwind adds the dark class on the root html element so this snippet changes the Shiki code theme when dark mode is active. There are a few ways to do this based on the shiki style guide but this works for me and we should not include the background-color: var(--shiki-foo) as that will get in the way of the css that highlights lines of code. Oh, and with this implementation !important is not required.

    Making custom line hightlighting for dark mode is easy since we can still use the regular dark: syntax.

    base.css
    pre {
    +  @apply mx-auto overflow-auto p-4;
    +  @apply scrollbar scrollbar-h-2 scrollbar-track-rounded-md scrollbar-thumb-rounded-sm scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-800;
    + 
    +  [data-line] {
    +    @apply mx-0 px-2 leading-tight;
    +  }
    + 
    +  [data-highlighted-line] {
    +    @apply bg-orange/20 dark:bg-orange-500/10 border-l-orange-500 border-l-2 border-opacity-50;
    +  }
    + 
    +  [data-highlighted-chars] {
    +    @apply rounded-md py-0 px-1.5 border-b-2 border-opacity-75 bg-opacity-30 dark:bg-opacity-20;
    +  }
    + 
    +  mark {
    +    @apply text-inherit bg-orange-500 border-b-orange-500;
    +  }
    +}

    Background Color

    Then I ran into trouble configuring a custom background color for code snippets in dark mode. Eventually, I realized because I’m using Tailwind’s typography plugin my code snippets are within the prose so the css defined by the prose class which overwrites other background styles. So I added a line with prose-pre classes to define and now we have our defined dark mode code snippet background.

    base.css
    [data-rehype-pretty-code-figure] {
    +  @apply relative;
    +  @apply rounded-lg bg-slate-200 dark:bg-slate-700;
    +  @apply prose-pre:bg-slate-50 dark:prose-pre:bg-slate-900;
    +}

    Group Highlighted Characters

    This didn’t break but I wanted to add this as an addition to the aforementioned blog post for how to highlight custom colors based on characters with a specific ID.

    base.css
    [data-chars-id='1'] {
    +  @apply bg-pink-500  border-b-pink-500;
    +}
    + 
    +[data-chars-id='2'] {
    +  @apply bg-emerald-500 border-b-emerald-500;
    +}
    + 
    +[data-chars-id='3'] {
    +  @apply bg-blue-500 border-b-blue-500;
    +}

    In the previous section additional character highlighting styling is configured.

    (Mostly) Complete CSS Demo

    Besides the parts outside of base.css mentioned earlier this is the majority of the css for my code snippet styling. It’s mostly specific style preferences I made for myself but I thought it was good to include the full context for this post.

    base.css
    [data-rehype-pretty-code-figure] {
    +  @apply relative;
    +  @apply rounded-lg bg-slate-200 dark:bg-slate-700;
    +  @apply prose-pre:bg-slate-50 dark:prose-pre:bg-slate-900;
    +}
    + 
    +[data-rehype-pretty-code-title] {
    +  @apply font-mono text-sm text-slate-900 dark:text-white ps-2 py-1;
    +}
    + 
    +[data-rehype-pretty-code-title] + pre {
    +  @apply m-0 px-0 py-1 rounded-none rounded-b-lg;
    +}
    + 
    +pre {
    +  @apply mx-auto overflow-auto p-4;
    +  @apply scrollbar scrollbar-h-2 scrollbar-track-rounded-md scrollbar-thumb-rounded-sm scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-800;
    + 
    +  [data-line] {
    +    @apply mx-0 px-2 leading-tight;
    +  }
    + 
    +  [data-highlighted-line] {
    +    @apply ps-1.5 bg-orange/20 dark:bg-orange-500/10 border-l-orange-500 border-l-2 border-opacity-50;
    +  }
    + 
    +  [data-highlighted-chars] {
    +    @apply rounded-md py-0 px-1.5 border-b-2 border-opacity-75 bg-opacity-30 dark:bg-opacity-20;
    +  }
    + 
    +  mark {
    +    @apply text-inherit bg-orange-500 border-b-orange-500;
    +  }
    +}
    + 
    +[data-chars-id='1'] {
    +  @apply bg-pink-500  border-b-pink-500;
    +}
    + 
    +[data-chars-id='2'] {
    +  @apply bg-emerald-500 border-b-emerald-500;
    +}
    + 
    +[data-chars-id='3'] {
    +  @apply bg-blue-500 border-b-blue-500;
    +}
    + 
    +[data-rehype-pretty-code-figure] code {
    +  @apply grid gap-0 font-mono;
    + 
    +  &[data-line-numbers] {
    +    counter-reset: line;
    + 
    +    > [data-line]::before {
    +      counter-increment: line;
    +      content: counter(line);
    + 
    +      @apply ml-2 mr-4 inline-block w-4 text-right text-slate-400 dark:text-slate-700;
    +    }
    +  }
    +}
    + 
    +html pre span {
    +  color: var(--shiki-light);
    +}
    + 
    +html.dark pre span {
    +  color: var(--shiki-dark);
    +}
    • astro
    • tailwind
    Share:
    \ No newline at end of file diff --git a/blog/design-of-a-web-scraper/index.html b/blog/design-of-a-web-scraper/index.html new file mode 100644 index 0000000..5ebdcf4 --- /dev/null +++ b/blog/design-of-a-web-scraper/index.html @@ -0,0 +1,135 @@ +Design of a Web Scraper — Michael Toohig
    Just Arrived in the USA! Check my resume here.

    · 6 min read

    Design of a Web Scraper

    The high-level overview of a custom web scraper.
+

    Background

    I built a web scraper for the Vanuatu Meteorology Services website so I could construct a Vanuatu weather API.

    Design Overview

    The scraper will process what I will call a Session. Each session has a name for reference, a list of web pages to scrape and a function to process/format the results. The reasoning here being that some data I want to collect requires multiple pages of raw data to construct so we need a Session to handle an all-or-nothing approach to collecting that data and processing it.

    sessions.py
    @dataclass
    +class SessionMapping:
    +    name: SessionName  # an Enum
    +    pages: list[PageMapping]
    +    process: callable  # processes results from PageMappings

    Each Page has a path to the URL it represents and a function to process/scrape the page. The output from the process function here will be passed to the process function in the Session later to aggregate the data from multiple pages.

    pages.py
    @dataclass
    +class PageMapping:
    +    path: PagePath  # an Enum
    +    process: callable
    + 
    +    @property
    +    def url(self):
    +        return config.VMGD_BASE_URL + self.path.value
    + 
    +    @property
    +    def slug(self):
    +        return self.path.value.rsplit("/", 1)[1]

    So together the session for the weekly forecast, which requires two pages of data to create, looks like the following.

    SessionMapping(
    +    name=SessionName.FORECAST_WEEK,
    +    pages=[
    +        PageMapping(PagePath.FORECAST_MAP, scrape_forecast),
    +        PageMapping(PagePath.FORECAST_WEEK, scrape_public_forecast_7_day),
    +    ],
    +    process=aggregate_forecast_week,
    +)

    Handling Sessions

    The main loop of the web scraper handles many Session objects and because we all know network requests are slow we setup an async loop to process all the sessions quickly.

    async def process_all_sessions() -> None:
    +    async with anyio.create_task_group() as tg:
    +        for session_mapping in session_mappings:
    +            tg.start_soon(process_session_mapping, session_mapping)

    Each Session is handled by the process_session_mapping function. Below is a condensed version of that function.

    async def process_session_mapping(session_mapping: SessionMapping):
    +    # create session
    +    async with async_session() as db_session:
    +        session = models.Session(name=session_mapping.name.value).create(db_session)
    + 
    +    # main session loop -- process pages
    +    try:
    +        async with async_session() as db_session, db_session.begin():
    +            results = []
    +            for page in session_mapping.pages:
    +                # process a page -- fetch webpage -> scrape data
    +                result = await process_page_mapping(db_session, page)
    +                results.append(result)
    + 
    +            # process the scrapped data to write the final aggregated data to database
    +            await session_mapping.process(db_session, session, results)
    + 
    +            # complete the session
    +            session.completed_at = now()
    +            db_session.add(session)
    +            await db_session.flush()
    +    except Exception as exc:
    +        logger.exception("Session failed: %s" % str(exc), traceback=True)

    Of note on the highlighted line is db_session.begin() which starts a database transaction and we handle the entire session in this context. This is so if any error is raised while processing the session or the pages the database transaction is cleanly rolled back and we are left with a NULL in the session’s end column to mark it as a failed session.

    IDsessionstartend
    1136forecast_weekly2023-03-26 8:35:28.235400NULL
    1137forecast_weekly2023-03-26 9:45:11.6954782023-03-26 9:45:20.502400

    Handling Pages & Errors

    Processing a page is quite simple but it is wrapped with lots of error handling so we can accurately record our errors for future reference. First, we attempt to fetch the page and handle all the possible network errors we could encounter. Then, we attempt to scrape the HTML page and handle all the possible errors we could encounter again. Finally, we pass the scraping_result back to process_session_mapping so we can save the page’s raw data and then run the process function of the Session this page belongs to.

    async def process_page_mapping(db_session: AsyncSession, mapping: PageMapping):
    +    error = None
    +    # grab the HTML
    +    try:
    +        html = await fetch_page(mapping)
    +    except httpx.TimeoutException as e:
    +        error = (PageErrorTypeEnum.TIMEOUT, e)
    +    except PageUnavailableError as e:
    +        error = (PageErrorTypeEnum.UNAUHTORIZED, e)
    +    except PageNotFoundError:
    +        error = (PageErrorTypeEnum.NOT_FOUND, e)
    +    except Exception as e:
    +        logger.exception("Unexpected error fetching page: %s" % str(e))
    +        error = (PageErrorTypeEnum.INTERNAL_ERROR, e)
    + 
    +    if error:
    +        await handle_processing_page_mapping_error(db_session, mapping, error)
    + 
    +    # process the HTML
    +    try:
    +        scraping_result = await mapping.process(html)
    +    except ScrapingNotFoundError as e:
    +        error = (PageErrorTypeEnum.DATA_NOT_FOUND, e)
    +    except ScrapingValidationError as e:
    +        error = (PageErrorTypeEnum.DATA_NOT_VALID, e)
    +    except ScrapingIssuedAtError as e:
    +        error = (PageErrorTypeEnum.ISSUED_NOT_FOUND, e)
    +    except Exception as e:
    +        logger.exception("Unexpected error processing page: %s" % str(e))
    +        error = (PageErrorTypeEnum.INTERNAL_ERROR, e)
    + 
    +    if error:
    +        await handle_processing_page_mapping_error(db_session, mapping, error)
    + 
    +    return scraping_result

    In the event of an error our handle_processing_page_mapping_error function will record what went wrong for later review including saving any HTML files that failed to be scraped and then the error is raised again to cause the database transaction in process_session_mapping to exit. I’ve also added logic to stack repeated errors with the count column so repeated errors don’t need multiple rows.

    IDcreatedupdatedpagedescriptionexceptionhtml_hashjson_dataerrorscount
    22023-03-24 07:00:08.8116122023-03-26 8:00:11.226044forecast_weekTIMEOUTNULLNULLNULLNULL3

    Scraping and Validating Data

    Lastly, we reach the real work being done. Each web page has has a purpose built function to extract the data from the HTML. The functions are full of hardcoded strings, fragile tree traversals and excessive uses of a function I called strip_html_text which just does .strip().replace("\n", " ").replace("\t", "").replace("\xa0", ""). If the VMGD pages were to change then this is where code would need rewritten.

    async def scrape_public_forecast_7_day(html: str) -> ScrapeResult:
    +    """Simple weekly forecast for all locations containing daily low/high temperature,
    +    and weather condition summary.
    +    """
    +    forecasts = []
    +    soup = BeautifulSoup(html, "html.parser")
    +    # grab data for each location from individual tables
    +    try:
    +        for table in soup.article.find_all("table"):
    +            for count, tr in enumerate(table.find_all("tr")):
    +                if count == 0:
    +                    location = tr.text.strip()
    +                    continue
    +                date, forecast = tr.text.strip().split(" : ")
    +                summary = forecast.split(".", 1)[0]
    +                minTemp = int(forecast.split("Min:", 1)[1].split("&", 1)[0].strip())
    +                maxTemp = int(forecast.split("Max:", 1)[1].split("&", 1)[0].strip())
    +                forecasts.append(
    +                    dict(
    +                        location=location,
    +                        date=date,
    +                        summary=summary,
    +                        minTemp=minTemp,
    +                        maxTemp=maxTemp,
    +                    )
    +                )
    +        v = Validator(process_public_forecast_7_day_schema)
    +        errors = []
    +        for location in forecasts:
    +            if not v.validate(location):
    +                errors.append(v.errors)
    +        if errors:
    +            raise ScrapingValidationError(html, forecasts, errors)
    +    except SchemaError as exc:
    +        raise ScrapingValidationError(html, forecasts, str(exc))
    + 
    +    # grab issued at datetime
    +    try:
    +        issued_str = strip_html_text(
    +            soup.article.find("table").find_previous_sibling("strong").text
    +        )
    +        issued_at = process_issued_at(issued_str, "Port Vila at")
    +    except (IndexError, ValueError):
    +        raise ScrapingIssuedAtError(html)
    +    return ScrapeResult(raw_data=forecasts, issued_at=issued_at)

    Our ScrapeResult data should have a predictable format/schema but HTML scraping can give subtle errors if you aren’t careful. To solve this problem I am using Cerberus to define the structure and types of data I expect.

    process_public_forecast_7_day_schema = {
    +    "location": {"type": "string", "empty": False},
    +    "date": {"type": "string", "empty": False},
    +    "summary": {"type": "string"},
    +    "minTemp": {"type": "integer", "coerce": int},
    +    "maxTemp": {"type": "integer", "coerce": int},
    +}

    After this step the session handling function runs its processing function to write the results to the database.

    Conclusion

    As of time of writing, I have been collecting for the past 4 months without error and building up a nice little history of weather data.

    It took a bit of time to layout the right abstractions but in the end I believe it was the right one for this purpose built web scraper. Next time I would like to look at the big web scraping frameworks for some other project ideas I have to see how they compare and how much better I could make my own scraper.

      Share:
      FYI: This post is a part of the following project.
      VMGD API

      VMGD API

      An unofficial API for Vanuatu Meteorology Services.

      \ No newline at end of file diff --git a/blog/directus-extensions/index.html b/blog/directus-extensions/index.html new file mode 100644 index 0000000..c312425 --- /dev/null +++ b/blog/directus-extensions/index.html @@ -0,0 +1,189 @@ +Custom Extensions for Directus — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 9 min read

      Custom Extensions for Directus

      How to get started with self-hosting Directus and write custom extensions.

      For the Sabdivisen.com project I needed a headless CMS that would provide a great experience for the client and also for myself. Most importantly, I wanted a CMS that I could be confident I could extend easily if I found myself needing an unique feature. One such feature I knew I would need early in the project planning was a way to allow the client to upload their KML files and transparently convert them to GeoJSON for use by the frontend. This will avoid requiring the client to pre-process files and hopefully make the experience more seamless for them as they can continue to use the file formats they are familar with.

      After reviewing several options I decided on using Directus. However, adding custom extensions to Directus has one hang-up, it requires either an Enterprise Cloud subscription or you must self-host. I opted to self-host Directus.

      Self-Hosting Directus

      To begin, I organized the project files as follows for reference. The backend directory contains code and data for Directus while the frontend directory contains Sabdivisen.com’s public Vue.js website.

      Directory Tree
      .
      +├── backend
      +│   ├── extensions
      +│   └── uploads
      +├── frontend
      +│   └── ...
      +└── docker-compose.yml

      The docker-compose.yml file below is a slightly modified version of the sample found in the documentation provided by Directus and highlighted below is the key line that mounts our local extensions directory for loading extensions into Directus.

      docker-compose.yml
      version: '3'
      +services:
      +  database:
      +    image: postgis/postgis:13-master
      +    volumes:
      +      - ./data/database:/var/lib/postgresql/data
      +    environment:
      +      POSTGRES_USER: 'directus'
      +      POSTGRES_PASSWORD: 'directus'
      +      POSTGRES_DB: 'directus'
      + 
      +  cache:
      +    image: redis:6
      + 
      +  directus:
      +    image: directus/directus:10.1.0
      +    ports:
      +      - 8055:8055
      +    volumes:
      +      - ./backend/uploads:/directus/uploads
      +      - ./backend/extensions:/directus/extensions
      +    depends_on:
      +      - cache
      +      - database
      +    environment:
      +      KEY: '255d861b-5ea1-5996-9aa3-922530ec40b1'
      +      SECRET: '6116487b-cda1-52c2-b5b5-c8022c45e263'
      + 
      +      DB_CLIENT: 'pg'
      +      DB_HOST: 'database'
      +      DB_PORT: '5432'
      +      DB_DATABASE: 'directus'
      +      DB_USER: 'directus'
      +      DB_PASSWORD: 'directus'
      + 
      +      CACHE_ENABLED: 'true'
      +      CACHE_STORE: 'redis'
      +      REDIS: 'redis://cache:6379'
      + 
      +      ADMIN_EMAIL: 'admin@example.com'
      +      ADMIN_PASSWORD: 'd1r3ctu5'
      + 
      +      # Make sure to set this in production
      +      # (see https://docs.directus.io/self-hosted/config-options#general)
      +      # PUBLIC_URL: 'https://directus.example.com'

      The next time Directus is restarted the local extensions directory will be populated as seen below.

      Directory Tree Updated
      .
      +├── backend
      +│   ├── extensions
      +│   │   ├── displays
      +│   │   ├── endpoints
      +│   │   ├── hooks
      +│   │   ├── interfaces
      +│   │   ├── layouts
      +│   │   ├── modules
      +│   │   ├── operations
      +│   │   └── panels
      +│   └── uploads
      +├── frontend
      +│   └── ...
      +└── docker-compose.yml

      Directus will scan these new dirctories for any Javascript modules and attempt to load them as an extension. Directus will see a file such as ./backend/extensions/endpoints/my-endpoint/index.js and attempt to load the my-endpoint custom API endpoint or ./backend/extensions/hooks/my-hook/index.js and attempt to load the my-hook custom API hook. Now we can write an extension.

      Writing the Extension

      The documentation for extensions is decent but I feel some examples are shallow or require you to dig around the docs to see the whole picture, so I’ll write the following out almost step-by-step.

      Extension Types

      There are many extesion types as you may have noticed when Directus populated the extensions directory. The 8 extension types can be combined to create even greater customizations. In the docs they describe this as an extension bundle and provide tools to help build them.

      I chose to write an API Hook extension because it allows us to run custom logic whenver specific events occur. The hook would allow us to inspect a subdivision payload, read the KML file, do some processing to generate the GeoJSON data from the KML contents and finally commit the modified payload to the database.

      I should mention that this could be done with a webhook to an external service as well. Although, as a webook the custom logic would be done asynchronously and we would not be able to interrupt the database transaction so that’s why I went with API hook extension instead.

      Creating The Data Model

      First, I created a Subdivisions collection in Directus which means behind the scenes Directus will create a table in the database for our subdivision data and Directus will provide us an API to interact with this table.

      For this demostration we only need two fields in our table. A File type field named kml is created to store the uploaded KML file and a Map type field named polygon is created to store the GeoJSON data.

      KML and Map fields

      You can polish things further by making the polygon field hidden or read-only so that only a new KML file can overwrite the polygon value. Then, you can update the access controls so the API response will not contain the kml field and your frontend will only receive the prepared GeoJSON data in the polygon field.

      Actually Writing the Extension

      First let’s create a no-op extension. This hook will do nothing but prove that Directus is configured correctly and our extensions are loading successfully. Notice this is a hook extension that uses the filter event, there are other events you may consider for your usecase.

      I wrote the API hook extension in the file ./backend/extensions/hooks/kml-converter/index.js so that Directus knows this code is a hook and it is called kml-converter.

      backend/extensions/hooks/kml-converter/index.js
      import { defineHook } from "@directus/extensions-sdk";
      + 
      +export default defineHook(
      +  ({ filter }) => {
      + 
      +    filter("items.create", async (input, { collection }, context) => {
      +      return input;
      +    });
      + 
      +    filter("items.update", async (input, { collection }, context) => {
      +      return input;
      +    });
      +  }
      +)

      When you restart the Directus container you should see the following log which shows our extension has successfully loaded.

      directus | [04:58:00.180] INFO: Loaded extensions: kml-converter

      However, this extension listens to every create and update event which is unnecessary for our use-case. We can add the collection name to our filter to limit our hook to only events that create or update the Subdivisions collection. Or alternatively, I could have used an if statement to check the value of collection is equal to Subdivisions.

      backend/extensions/hooks/kml-converter/index.js
      import { defineHook } from "@directus/extensions-sdk";
      + 
      +export default defineHook(
      +  ({ filter }) => {
      + 
      +    filter("Subdivisions.items.create", async (input, { collection }, context) => {
      +      return input;
      +    });
      + 
      +    filter("Subdivisions.items.update", async (input, { collection }, context) => {
      +      return input;
      +    });
      +  }
      +)

      Now the hook can act on the correct events, so I create a dummy function convertKmlToGeoJSON which is where I will actually do the real work of converting the KML file to GeoJSON soon. Also, take note that the update hook specifically checks if the kml property exists on input because if the KML file does not change during an update then it won’t be present on the input object and we can return as we don’t need to update the existing GeoJSON data.

      backend/extensions/hooks/kml-converter/index.js
      import { defineHook } from "@directus/extensions-sdk";
      + 
      +export default defineHook(
      +  ({ filter }) => {
      + 
      +    const convertKmlToGeoJSON = async (kmlAssetId, context) => {
      +      return undefined;
      +    }
      + 
      +    filter("Subdivisions.items.create", async (input, { collection }, context) => {
      +      const polygon = await convertKmlToGeoJSON(input.kml, context);
      +      input.polygon = polygon;
      +      return input;
      +    });
      + 
      +    filter("Subdivisions.items.update", async (input, { collection }, context) => {
      +      if (!input.hasOwnProperty('kml')) {
      +        return input;
      +      }
      +      const polygon = await convertKmlToGeoJSON(input.kml, context);
      +      input.polygon = polygon;
      +      return input;
      +    });
      +  }
      +)

      Adding Packages to the Environment

      To convert the KML files easily I wanted to use the togeojson package that was built for converting KML to GeoJSON 🙄…duh. But this package is not available in Directus by default so it is not available to the extension either.

      There is another way to add packages, see the Conclusion at end of this post for more information.

      To install additional packages into the Directus environment I had to modify the docker-compose.yml file and include a new Dockerfile in the backend directory. Instead of using the official Directus image I am building a new image ontop of the official image.

      docker-compose.yml (snippet)
      services:
      +  directus:
      +    # image: directus/directus:10.1.0
      +    container_name: directus
      +    build:
      +      context: backend
      +      dockerfile: Dockerfile
      +  ...

      I go through the hassle of installing pnpm because I already use it for the frontend codebase and wanted consistency…

      backend/Dockerfile
      FROM directus/directus:10.1.0
      + 
      +USER root
      +RUN corepack enable && corepack prepare pnpm@8.3.1 --activate
      + 
      +USER node
      +RUN pnpm install togeojson xmldom stream-to-string

      Later I had to install a couple other packages to handle the KML file itself as you will see.

      The Final Extension with Error handling

      Now, I can put the whole thing together. Only 3 lines are actually converting the KML file (thanks togeojson). The remaining lines are handling the KML file itself and handling errors.

      File type fields in Directus are a relationship to the Assets table so the field only contains the asset ID. I had to use the AssetsService to obtain the file stream so that I could access its contents.

      The great majority of lines are error handling. I found when the extension fails the user will only receive a generic internal server type error which certainly doesn’t help them. Instead I raise the InvalidPayloadException where ever I can to provide useful information to the user, especialy since many errors were due to bad KML files and with useful error messages the client could then fix it themselves.

      I renamed the function here convertKmlToPolygon to match its use as I only want the coordinates field from the GeoJSON object.

      backend/extensions/hooks/kml-converter/index.js
      import { defineHook } from "@directus/extensions-sdk";
      +import { DOMParser } from "xmldom";
      +import toGeoJSON from "togeojson";
      +import streamToString from "stream-to-string";
      + 
      +export default defineHook(
      +  ({ filter }, { exceptions, services }) => {
      +    const { InvalidPayloadException } = exceptions;
      +    const { AssetsService } = services;
      + 
      +    const convertKmlToPolygon = async (kmlAssetId, context) => {
      +      // get the kml file
      +      const assets = new AssetsService(context);
      +      const { stream } = await assets.getAsset(kmlAssetId, {});
      +      
      +      // convert kml to geojson
      +      const doc = await streamToString(stream);
      +      const kml = new DOMParser().parseFromString(doc)
      +      const gj = toGeoJSON.kml(kml);
      + 
      +      // sanity check
      +      if (!gj.hasOwnProperty('features')) {
      +        throw new InvalidPayloadException('KML file does not have features.');
      +      }
      + 
      +      // grab geometry data from geojson and remove Z value from coordinates
      +      let geometry = gj.features?.[0]?.geometry
      +      if (geometry === undefined) {
      +        throw new InvalidPayloadException('KML file does not have geometry.')
      +      }
      +      const coords = geometry.coordinates[0].map((coord) => [coord[0], coord[1]])
      +      geometry.coordinates = [coords];
      +      return geometry;
      +    }
      + 
      +    filter("Subdivisions.items.create", async (input, { collection }, context) => {
      +      try {
      +        const polygon = await convertKmlToPolygon(input.kml, context);
      +        input.polygon = polygon;
      +        return input;
      +      } catch (error) {
      +        console.error(error);
      +        throw new InvalidPayloadException(`Failed to convert KML file. ${error}`);
      +      }
      +    });
      + 
      +    filter("Subdivisions.items.update", async (input, { collection }, context) => {
      +      // return if `kml` column is not updated
      +      if (!input.hasOwnProperty('kml')) {
      +        return input;
      +      }
      +      try {
      +        const polygon = await convertKmlToPolygon(input.kml, context);
      +        input.polygon = polygon;
      +        return input;
      +      } catch (error) {
      +        console.error(error);
      +        throw new InvalidPayloadException(`Failed to convert KML file. ${error}`);
      +      }
      +    });
      +  }
      +)

      Conclusion

      The way I added this extension to Directus is one way to do so but probably best for small extensions. The other way is with the create-directus-extension utility which you can read more about here. The advantage being you can keep dependencies of your extensions seperate from Directus and other extensions and publish your extension as a package for others to use.

      • directus
      • docker
      Share:
      FYI: This post is a part of the following project.
      \ No newline at end of file diff --git a/blog/directus-insights-are-not-mature/index.html b/blog/directus-insights-are-not-mature/index.html new file mode 100644 index 0000000..7bf1a29 --- /dev/null +++ b/blog/directus-insights-are-not-mature/index.html @@ -0,0 +1,4 @@ +Directus Insights Are Not Mature — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 3 min read

      Directus Insights Are Not Mature

      Directus itself is a great headless CMS but its Insights feature is not mature.
+

      I’ve made two previous posts using the tagline X is not mature and both of those technologies were soon updated after I published. I am honestly hoping the same happens again with the insights features in Directus.

      What is Directus Insights

      Directus Insights is a no-code feature of the Directus CMS to quickly put together dashboards and/or charts from your data. You still need understanding of the underlying database to use it but it abstracts away all the boilerplate required to fetch data from your database and coerce it into the format required by the particular chart or metric for presentation.

      What’s Wrong?

      Ultimately, the abstraction they use has some limitations and therefore the dashboards you can build are limited. There have been recent updates at the time of writing, but it still suffers from sporadic unexpected errors and lacks some features I would deem necessary to be practical.

      First, the Directus insights does not allow for a user selected column to be used as the display value in charts requiring a JOIN. So while the chart displays what you want to see it becomes useless in practice because the chart’s text displayed to the user is only the ID of the related table row. Any end user approaching this dashboard does not want to see an uuid or random digit when really they want a pie chart with product categories or usernames.

      Unstable

      The Directus insight dashboards are particularly unstable with global relational values. These values allow the user to select an item from a table which you can use in other dashboard components to filter charts, lists, metrics, etc. However, often times the dashboard will error out when it loads or when the user changes this value requiring a refresh of the page.

      Impractical List Limits

      The list component has an Impractical design limit forcing you to select the max number of items to display without pagination. For a non-technical end user this could hide the possibility that items are removed to comply with this arbitrary limit. Plus, the list component is not a proper table so it does not do well to display structured data and quickly overflows the screen making it less attractive for any usecase that may display unknown numbers of items. It feels arbitrary and like a short-sighted design in my opinion.

      Conclusion

      The insights features have a lot of potential and for the most basic usecases is fine. But, usecases requiring more than 1 or 2 tables joined it falls short.

      I’ve written extensions for Directus to get around it but the learning curve to write these extensios make me believe now that it may be better to exclusively keep Directus as a great CMS and backend and let other framewoks do the dashboards and presentation outside of Directus for best results.

      • directus
      Share:
      FYI: This post is a part of the following project.
      \ No newline at end of file diff --git a/blog/fastapi-is-not-mature/index.html b/blog/fastapi-is-not-mature/index.html new file mode 100644 index 0000000..04191e0 --- /dev/null +++ b/blog/fastapi-is-not-mature/index.html @@ -0,0 +1,45 @@ +FastAPI Is Not Mature — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 7 min read

      FastAPI Is Not Mature

      FastAPI and its ecosystem is touted for more than it is ready for.
+

      I was recommended to give FastAPI a try and so I began using it for my next personal project, Bilolok. At the time, FastAPI was still a bit young and I knew there was some risk adopting a new framework but, hype around the project got my attention and my project was personal so I didn’t worry about the consequencies if I had throw the whole project away and rewrite with Flask later. Plus, I would add some experience with async Python into my repertoire.

      At this point async Python has around for awhile but I had not realized how many packages for web development were still not ready for the jump to async. Due to this power vacuum if you will, the Python ecosystem exploded up with many async capable SQL ORM packages and they all looked nice but most of them showed their limits compared to the power of SQLAlchemy when digging into them.

      SQLAlchemy

      I think SQLAlchemy is an essential tool for its ability to simplify database access for simple use cases and get out of the way for more complex SQL queries. That plus the fact it is heavily recommended in the FastAPI docs is why I write about it here.

      Around this time SQLAlchemy had released version 1.4 which introduced async functionaliy and a fundamental shift in thier API for the future of SQLAlchemy on the road to version 2.0. But, SQLAlchemy 1.4 was not stable yet. So I started the Bilolok project with SQLAlchemy 1.3 and ran into issues immediately trying to force the async requests to handle the sync database connections.

      The first issue was tearing down SQLAlchemy connections after each request. Something in FastAPI was failing to complete the teardown lifecycle hook which would cause database connections to be left hanging or at best leave unsightly exceptions in the logs.

      Also, SQLAlchemy is syncronous while that kinda defeated the purpose of using an asyncronous framework like FastAPI. I like SQLAlchemy and I wanted to use it so rather than compromising on some new ORM that had limited features and unknown bugs, I decided to use a package supported in the FastAPI documentation called “encode/databases” which wrapped SQLAlchemy 1.3 until I was ready for version 1.4 and its native async functionality. encode/databases (which is one of the worst package names) wrapped the SQLAlchemy database connection in some magic that made it async and therefore worked with FastAPI, but that’s just the writing on the box, where in reality it didn’t always work perfectly and it felt like a kludge.

      Eventually, SQLAlchemy 1.4 appeared to be stable and I upgraded to that. But now I needed to adapt to its new API and its intricacies with regards to handling relationships which requires a new way to think about using SQLAlchemy. In older versions you could access a model’s relationships without thought at anytime even though that would easily create n+1 problems. Now, you have to carefully plan your SQL queries in advance to have loaded all relationships you will need.

      So in the end SQLAlchemy issues I faced are not really a FastAPI maturity problem but in general I found commonly used and powerful packages that are often used for web development just were not ready for async or were working their way there and this means issues will arise when you try to use FastAPI coming from a more mature, sync web framework.

      Alembic Revisions

      One of the issues upgrading SQLAlchemy meant Alembic became a minor wart in the code base. Alembic does not support async connections to the database so I had to keep an async and sync SQLAlchemy engine in my code base with two different database URIs so Alembic could run syncronous upgrades to my database schema and FastAPI had access to the async engine for normal API requests.

      Pydantic

      From my background with Flask I had picked up Marshmallow and then later Webargs for handling de/serialization. Especially in my less experienced years, I would abuse the Function and other Marshmallow features that allowed dynamic attributes which would lazily load relationships on the ORM object causing all sorts of n+1 problems if not careful. Marshmallow also allowed methods to handle data differently depending if you were loading or dumping data which added more flexibility but ultimately more complexity to reason about.

      The equivelant package recommeneded by the FastAPI documentation is Pydantic which uses the sorta new Python typing features to define a schema’s data types which has a nice, clean API and I was happy to start using it. It also doesn’t differentiate between dumping and loading so you end up creating many more schemas to represent a single entity compared to marshmallow. Which is not too bad and results in explicit, clear schemas. But every schema you need ends up looking like the following.

      import uuid
      +from typing import List, Optional
      + 
      +from .base import BaseSchema
      + 
      + 
      +class NakamalSchemaBase(BaseSchema):
      +    name: Optional[str] = None
      +    aliases: Optional[List[str]] = None
      +    lat: Optional[float] = None
      +    lng: Optional[float] = None
      +    owner: Optional[str] = None
      +    phone: Optional[str] = None
      +    light: Optional[str] = None
      +    windows: Optional[int] = None
      + 
      + 
      +class NakamalSchemaUpdate(NakamalSchemaBase):
      +    area_id: Optional[uuid.UUID] = None
      +    kava_source_id: Optional[uuid.UUID] = None
      + 
      + 
      +class NakamalSchemaIn(NakamalSchemaBase):
      +    name: str
      +    light: str
      +    windows: int
      +    lat: float
      +    lng: float
      +    area_id: uuid.UUID
      +    kava_source_id: uuid.UUID
      + 
      + 
      +class NakamalSchema(NakamalSchemaBase):
      +    id: uuid.UUID
      +    kava_source: NakamalKavaSourceSchema
      +    resources: List[NakamalResourceSchema]
      +    area: NakamalAreaSchema
      + 
      + 
      +class NakamalSchemaOut(NakamalSchema):
      +    pass
      + 

      Overall, the DX with Pydantic is not much different from Marshmallow except, and this many be a big one for some, it doesn’t come with the data validation abilities or serialization hooks built in. Regardless, the transition to using this Pydantic is not as difficult as the work arounds to get SQLAlchemy working with FastAPI.

      The Big Issue With Pydantic

      Now the reason for this post is I found Pydantic really fell short in more complex relationships. Where Marshmallow and SQLAlchemy handle circular references just fine, Pydantic requires a ForwardRef to handle a bi-directional relationships and any more relationships results in an impossible infinite loop.

      Again, this is not per-se a probem with FastAPI but it is the recommended package to use and has no solution after a couple years of people asking how to resolve this. How this issue will affect Tiangolo’s (the guy who created FastAPI) SQLModel project which aims to combine SQLAlchemy and Pydantic models into a single monster model will work for anything beyond toy projects I have not yet seen.

      The recommended solution from responses to these issues or comments on StackOverflow seem to be “Don’t have circular dependencies” or the explanation of the ForwardRef solution which only works for most basic two-way relationships. The official solution seems to be have all models in a single file, but for anything beyond a toy project this is not reasonable and I believe shouldn’t be required.

      Conclusion

      I really don’t think anyone will read this, but just in case I don’t mean to say the work done for FastAPI is bad or not worth merit since it is IMO. I do enjoy FastAPI outside of these and other small nuisances. The ecosystem will improve and in time I believe and these issues will be resolved. But, I just had to vent some frustrations and document this issue for my future self.

      Future self here, I found returning relation object IDs as opposed to nested objects alleviated my issue but required I refactor my frontend caching strategy. You can read about it in another post.

      But to stay on topic with the title, FastAPI does have a few quirks and I do have more concern for its future when I realize how far the creator is stretching himself between FastAPI, SQLModel and other projects and as far as I know he has still not accepted much community developement support while the issue tracker on Github surpasses 1000.

      Performance speed is not my primary concern for my personal projects so my next personal project will likely return to using Flask with some lessons on dependency injection to take with me.

      • python
      • fastapi
      Share:
      \ No newline at end of file diff --git a/blog/index.html b/blog/index.html new file mode 100644 index 0000000..f6fd99a --- /dev/null +++ b/blog/index.html @@ -0,0 +1 @@ +Blog — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      My Blog

      Mostly I write how-to or lesson-learned stories coming from my larger projects.
      \ No newline at end of file diff --git a/blog/local-first-pwa/index.html b/blog/local-first-pwa/index.html new file mode 100644 index 0000000..d3b87c3 --- /dev/null +++ b/blog/local-first-pwa/index.html @@ -0,0 +1,132 @@ +Local First PWA — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 7 min read

      Local First PWA

      A webapp built for offline users which gracefully handles expired authorizations.

      I needed an app that would allow users to submit forms while offline and/or with expired authorization credentials. But the app is not available for anyone to access so the user must be manually registered and the user must login once before using the app. Similiar to a guest mode on e-commerce or other websites but I’ll call it local-first as I do not open the app for the public and the app is expected to work without network or authorization to the backend; hence it works locally first.

      The users for this app are delivery drivers who opperate in remote areas for extended trips so a worst case scenario is losing authorization, going offline then stuck at the login screen unable to use the app to record deliveries.

      Solution

      The solution revolves around my authStore and the decision I made to treat both the user’s info and their authorization as independent entities. // I mitigated this by treating both the user’s info and their authorization token as independent entities. In a typical app with user login the two would be all or nothing and when the authorization token is lost the user is usually logged out and forced to login again to verify their identity. I decided if the user had logged in once already then I can allow them to continue in the app and they can fill an outbox with items but can not submit the items until they verify their identity again by logging in. Plus, this outbox pulls double-duty for authenticated users who just happen to be offline.

      Handling Authorization

      First step, I had to keep info about the user and the user’s authorization token in the browser’s local storage.

      authStore.ts
      export const storedUser: Ref<string | null> = useStorage('user', null);
      +export const authToken: Ref<string | null> = useStorage('auth_token', null);

      Then I set up an auth store with Pinia and defined basic actions. I’m using Directus for the backend of this project. Of note, user is a computed value using the storedUser so that even after a page refresh we can seamlessly load the user from the browser’s storage.

      authStore.ts
      export const useAuthStore = defineStore('auth', {
      +  state: (): AuthStoreState => ({
      +    loading: false,
      +    error: null,
      +  }),
      +  getters: {
      +    user(): UserType | null {
      +      if (!storedUser.value) return null;
      +      try {
      +        return storedUser.value ? JSON.parse(storedUser.value) as UserType : null;
      +      } catch {
      +        console.log('[Auth] failed to parse user')
      +        storedUser.value = null;
      +        return null;
      +      }
      +    },
      +    isLoggedIn(): boolean {
      +      return authToken.value !== null && this.user !== null;
      +    },
      +  },
      +  actions: {
      +    async getMe() {
      +      try {
      +        const me = await directus.users.me.read();
      +        const user: UserType = {
      +          id: me.id,
      +          first_name: me.first_name,
      +          last_name: me.last_name,
      +          email: me.email,
      +          avatar: me.avatar,
      +        };
      +        storedUser.value = JSON.stringify(user);
      +      } catch (err) {
      +        console.log('[Auth] error fetching me', err);
      +        throw err;
      +      }
      +    },
      +    async login(credentials: CredentialsType) {
      +      try {
      +        this.loading = true;
      +        await directus.auth.login({ ...credentials });
      +        await this.getMe()
      +        return;
      +      } catch (err: any) {
      +        const error = err.response?.data?.errors[0]?.extensions?.code || err;
      +        if (error == 'INVALID_CREDENTIALS' || error == 'Error: Invalid user credentials.') {
      +          this.error = 'INVALID_CREDENTIALS';
      +        } else {
      +          console.error('[Auth] Unhandled login error', err);
      +          this.error = err;
      +        }
      +        throw err;
      +      } finally {
      +        this.loading = false;
      +      }
      +    },
      +    async logout() {
      +      try {
      +        await directus.auth.logout();
      +      } finally {
      +        authToken.value = null;
      +        storedUser.value = null;
      +      }
      +    },
      +  },
      +});

      Login Page

      The app’s router can now use the authStore and only if there is no user do we direct the user to the login page. This allows a user that has previously logged in to continue using the app without an auth token. This would now be an anonymous mode or something but not a guest mode like you would see on some e-commerce websites.

      router.ts
      router.beforeEach((to, from, next) => {
      +  if (to.name === 'login') {
      +    const authStore = useAuthStore();
      +    if (authStore.isLoggedIn) {
      +      next({ name: 'home' });
      +    }
      +  }
      +  if (to.matched.some((record) => record.meta.requiresAuth)) {
      +    const authStore = useAuthStore();
      +    if (!authStore.user) {
      +      next({ name: 'login' });
      +    }
      +  }
      +  next();
      +});

      Later, have to handle some problems we just introduced if an unauthenticated user attempts to GET something from a protected endpoint.

      A user may try to login but then become offline, so to prevent being stuck on the login screen we add a link to allow the user to return to the main app. Alternatively, we could have offered a modal for users to re-login which would not lock the user in a route they cannot navigate out of otherwise. Or someone could not design their login page like mine which hid all navigation by default .

      LoginPage.vue
      <template>
      +<!-- page layout and form omitted -->
      +<router-link v-if="user" :to="{ name: 'home' }">
      +  Skip
      +</router-link>
      +</template>
      + 
      +<script setup lang="ts">
      +const authStore = useAuthStore();
      +const { user } = storeToRefs(authStore);
      +</script>

      Elsewhere we can write logic to distinguish between a user with valid authorization or not using isLoggedIn.

      Foo.vue
      <template>
      +  <button v-if="isLoggedIn" @click="syncPendingRequests">Send Outbox Items</button>
      +  <router-link v-else :to="{ name: 'login' }">Login</router-link>
      +</template>
      + 
      +<script setup lang="ts">
      +const authStore = useAuthStore()
      +const { isLoggedIn } = storeToRefs(authStore);
      +</script>

      Handling Authorization Errors While Offline

      This may be enough boilerplate for some offline apps; however, for this particular app I needed more as all API endpoints including GET endpoints were secured. Therefore, without authorization the app would not be able to fetch what it needs to function. Here the service worker again transparently does double service to provide a cache for us when the user is offline or when the user is currently unauthorized.

      I was leaning on Workbox for this app’s service worker and to make it respond with cached results due to authorization errors I had to add a small plugin to the caching strategy.

      service-worker.ts
      registerRoute(
      +  matchDirectusApiCb,
      +  new StaleWhileRevalidate({
      +    cacheName: 'api',
      +    plugins: [
      +      new CacheableResponsePlugin({
      +        statuses: [0, 200],
      +      }),
      +      {
      +        cacheWillUpdate: async ({ request, response }) => {
      +          if (response && response.status === 403) {
      +            const cache = await caches.open('api');
      +            const cachedResponse = await cache.match(request);
      +            if (cachedResponse) {
      +              return cachedResponse;
      +            }
      +          }
      +          return response;
      +        },
      +      },
      +    ],
      +  })
      +);

      Entry Point to Local First

      Finally, we modify App.vue to attempt to update the current storedUser on initial load of the app. If that fails due to an expired or missing auth token then the app will continue forward using the last known user from the browser’s storage and if that fails then we hit the login page per the router’s setup. And that’s it, the user can now use the app in an local first manner.

      App.vue
      onBeforeMount(async () => {
      +  console.info('[App] Checking auth onBeforeMount');
      +  if (storedUser.value) {
      +    // previous login exists
      +    try {
      +      // update user
      +      await authStore.getMe();
      +    } catch (err) {
      +      console.log('[App] failed to get user', err);
      +    }
      +  }
      +});

      Next Steps

      Now, this particular setup works for my usecase but it is not a panacea for all local first apps.

      Firstly, I have the benefit of an app with closed registration which reduces the number of edge cases. Otherwise, I may have to consider handling deleting the current outbox when the user voluntarily logs out of the app as a new user could inherit an existing outbox when they login to a shared device.

      Secondly, this design relies on a service worker but an alternative solution could implement a local cache.

      For a different app you may have to adapt this tutorial for your particular requirements.

      Conclusion

      I hope this tutorial helps others to develop more apps that do not depend on an ever-present internet access. Also, I am grateful for being afforded the time to develop this app which actually provides real-world benefit.

      • pwa
      Share:
      FYI: This post is a part of the following project.
      \ No newline at end of file diff --git a/blog/migrating-to-astro/index.html b/blog/migrating-to-astro/index.html new file mode 100644 index 0000000..b8fe933 --- /dev/null +++ b/blog/migrating-to-astro/index.html @@ -0,0 +1,23 @@ +Migrating to Astro — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 10 min read

      Migrating to Astro

      Detailing the process I experienced migrating this site from Vitepress to Astro and how I got to `yet another framework`.
+

      Foreward

      This post may be a bit less structured as I am still technically migrating to Astro after publishing this and I am still finding bugs. I am just recording my thoughts for now and may clean this up down the road.

      I migrated my personal website to Astro while it was still in beta and I have upgraded through release-candidates all the way to official 1.x.x releases and still find issues being created with updates. Is Astro ever going to stablize?

      So What is Astro?

      Astro is a relatively new (maybe old in Javascript years) framework for building SSR or SSG websites. Astro as a framework is unique that you can Bring Your Own Framework which I haven’t heard of elsewhere. The BYOF feature meant I do get to learn a new hip thing that may along the way solve a problem I am having but also lets me use Vue.js which I am already familiar with. As I read someone else put it, it’s nice that Astro appears to keep itself focused on the static asset generation and makes itself flexible for the JS framework to change with the popularity tides down the road. Of course, unless a newer, better meta-framework comes along!

      Disclaimer: I don’t enjoy the churn in the frontend ecosystem but I want to stay relevant and fresh with new skills so I decided to give Astro a shot. Plus, as long as I would be learning something new, I might as well learn something currently hip and cutting-edge so I get that sweet RRD credit.

      Astro also supports dynamic content on the page and hydrating interactive components on the client side but all that sounds like unnecessary bloat for my usecase.

      Astro Hype

      When I decided to switch away from Vitepress I did some searching for Vue.js based SSG. I really didn’t want to have to pick up a new Javascript framework. I thought I wanted to use Gridsome because it uses Vue 3, Vite and most importantly it would be more flexible compared to Vitepress. But, I found after a bit of research on the Github repo that the project has been neglected and users on other forums were lamenting the death of the project.

      I then found many blogs mentioning Astro. I assume this is because it is adaptable to many UI frameworks so it has a larger pool of potential users to attract and it is the first (that I know of) meta-framework :rolls-eyes: which attracts more to the bandwagon. Plus, Astro has a large number of sponsors which may have contributed to its speedy growth and popularity so early in its development.

      I also noticed the quality of their website and documentation and believed at the time the framework was mature and stable. The number of recognizable companies listed on their homepage as sponsers also didn’t help me see Astro as a project still in beta.

      Astro Documentation

      Astro’s documentation is quite detailed and most of anything I wanted to do with Astro was easily found there. That was until the migration to version 1.0.0 began and their framework started changing too rapidly to keep the documentation up-to-date; but that I will say more about later.

      I will say though, the docs only caused me difficulty early on because I was fixated on using Vue.js as part of the BYOF feature and most of their docs use various frameworks or their own .astro files for examples so I had some trouble looking past that to adapt their examples to what I wanted. The example repos on their Github really clarified things in my mind then later I ended up finding their own .astro files to be pretty practical, so now I use them quite a bit. It really drives home their tagline, “Use Less Javascript”.

      My Plan

      I want to have a simple SSG personal website that allows me to write blog posts and pages for portfolio content via markdown while allowing some flexability with scripting so I can be sure the home page is always updated with the latest items from each category.

      The output should be HTML so I can host for free via Github. And if the output is static HTML then I hope the final result is a responsive, minimal and no frills website. But knowing me, as this goes on I may look for ways to add more complications in the future for the sake of exploring.

      simple I said, lol, I’m using a new wave, meta-framework with a mix of astro, markdown, vue, and who knows what else to compile static HTML. I really considered just writing markdown and HTML by hand with something as simple as Jinja for templating but that’s not RDD!

      First Challenge - Blog Posts And Portfolio Posts

      As I said earlier my first priority was to migrate to a system that allows me to be more flexible with my website layout and features. Foremost, I wish to have separate sections for blog posts and portfolio posts and combine them together on the home page with ease.

      So I began with the default Portfolio template provided by Astro which you can find here.

      Immediately I added a sibling directory post next to the already existing directory project. In there I added this file you are reading now. I found in the demo a page that lists all portfolio projects titled projects.astro and copied it to create posts.astro and inside there I changed the logic to fetch markdown files from the post directory and added a PostPreview component copied from the already existing PortfolioPreview component. Lastly, I simply added a nav item to the Nav component directed to the new posts.astro and I had acheived what I was looking for. A listing of portfolio pages on the URL /projects and a listing of blog posts on the URL /posts.

      By attaching a project tag to the frontmatter section of each post and project markdown file I quickly had a way to find posts that are related to a project.

      const posts = (await Astro.glob('/src/pages/post/**/*.mdx'))
      +	.filter(({ frontmatter }) => !frontmatter.draft && frontmatter.project === content.project)
      +	.sort((a, b) => new Date(b.frontmatter.publishDate).valueOf() - new Date(a.frontmatter.publishDate).valueOf());

      Then at the end of the portfolio.astro layout I loop through the posts and reuse the same PostPreview Vue component from the home page.

      {posts.length > 0 &&
      +  <div class="mt-12">
      +    <Wrapper>
      +      <h2 class="font-bold text-3xl my-8">Related Posts</h2>
      +      <div class="grid grid-cols-1 md:grid-cols-2 gap-12">
      +        {posts.map((post) => <PostPreview post={post} />)}
      +      </div>
      +    </Wrapper>
      +  </div>
      +}

      Customization of this sort was my real goal and it ended up being easier than I had hoped.

      Second Challenge - Vue component inside a Markdown file

      I wanted to test Astro’s ability to add some dynamic content within the markdown content so I can share demostrations or examples in my posts or perhaps some other usecase down the road.

      Let’s add this Counter.vue from the documentation’s examples.

      To add components to Markdown files you add a setup key to the frontmatter object of the markdown file. This makes the solution easy but at first feels like a footgun to have Javascript logic in this yaml key.

      As of Astro 1.0.0 release and the migration to mdx over plain markdown the setup key has been removed. Instead Javascript logic can be written directly in the document itself.

      0

      Look ma I got Vue.js in my Markdown in my Astro.

      Hey, but it works.

      Third Challenge - Vue UI Framework For Layout and Components

      I wanted to use a UI Framework I’m already familiar with such as Vuetify so I can, again, use something I know to help keep things simple. I’m not the greatest with CSS nor do I want to play around with CSS too much but I do want the site to look good enough so I think a UI framework will fit that requirement.

      This challenge became impossible after realizing Astro abstracts Vite and the Vue instance so these packages that hook into either one of those can not be setup without an Astro plugin that would expose lower level access to Astro’s internals.

      Even though I didn’t want to muck around with CSS too much I ended up taking the opportunity to try Tailwindcss and play around with styling on my own. Tailwindcss is one of those packages that wouldn’t work normally with Astro for the same reason Vuetify won’t work but, the Astro team built a plugin to make it work (well mostly).

      Fourth Challenge - Aliases

      Quickly I found that I was missing aliases from my usual Vue.js and Webpack based development. It was easy to find in the docs how to make aliases even though it is technically a Vite feature and not related to Astro at all. So no longer do I type ../../components/whatever.vue but instead @components/whatever.vue. Nice little improvements like this make a big difference over time.

      So the solution is to add a few alias paths to tsconfig.json then aliases worked in both .astro and .vue components.

      "baseUrl": ".",
      +"paths": {
      +  "@components/*": [
      +    "src/components/*"
      +  ],
      +  "@layouts/*": [
      +    "src/layouts/*"
      +  ]
      +},

      Issues

      • Changing API

      The beta to 1.0.0 release has introduced some issues that temporarily broke this website. Also, the project threw out markdown and replaced it with markdownx. Same for code snippet handling.

      At this point I no longer know the proper way to display code snippets with the formatting I had set up while in beta since the documentation is not yet up to date on this.

      • Hot Module Reload Flakiness

      Occasionally my only option to be sure my code was taking effect was to restart the dev server. There are also some issues that can guarantee you will need to restart the dev server such as accidently importing a component with the the wrong extension. And editing the astro config file requires a restart of the dev server which is not that bad or suprising.

      • Tailwindcss or similar Vite based plugins

      Any UI framework or package that integrates directly with the Vue app instance is not going to work as it is abstracted away behind Astro. A plugin could resolve that I believe. Astro also abstracts Vite a bit so Vite based plugins do not work well with Astro either. Tailwindcss being one of those packages has an official Tailwindcss plugin but it doesn’t come without its own issues.

      Tailwindcss and other comparable Vite plugin based packages are a source of trouble for the Astro developers as I found in their Github issues page.

      The gist of the problem that I could gather from here and here seem to be UnoCSS, Windicss, etc are Vite plugins which require being built to work. Astro on the other hand never builds .astro files and insteads injects the default css file from these frameworks into the <head> which means it loads the css via request once then does not have HMR capability. Clearly, the meta-framework is running into issues when popular packages make assumptions about their intended environment that contradicts what Astro is doing under the hood. Work arounds have been made such as this but I can’t help but think between this and Astro developers’ discussions to fix these sort of issues point to a fundemental issue which will only lead to more issues or complexity in the future and Astro may end up with more bespoke configuration options that contradict their “Write Less Javascript” tag line.

      Positives

      • VSCode extension works very well

      • HMR generally works and is fast (until tailwind was added)

      Although with the latest releases this is improving.

      • Great docs and demos

      • Straightforward and gives enough freedom

      Final Thoughts

      Astro is relatively easy to get going and I enjoy the mix of Markdown and Vue.js that I can mix as I please. The project file structure just feels familiar and easy to use compared to some of the big players in the SSG space like Hugo and Gatsby. Astro’s API is changing though so I will need to keep up with in as it exits beta but I have good hopes that it will serve me well for this personal website.

      • javascript
      • vue
      Share:
      \ No newline at end of file diff --git a/blog/open-graph-facebook/index.html b/blog/open-graph-facebook/index.html new file mode 100644 index 0000000..07a3828 --- /dev/null +++ b/blog/open-graph-facebook/index.html @@ -0,0 +1,4 @@ +OG Tags and Facebook Issues — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 6 min read

      OG Tags and Facebook Issues

      Painful workarounds to accommodate Facebook's web crawler
+

      Working on my personal project Bilolok, I began implementing the Open Graph Protocol so that content shared on Facebook or other social media sites would appear professional and garner interest for the app. This was especially important for Facebook since nearly 1/3 of the country’s population are active members and I expect most incoming visitors would be from there.

      Originally, I just had static Open Graph tags in the project’s <head> but later, when I added more user content I wanted dynamic OG tags to match the content of the page.

      First Issue

      Bilolok is a SPA and therefore the index page’s <head> would not be able to update until the JavaScript was loaded and run. Well, that is true in the basic deployment pattern others exist and keep reading for more. Web crawlers or bots do not usually run JavaScript so whatever OG tags I set on the website’s index.html would be the OG tags for all content shared on external platforms. I was using a package called vue-meta to update the OG tags and page title when the user navigates through the website but again this requires the JavaScript is running which web crawlers and bots do not do. So I needed a way to render the OG tags I wanted server side so the external social media sites had the information I wanted.

      One solution would be to invest in server side rendered SPA such as NuxtJS which is Vue’s response to NextJS but that sort of investment to my app seemed like a major rewrite since Nuxt is very opinionated and I would have to make sure my app fits into whatever their conventions are.

      Another solution would be an external service such as Prerender.io. I did not look too far into this one since I didn’t want to take on an external service for my small project. Plus, I know my requirements are quite simple and I could handle it myself.

      So finally, I decided to build my own prerender of sorts. This solution uses a simple nginx if statement to catch known/popular crawler and/or bot user agent strings then redirect them to the dynamically crafted index.html file for the resource they requested. I do not prebuild index files for every resource but instead added a subdirectory in the backend FastAPI server where I use starlette to handle the requests. Starlette is the foundation which FastAPI is built so this doesn’t require any new packages to be installed to the project except for jinja2 for templating. Since the small app for OG tags is nested in the main backend app I have access to all the same models and CRUD classes so I can keep consistency between the main API and this app easily. The returned files are very simple only really containing the customized OG tags in the head and not much more.

      Possible Improvement

      So now we have two backend apps, the primary API and an app for server side rendered files containing customized OG tags. Don’t forget we also have to add if statements to our app’s nginx config to redirect known crawlers to this OG app. Also, don’t forget we have the default, static OG tags on the index page loaded by those who do not get caught in the crawler check. So at this point there is a bit of coupling between the dynamic OG tags and default static OG tags which makes me feel I have two sources of truth, 3 if you count the tags being modified by vue-meta in the app too.

      This sort of coupling has me thinking about how I could use the OG tags app to return an HTML file that includes scripts to the built frontend app. Now this is another sort of coupling that complicates deployment but removes the 2 or 3 sources of truth for OG tags. The trade-off down the road may be worth it but for now this quick afternoon fix to my initial problem using starlette suffices.

      Second Issue

      Facebook could not find the page each time I tested on their Sharing Debugger. They would show that they received a 307 response to upgrade their request from http to https. Apparently, they do not have time to follow redirects so I had add my crawler check into the nginx servers on port 80 and allow them to the OG tags app without the usual redirect to https.

      The OG tags app is working as intended and very small and easily to modify if ever needed. But, it still doesn’t work on Facebook. Facebook would also report issues for images saying the content type was invalid.

      Bilolok has a sub-domain for images since I found that easy to set up and I like keeping services distinct that way. I am using Thumbor to process return images so my first thought was something was wrong with how Thumbor was responding. Yes, Thumbor is configured to return image/webp formats if supported which Facebook sharing does not support so I thought that was issue but even forcing all users to use jpeg format by adding a header in the nginx config that forced Accept: image/jpeg did not resolve the issue.

      My next thought was to be sure Thumbor was not managling the images in some way that I hadn’t noticed before but the Facebook crawler may have been sensitive too. But, I noticed the same issue was true to a jpeg file on the root of the app which was returned by the root domain and not the sub domain for images so Thumbor was not the issue.

      Lastly, I dug further into Google to find this issue online. Suprisingly, I found a new result that said HTTPS was the issue. The first occurance of the issue I could find was a StackOverflow post from 2013 but apparently later posts also complained this issue was ongoing as late as 2018 and I can confirm even in 2022. As before the https issue was the root problem. I then had to modify my nginx app config further in the images subdomain config to check if a crawler was requesting the resource then allow them access the resource and not redirect to https.

      Benefits

      Overall this feature adds a bit of complexity to the Nginx config files and requires another systemd service to keep the starlette app running. But for an app that leans on social elements so much the reward of dynamic share titles and images is worth it. In fact, once I searched Facebook for a kava bar that I heard was having an event and my app was the top result since the kava bar did not have an active page of their own.

      Conclusion

      Catering to Facebook adds complexity to my nginx configs and some mental overhead for future development if I add new resources for sharing but I guess it is a necessary evil since my user base are active Facebook users so Facebook sharing has to work.

      • python
      • starlette
      Share:
      FYI: This post is a part of the following project.
      Bilolok

      Bilolok

      A Foursqaure inspired app for kava bars in Port Vila, Vanuatu.

      \ No newline at end of file diff --git a/blog/pwa-outbox/index.html b/blog/pwa-outbox/index.html new file mode 100644 index 0000000..a47df78 --- /dev/null +++ b/blog/pwa-outbox/index.html @@ -0,0 +1,319 @@ +Building a PWA Outbox Feature — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 9 min read

      Building a PWA Outbox Feature

      Recording failed POST requests and retrying on demand.

      I needed my PWA to gracefully handle network errors for users’ submissions so they would be stored in an outbox to send later. This would be kind of like the experience of using Gmail or your choice of messanging app while offline. You can still click send but the message won’t actually deliver until you regain internet access.

      To do this we need to break the problem down into a few parts. First, a queue to store the failed requests. Second, a hook so failed requests can be intercepted and placed into the queue. Lastly, a couple UI elements to interact with the queue. The process will look something like the following.

      Diagram

      Why Not Use Workbox’s Background Sync Plugin?

      I think this belongs in a post of its own.

      Building the Queue

      I used IndexedDB for the queue. I wrote a few helper functions for adding, removing and fetching items from the queue becuase later we need to import these functions into both the service worker and the frontend.

      retry-queue.ts
      import { openDB } from 'idb';
      +import { RETRY_QUEUE } from '../constants';
      +import type { Movement } from '../services/directus';
      + 
      +export const initQueue = async () => {
      +  console.log('[Queue] Init');
      +  await openDB(RETRY_QUEUE, 1, {
      +    upgrade(db) {
      +      if (!db.objectStoreNames.contains('queue')) {
      +        console.log('[Queue] Creating store')
      +        db.createObjectStore('queue', { keyPath: 'id' });
      +      }
      +    },
      +  });
      +};
      +export const addToQueue = async (payload: Movement) => {
      +  console.log('[Queue] Add one', payload);
      +  const db = await openDB(RETRY_QUEUE, 1);
      +  const tx = db.transaction('queue', 'readwrite');
      +  const id = Date.now()
      +  await tx.store.add({
      +    'id': id,
      +    payload,
      +  });
      +  await tx.done;
      +};
      + 
      +export const deleteFromQueue = async (id: number) => {
      +  console.log('[Queue] Delete one', id)
      +  const db = await openDB(RETRY_QUEUE, 1);
      +  const tx = db.transaction('queue', 'readwrite');
      +  await tx.store.delete(id);
      +  await tx.done;
      +}
      + 
      +export const getQueue = async () => {
      +  console.log('[Queue] Get all')
      +  const db = await openDB(RETRY_QUEUE, 1);
      +  const tx = db.transaction('queue', 'readonly');
      +  const queue = await tx.store.getAll();
      +  // add logic for maxRetention if you want that here
      +  await tx.done;
      +  return queue.sort((a, b) => {
      +    return a.date_reported > b.date_reported ? 1 : -1;
      +  });
      +};

      Later be sure to call initQueue in your entrypoint of your frontend.

      Intercepting Failed Requests

      In our service worker we create a hook to handle failed requests and place them into the queue. For my usecase I only need to filter for POST requests to a single /items/Movements endpoint so the logic is quite straightforward.

      sw.ts
      self.addEventListener('fetch', (event: FetchEvent) => {
      +  if (event.request.method !== 'POST') {
      +    return;
      +  }
      + 
      +  if (!event.request.url.includes('/items/Movements')) {
      +    return;
      +  }
      + 
      +  const bgSyncLogic = async (event: FetchEvent) => {
      +    try {
      +      const response = await fetch(event.request.clone());
      +      if (!response.ok) {
      +        throw new Error('Server error was not ok');
      +      }
      +      return response;
      +    } catch (error) {
      +      console.error('[SW] Failed to POST, adding to retry queue', error);
      +      const clonedrequest = event.request.clone();
      +      const payload = await clonedrequest.json();
      +      try {
      +        await addToQueue(payload);
      +      } catch (queueError) {
      +        console.error('[Queue] Failed to save to queue', queueError);
      +        throw queueError
      +      }
      +      throw error;
      +    }
      +  };
      + 
      +  event.respondWith(bgSyncLogic(event));
      +});

      Frontend UI

      On the frontend I first wrap the queue’s helper functions in a Pinia store. At the moment it looks like it adds very little value over the helper functions itself but later I will expand upon it by adding calls to the user notification library, user authentication store and the service worker.

      queueStore.ts
      import {
      +  addToQueue,
      +  deleteFromQueue,
      +  getQueue,
      +  initQueue,
      +} from '../service-worker/retry-queue';
      + 
      +interface QueueItem {
      +  id: number;
      +  payload: any;
      +};
      + 
      +interface State {
      +  items: QueueItem[];
      +  isSyncing: boolean;
      +  isSyncingTimer: any | null;
      +};
      + 
      +export const useRetryQueueStore = defineStore('queue', {
      +  state: (): State => ({
      +    items: [],
      +    isSyncing: false,
      +    isSyncingTimer: null,
      +  }),
      +  getters: {
      +    count(): number {
      +      return this.items.length;
      +    },
      +  },
      +  actions: {
      +    async init() {
      +      await initQueue();
      +      await this.getItems();
      +    },
      +    async getItems() {
      +      const items = await getQueue();
      +      this.items = items;
      +    },
      +    async addItem(payload: Movement) {
      +      await addToQueue(payload);
      +    },
      +    async deleteItem(id: number) {
      +      await deleteFromQueue(id);
      +    },
      +    async syncQueue() {
      +      ...
      +    },
      +    async setQueueComplete() {
      +      ...
      +    },
      +  },
      +});

      Then I built out a view for the user to interact with the queue.

      QueueView.vue
      <template>
      +<div>
      +  <div v-if="isLoggedIn" role="alert">
      +    <IconThumbsUp v-if="count === 0" />
      +    <IconLoading v-else-if="isSyncing" />
      +    <IconInfo v-else />
      +    <div>
      +      <span v-if="count === 0">Queue is empty.</span>
      +      <span v-else-if="!isSyncing">{{ count }} items are waiting in the queue.</span>
      +      <span v-else>Attempting to submit queue items.</span>
      +    </div>
      +    <button :disabled="count === 0 || isSyncing" @click="syncPendingItems">Sync</button>
      +  </div>
      +  <div v-else>
      +    <IconInfo />
      +    <span>You must login again. Then you may submit items in the queue.</span>
      +    <router-link :to="{ name: 'login' }">Login</router-link>
      +  </div>
      + 
      +  <div class="divider"></div>
      + 
      +  <div v-if="count > 0">
      +    <table>
      +      <tbody>
      +        <tr v-for="item in items" :key="item.id" @click="setSelected">
      +          <!-- queue item payload content and button to open modal to delete item -->
      +        </tr>
      +      </tbody>
      +    </table>
      +  </div>  
      +</template>
      + 
      +<script setup lang="ts">
      +import { onMounted, ref, watch } from 'vue';
      +import { storeToRefs } from 'pinia';
      + 
      +const queueStore = useQueueStore();
      +const { count, isSyncing, items } = storeToRefs(queueStore);
      + 
      +const selected = ref();
      +const setSelected = (item) => {
      +  selected.value = item;
      +};
      + 
      +const deleteSelectedItem = async () => {
      +  if (!selected.value) return;
      +  await queueStore.deleteItem(selected.value.id);
      +  selected.value = undefined;
      +  await queueStore.getItems();
      +};
      + 
      +const syncPendingItems = async () => {
      +  await queueStore.syncQueue();
      +};
      + 
      +watch(isSyncing, async (newValue: boolean) => {
      +  if (!newValue) {
      +    await queueStore.getItems();
      +  }
      +});
      + 
      +onMounted(async () => {
      +  await queueStore.getItems();
      +});
      +<script>

      Elewhere you may use the count value to display a badge or other UI element to indicate the queue is not empty.

      Message Passing

      Lastly, I need the means to pass messages from the frontend to the service worker and vice versa. This will allow the user to retry the queue manually and for the service worker to send back the results after retrying the queue for the purpose of UI notifications.

      To begin I store a constant RETRY_MESSAGE_KEY for the key for my event messages.

      constants.ts
      export const RETRY_MESSAGE_KEY = 'RETRY_QUEUE';

      This key is used in my auth store to tell the service worker I want to resubmit the items in the queue. I also must point out that my particular authentication pattern requires I submit the user’s authentication token in the message payload. This is because the service worker does not have access to the browser’s local storage and hence can not read it for the purpsoe of updating the request headers for the queue items so we manually pass it to the service worker. A different authentication pattern may not require this.

      Another alternative would be to store a refresh token with the original payload then including logic in your service worker to fetch its own auth token before attempting to submit an item from the queue. I found that to be a bit too complicated for my needs so I opted for this pattern. But, if you wanted true background sync without requiring the user to be actively online to submit the queue items this would be a good approach.

      authStore.ts
      actions: {
      +  async syncRetryQueue() {
      +    console.log('[Retry] Sending message to retry queue');
      +    const authStore = useAuthStore();
      +    const notifyStore = useNotifyStore();
      +    const authToken = authStore.authToken;
      +    if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
      +      if (!authToken) {
      +        notifyStore.notify('Failed to retry queue; you are not logged in.', NotificationType.Error);
      +        return;
      +      }
      +      navigator.serviceWorker.controller.postMessage({
      +        type: RETRY_MESSAGE_KEY,
      +        payload: {
      +          authToken,
      +        },
      +      });
      +      this.isSyncing = true;
      +      // A failsafe to stop isSyncing in case of unexpected error from SW
      +      this.isSyncingTimer = setTimeout(async () => {
      +        await this.setRetryComplete();
      +      }, 10000);
      +    }
      +  },
      +  async setRetryComplete() {
      +    console.log('[Retry] Updating retry state as complete');
      +    clearTimeout(this.isSyncingTimer);
      +    this.isSyncing = false;
      +    await this.getItems();
      +  },
      +},

      The service worker will receive the message and replay the queue with the provided authToken. Along the way we count the success rate of the retries and return a message back to the frontend via messageClientsRetryResults. This is like the authStore before sending a message to the service worker but in reverse and I reuse the RETRY_MESSAGE_KEY constant.

      sw.ts
      self.addEventListener('message', (event: ExtendableMessageEvent) => {
      +  console.log('SW received message', event.data);
      +  if (event.data && event.data.type === RETRY_MESSAGE_KEY) {
      +    const authToken = event.data.payload.authToken;
      +    replayQueue(authToken);
      +  }
      +});
      + 
      +const replayQueue = async (authToken: string) => {
      +  console.log('[Queue] Attempting retry queue');
      +  let successCount = 0;
      +  let failureCount = 0;
      +  const queue = await getQueue();
      +  try {
      +    for (const item of queue) {
      +      try {
      +        console.log('retrying item', item);
      +        await fetch(`${DIRECTUS_URL}/items/Movements`, {
      +          method: 'POST',
      +          headers: {
      +            'Content-Type': 'application/json',
      +            'Authorization': `Bearer ${authToken}`
      +          },
      +          body: JSON.stringify(item.payload)
      +        });
      +        console.log('[Queue] Retry Success; will delete from queue', item.id);
      +        await deleteFromQueue(item.id);
      +        successCount += 1;
      +      } catch (error) {
      +        // Request fails, leave it in the queue
      +        console.error('[Queue] Retry Failed', error);
      +        failureCount += 1;
      +      }
      +    }
      +  } finally {
      +    messageClientsRetryResults(successCount, failureCount);
      +  }
      +};
      + 
      +const messageClientsRetryResults = (success: number = 0, failure: number = 0) => {
      +  self.clients.matchAll().then((clients) => {
      +    clients.forEach((client) =>
      +      client.postMessage({
      +        type: RETRY_MESSAGE_KEY,
      +        success: success,
      +        failure: failure,
      +      })
      +    );
      +  });
      +});

      Back on the frontend I have an event listener which listens for the same RETRY_MESSAGE_KEY value and then uses the UI notification library to inform the user of the results.

      useRetryQueueEventListenee.ts
      import { onMounted, onUnmounted } from 'vue';
      +import { RETRY_MESSAGE_KEY } from '../constants';
      +import { NotificationType, useNotifyStore } from '../stores/notifyStore';
      +import { useQueueStore } from '../stores/queueStore';
      + 
      +export default function useRetryQueueEventListener() {
      +  const queueStore = useQueueStore();
      +  const notifyStore = useNotifyStore();
      + 
      +  const notifyRetryQueueEvent = (event: any) => {
      +    console.log('retry queue event', event);
      +    if (event.data && event.data.type === RETRY_MESSAGE_KEY) {
      +      // Update `isSyncing` status to mark end of SW side of work
      +      queueStore.setRetryComplete();
      + 
      +      // Notify user of results
      +      const successCount = event.data?.success || 0;
      +      if (successCount > 0) {
      +        notifyStore.notify(
      +          `${successCount} container movements in the retry queue have been successfully submitted.`,
      +          NotificationType.Success
      +        );
      +      }
      +      const failureCount = event.data?.failure || 0;
      +      if (failureCount > 0) {
      +        notifyStore.notify(
      +          `${failureCount} container movements in the retry queue have failed to submit. Try again later.`,
      +          NotificationType.Error
      +        );
      +      }
      +    }
      +  };
      + 
      +  onMounted(() => {
      +    navigator.serviceWorker.addEventListener('message', notifyRetryQueueEvent);
      +  });
      + 
      +  onUnmounted(() => {
      +    navigator.serviceWorker.removeEventListener('message', notifyRetryQueueEvent);
      +  });
      +}

      Finally, as mentioned way back in the beginning of this article, the queue store and the event listener on our service worker need to be initalized at the start of the application.

      App.vue
      <script setup lang="ts">
      +import { useQueueStore } from './stores/queueStore';
      +import useRetryQueueEventListener from './composables/useRetryQueueEventListener';
      + 
      +const queueStore = useQueueStore();
      +queueStore.init();
      + 
      +useRetryQueueEventListener();
      +</script>

      Conclusion

      Again, my requirements are quite straightforward and therefore I could take some shortcuts in the design of this outbox. For an outbox for multiple endpoints you may need to store the endpoint with the payload for your queue or some other solution. No matter I hope this can be a good starting point for your PWA’s outbox feature.

      • pwa
      • indexeddb
      Share:
      FYI: This post is a part of the following project.
      \ No newline at end of file diff --git a/blog/scraping-pdfs-with-opencv/index.html b/blog/scraping-pdfs-with-opencv/index.html new file mode 100644 index 0000000..744ef13 --- /dev/null +++ b/blog/scraping-pdfs-with-opencv/index.html @@ -0,0 +1,4 @@ +Scraping PDFs With Computer Vision — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 4 min read

      Scraping PDFs With Computer Vision

      Using OpenCV to scrape figures from PDF files.
+

      I am in the process of building a dashboard to explore the relationship between fuel prices and utility rates in Vanuatu among other things. The data is coming from publicly released reports so far so the first step to build the dashboard is to turn the reports into something machine readable.

      The Problem

      One data point I want to collect is the amount of energy produced via different sources whether it be diesel generators or some form of a renewable energy source. Each month a new report is released and I want to grab the figure from the top of each report but the figures are not positioned or labeled consistently.

      sample of PDF documents

      Let’s ignore I could just grab the information needed to contruct the pie chart myself from the table below the figure for the sake of demonstrating this data scraping technique.

      The Plan

      The figure we want is not in the same absolute position each month nor is it the same size or shape. So immediately we can rule out a simple image crop strategy. If we pretend reconstructing the figure based on the text contained in the PDF or if the PDF were a copy of a scanned document that wouldn’t actually contain any text that could be extracted then that leads us to take a computer vision approach to locate the figure.

      So how do we find the figure with computer vision?

      What we do know is the figure we want is always near the top of the first page, so we can easily limit our search to the top half or so of the first page of each report. This will reduce false-positive results and speed up the time to locate the figure as we are searching less area. The figure also has an uniquely large box shaped border which can be the primary feature our computer vision script will search for. And as a back-up we have the consistent and unique color palette used in the figure to help us find the figure but we don’t actually use this detail for our usecase this time.

      The Script

      First, we will use a Python package called pdf2image to open each PDF file and translate each page to an image which we load into OpenCV.

      Secondly, we crop our image down to our region of interest, the top half of the first page of each report, as this is where we are certain the figure can be found.

      region of interest

      Then, we use the Canny edge detection algorithm to find edges of features of our region of interest.

      canny edge detection

      With all of the edges detected we then find all of the contours therein and we filter down the list of contours to those with approximately four sides since we are searching for the figure’s rectangular border.

      four sided contours found

      Luckily for our use case, we have only one very large four sided contour and it happens to contain the figure we want to extract. So the final step is to select the contour with the greatest internal area and extract that section from our original region of interest resulting in an image of just the figure.

      The Result

      In just seconds all 3 years of reports are extracted.

      We could then follow up with OCR to read the values on the figure. Or if no percentage was written on the figure but if a key were provided we could perform a similar contour analysis by each colored slice of the pie chart to determine its total area of the circle and approximate the percentages. But that’s completely unnecessary for my simple project.

      • python
      • computer-vision
      • pdf
      Share:
      FYI: This post is a part of the following project.
      Vanuatu Energy Dashboard

      Vanuatu Energy Dashboard

      An open source dashboard on Vanuatu's energy sources and prices from information obtained in public records.

      \ No newline at end of file diff --git a/blog/thumbor/index.html b/blog/thumbor/index.html new file mode 100644 index 0000000..d090c08 --- /dev/null +++ b/blog/thumbor/index.html @@ -0,0 +1,81 @@ +Thumbor — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 3 min read

      Thumbor

      Integrating Thumbor image thumbnail service into Bilolok. 
+

      In past projects with user uploaded images I used a task queue to process images after they were uploaded into various resolutions and to include watermarks. This method is simple but if down the road you find the image resolutions are not ideal or the watermark needs to change then you have to write some one-off tasks to redo all of the images (hope you kept the original) and depending on your file serving implemenation and/or filename convention you may need to update your frontend service too. For my Bilolok project I wanted to have on-demand image resizing and watermark capabilities.

      Thumbor is an on-demand image resizing, cropping and filtering service which fits my needs. In fact, I was able to add into Bilolok by including just two services into our docker-compose.yml config and by adding a short function into the backend API.

      The two services added are the nginx-proxy service which acts as an image caching layer infront of the thumbor service. So when an image is not found in the cache then the request goes all the way to Thumbor and only then would image processing take place and not on every single request.

      services:
      +  thumbor:
      +    image: minimalcompact/thumbor
      +    volumes:
      +      - "${DATA_LOCAL_DIR}/:/data/uploads/"
      +    env_file:
      +      - .env
      +    environment:
      +      - VIRTUAL_HOST=image.bilolok.com
      +      - THUMBOR_NUM_PROCESSES=1
      +      - AUTO_WEBP=True
      +      - ALLOW_UNSAFE_URL=False
      +      - SECURITY_KEY=${THUMBOR_SECURITY_KEY}
      +      - LOADER=thumbor.loaders.file_loader
      +      - FILE_LOADER_ROOT_PATH=/data/uploads/
      +      # nginx-proxy does caching automatically, so no need to store the result storage cache
      +      # (this greatly speeds up and saves on CPU)
      +      - RESULT_STORAGE=thumbor.result_storages.no_storage
      +      - RESULT_STORAGE_STORES_UNSAFE=True
      +      - STORAGE=thumbor.storages.file_storage
      +    restart: always
      + 
      +  nginx-proxy:
      +    image: minimalcompact/thumbor-nginx-proxy-cache
      +    environment:
      +      - DEFAULT_HOST=image.bilolok.com
      +      - PROXY_CACHE_SIZE=10g
      +      - PROXY_CACHE_MEMORY_SIZE=500m
      +      - PROXY_CACHE_INACTIVE=168h
      +    volumes:
      +      # this is essential for nginx-proxy to detect docker containers, scaling etc
      +      # see https://github.com/jwilder/nginx-proxy
      +      - /var/run/docker.sock:/tmp/docker.sock:ro
      +      # mapping cache folder, to persist it independently of the container
      +      - bilolok-img-cache-data-prod:/var/cache/nginx
      +    ports:
      +      - "8888:80"
      +    restart: always
      +  
      +volumes:
      +  bilolok-img-cache-data-prod:

      In the API codebase I keep a helper file which has configured libthumbor ready to use Thumbor’s secret key. We use this because otherwise a malicious user could spam our backend for millions of variations of the same image.

      from libthumbor import CryptoURL
      +from app.core.config import settings
      + 
      +img_crypto_url = CryptoURL(key=settings.THUMBOR_SECURITY_KEY)

      And lastly, I use the img_crypto_url instance to generate safe URLs that Thumbor will accept on the client side.

      def make_src_url(self, image: Image, width: int, height: int, **kwargs) -> str:
      +    uri = img_crypto_url.generate(
      +        width=width,
      +        height=height,
      +        smart=True,
      +        image_url=str(
      +            Image.build_filepath(image.nakamal.id, image.file_id, image.filename)
      +        ),
      +        **kwargs,
      +    )
      +    return "{}{}".format(settings.THUMBOR_SERVER, uri)
      + 
      +def make_src_urls(self, image: ImageSchema) -> ImageSchema:
      +    image.src = self.make_src_url(
      +        image,
      +        height=720,
      +        width=1280,
      +        full_fit_in=True,
      +        filters=[
      +            "watermark(/images/watermark.png,20,-20,20,30)",
      +        ],
      +    )
      +    image.msrc = self.make_src_url(image, height=32, width=32, full_fit_in=True)
      +    image.thumbnail = self.make_src_url(image, height=200, width=200)
      +    return image

      So when the user requests an image object from the API they are returned a response like the one below.

      {
      +  "id": "7bebc655-ee33-4f44-bea9-3244c57d1d22",
      +  "created_at": "2021-12-02T06:28:02.669341+00:00",
      +  "src": "https://image.bilolok.com/ls3NgsypowbLm8YQvK79UOINcNk=/full-fit-in/1280x720/smart/nakamals/f9/f9b1994d-bfc7-4d2b-8225-47e9b5b9dd16/0ee90ed94acc56f000579f2d13f3866f.jpg",
      +  "msrc": "https://image.bilolok.com/e7wazqFPJMko1obtAFoCNoDN0rw=/full-fit-in/32x32/smart/nakamals/f9/f9b1994d-bfc7-4d2b-8225-47e9b5b9dd16/0ee90ed94acc56f000579f2d13f3866f.jpg",
      +  "thumbnail": "https://image.bilolok.com/RDkUYpi5ZM8hxwH_6iCWn8yCD8M=/200x200/smart/nakamals/f9/f9b1994d-bfc7-4d2b-8225-47e9b5b9dd16/0ee90ed94acc56f000579f2d13f3866f.jpg",
      +  "user": {
      +    "id": "d509f517-3042-4595-89bc-4f93be36acf7",
      +    "avatar": "https://image.bilolok.com/F5cOi-sYJg5KAdL81_3mmmeRCgY=/100x100/smart/users/d5/d509f517-3042-4595-89bc-4f93be36acf7/default.png"
      +  }
      +}

      Conclusion

      If at a later point I want to offer larger images or change the watermark I only need to update the backend API and new images will be generated.

      • python
      • docker
      Share:
      FYI: This post is a part of the following project.
      Bilolok

      Bilolok

      A Foursqaure inspired app for kava bars in Port Vila, Vanuatu.

      \ No newline at end of file diff --git a/blog/tus-and-uppy/index.html b/blog/tus-and-uppy/index.html new file mode 100644 index 0000000..1f5c64a --- /dev/null +++ b/blog/tus-and-uppy/index.html @@ -0,0 +1,133 @@ +Uppy and Tus — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 3 min read

      Uppy and Tus

      Integrating resumable uploads into Bilolok. 
+

      As part of my Bilolok project’s goal to be friendly to poor network conditions I decided to use the Tus protocol for resumeable uploads that would mean users would not lose their upload progress when the network had a hiccup.

      Tus is an open protocol for resumable file uploads which pairs well with Uppy on the frontend. On the backend, I didn’t need to implement the protocol myself and instead I used the official tusd server by including it in the docker-compose.yml config. To actually integrate the file uploads into Bilolok I added a custom hook in the backend API to handle the uploaded files.

      Frontend

      upload.vue
      <template>
      +  <div>
      +    <DashboardModal
      +      :uppy="uppy"
      +      :open="open"
      +      :plugins="[]"
      +      :props="{theme: 'light'}"
      +    />
      +  </div>
      +</template>
      + 
      +<script>
      +import { mapGetters } from 'vuex';
      +import { DashboardModal } from '@uppy/vue';
      +import { uploadDomain } from '@/env';
      + 
      +import '@uppy/core/dist/style.css';
      +import '@uppy/dashboard/dist/style.css';
      + 
      +import { Uppy } from '@uppy/core';
      +import Tus from '@uppy/tus';
      + 
      +export default {
      +  name: 'NakamalImageUpload',
      +  props: ['nakamal', 'open'],
      +  components: {
      +    DashboardModal,
      +  },
      +  computed: {
      +    ...mapGetters({
      +      user: 'auth/user',
      +      token: 'auth/token',
      +    }),
      +    uppy() {
      +      return new Uppy({
      +        meta: {
      +          NakamalID: this.nakamal.id,
      +        },
      +      })
      +        .use(Tus, {
      +          endpoint: `${uploadDomain}/files/`,
      +          chunkSize: 2_000_000,
      +          headers: {
      +            authorization: `Bearer ${this.token}`,
      +          },
      +        })
      +        .on('complete', () => {
      +          this.$emit('close-modal');
      +        })
      +        .on('upload-error', (_, error) => {
      +          this.$emit('close-modal');
      +          this.$store.dispatch('notify/add', {
      +            title: 'Upload Error',
      +            text: 'Upload failed due to some unknown issues. Try again later.',
      +            type: 'warning',
      +          });
      +        });
      +    },
      +  },
      +  beforeDestroy() {
      +    this.uppy.close();
      +  },
      +};
      +</script>

      Backend

      The tusd instance will handle the incoming uploads and communicate with our API via the -hooks-http value, including forwarding the Authorization header from the client so our API can authenticate the user uploading the file.

      docker-compose.yml
      tusd:
      +  image: tusproject/tusd
      +  ports:
      +    - "8070:8070"
      +  volumes:
      +    - "${IMAGES_LOCAL_DIR}/uploads/:/data/"
      +  env_file:
      +    - .env
      +  command: -port 8070 -upload-dir /data/ -behind-proxy -hooks-http https://example.com/tus-hook -hooks-http-forward-headers authorization -hooks-enabled-events pre-create,post-finish

      Lastly, our backend built with FastAPI handles the incoming webhook requests from the tusd instance to authenticate uploads and when the upload completes it handles the file and adds it to our database.

      Do take note this example assumes the tusd instance is saving the files to the same locally accessible filesystem as the backend API.

      tus.py
      @router.post("/tus-hook", include_in_schema=False)
      +async def tus_hook(
      +    db: AsyncSession = Depends(get_db),
      +    user_manager: UserManager = Depends(get_user_manager),
      +    *,
      +    hook_name: str = Header(...),
      +    tusdIn: Any = Body(...),
      +) -> Any:
      +    """
      +    Hook for tusd.
      +    """
      +    scheme, _, token = tusdIn.get("HTTPRequest").get("Header").get("Authorization")[0].partition(" ")
      +    try:
      +        user_id = get_user_id_from_jwt(token)  # psuedo code
      +        if user_id is None:
      +            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
      +        # Check user is verified and active or superuser
      +        user = await user_manager.get(user_id)
      +        status_code = status.HTTP_401_UNAUTHORIZED
      +        if user:
      +            status_code = status.HTTP_403_FORBIDDEN
      +            if not user.is_active:
      +                status_code = status.HTTP_401_UNAUTHORIZED
      +                user = None
      +            if not user.is_verified or not user.is_superuser:
      +                user = None
      +        if not user:
      +            raise HTTPException(status_code=status_code)
      +    except jwt.PyJWTError:
      +        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
      + 
      +    # Check Nakamal exists
      +    nakamal_id = tusdIn.get("Upload").get("MetaData").get("NakamalID")
      +    nakamal = get_nakamal(nakamal_id)  # psuedo code
      +    if not nakamal:
      +        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nakamal not found.")
      +    if hook_name == "post-finish":
      +        file_id = tusdIn.get("Upload").get("ID")
      +        filename = tusdIn.get("Upload").get("MetaData").get("filename")
      +        filetype = tusdIn.get("Upload").get("MetaData").get("filetype")
      +        try:
      +            tusUpload = Path(settings.IMAGES_LOCAL_DIR) / "uploads" / file_id
      +            assert tusUpload.exists()
      +            save_file(tusUpload, nakamal_id=nakamal_id, file_id=file_id, filename=filename)  # psuedo code
      +            # Remove Tus `.info` file
      +            tusInfo = Path(str(tusUpload) + ".info")
      +            tusInfo.unlink()
      +        except Exception as exc:
      +            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
      +        try:
      +            image = create_image(
      +                file_id=file_id,
      +                filename=filename,
      +                filetype=filetype,
      +                user_id=user_id,
      +                nakamal_id=nakamal_id,
      +            )  # psuedo code
      +        except Exception as exc:
      +            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

      Conclusion

      All-in-all this was a relatively easy setup for the goal of making user uploads resilient to poor network conditions.

      • fastapi
      • vue
      • docker
      Share:
      FYI: This post is a part of the following project.
      Bilolok

      Bilolok

      A Foursqaure inspired app for kava bars in Port Vila, Vanuatu.

      \ No newline at end of file diff --git a/blurbs/2/index.html b/blurbs/2/index.html new file mode 100644 index 0000000..896376f --- /dev/null +++ b/blurbs/2/index.html @@ -0,0 +1,4 @@ +Blurbs — Page 2 — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Blurbs

      A collection of snippets and helpful references.

      • · til

        Importing System Site Packages

        You can use your system’s Python packages such as those built by your linux distrobution and installed like sudo apt install python3-pymssql inside of a virtual environment by adding the --system-site-packages flag when creating the virtual environment.

        sudo apt install python3-pymssql
        +python3 -m venv --system-site-packages .venv
        +source .venv/bin/activate
        +python -c "import pymssql"

        This saved me after spending far more time than I like trying to install pymssql on an old RaspberryPi which even the team at PiWheels.org don’t provide a wheel for.


      \ No newline at end of file diff --git a/blurbs/awesome-vue3-starter/index.html b/blurbs/awesome-vue3-starter/index.html new file mode 100644 index 0000000..834b243 --- /dev/null +++ b/blurbs/awesome-vue3-starter/index.html @@ -0,0 +1 @@ +Vue Ultimate Starter — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · fyi

      Vue Ultimate Starter

      This project template, Vite Vue Ultimate Starter has been awesome. I’ve used it for one project and I am beginning a new project with it now.

      Given the JS ecosystem, I am aware this post is likely to be outdated before I publish this .

      Share:
      \ No newline at end of file diff --git a/blurbs/borgmatic-docker/index.html b/blurbs/borgmatic-docker/index.html new file mode 100644 index 0000000..72cb9db --- /dev/null +++ b/blurbs/borgmatic-docker/index.html @@ -0,0 +1 @@ +Borgmatic Backups with Docker — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · fyi

      Borgmatic Backups with Docker

      I am currently implementing BorgBackup for my NAS to meet the recommended 3-2-1 backup strategy. BorgBackup on its own is just a CLI, and on top of it you can use Borgmatic for easier configuration via yaml files rather than manually scripting all the command line flags. But for my NAS I need to deploy Borgmatic with Docker so I’ll be using this borgmatic-docker repo maintained by the same borgmatic team.

      Being 3 layers of abstraction deep while trying to learn a new tool made for a few hiccups and what I found online required a combination of sources to piece all the parts together. While I was writing my own notes to document my setup I found this tutorial. It covers everything I needed and with more detail than my own notes so I recommend it for your own borgmatic setup.

      I’m using BorgBase for my backups, BTW.

      Share:
      \ No newline at end of file diff --git a/blurbs/category/fyi/index.html b/blurbs/category/fyi/index.html new file mode 100644 index 0000000..30f49d4 --- /dev/null +++ b/blurbs/category/fyi/index.html @@ -0,0 +1 @@ +Category 'fyi' for Blurbs — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      fyi

      Short blurbs that share things I found interesting or useful.
      • · fyi

        Borgmatic Backups with Docker

        I am currently implementing BorgBackup for my NAS to meet the recommended 3-2-1 backup strategy. BorgBackup on its own is just a CLI, and on top of it you can use Borgmatic for easier configuration via yaml files rather than manually scripting all the command line flags. But for my NAS I need to deploy Borgmatic with Docker so I’ll be using this borgmatic-docker repo maintained by the same borgmatic team.

        Being 3 layers of abstraction deep while trying to learn a new tool made for a few hiccups and what I found online required a combination of sources to piece all the parts together. While I was writing my own notes to document my setup I found this tutorial. It covers everything I needed and with more detail than my own notes so I recommend it for your own borgmatic setup.

        I’m using BorgBase for my backups, BTW.


      • · fyi

        WSL Clipboard for Neovim

        At home I’ve been using Neovim on WSL2 but out of the box I could not copy and paste from Windows to Neovim or vice versa. There are a few solutions but I found wsl-clipboard to be incredibly simple and apparently unknown due to lack of stars on the repo.


      • · fyi

        The Longest Shooting Star

        I enjoy sitting outside at night; often times sit on my computer for personal projects. Being in Vanuatu I can enjoy this perk all year long and it also means I’m much more aware of the sun, moon and other things in the sky I wouldn’t pay much mind to if I spent more nights indoors.

        Just now I saw the longest shooting star I’ve ever witnessed, and I’ve seen many. From my current vantage, it appeared dead west a tad north of where the sun sets about 15 - 25 degree above the horizon and traveled north across the sky while never really appearing to lose much altitude. I could not say why but it flew towards the northern horizon where it fell out my view 20 - 30 seconds after I spotted it. I comptiplated taking a video but never knew if I would have the time to record it so I didn’t. Anyways, it flashed a couple times and grew in light intensity too a couple times but did not burn out which seems unusual also given its duration in the sky.

        I’m curious if there is a website that collects known space debris and meteorites so I can read about what I saw.


      • · fyi

        Vue Ultimate Starter

        This project template, Vite Vue Ultimate Starter has been awesome. I’ve used it for one project and I am beginning a new project with it now.

        Given the JS ecosystem, I am aware this post is likely to be outdated before I publish this .


      • · fyi

        The Sound Preceding An Earthquake

        Living on the ring of fire I’ve had my share of earthquakes and we believe generally we think of them as coming without warning. But, I begun noticing earthquakes of 5-point-something or more often have a deep, wide rumble announcing their approach just a couple seconds ahead of the quake. I perceive the rumble to come rushing towards me (if that makes sense), then at its crescendo the quake begins. The otherwordly sound fills the sky as if coming from everywhere at once but it feels as if it is approaching too. A true reminder of how small you are compared to the earth below your feet.

        Recently, I’ve been quick enough to recognize this sound as a warning, “an earthquake is coming”, I would say. It felt like a super power the first time I predicted an earthquake and then I decided to lookup online the science behind the sound I was hearing. Surprisingly, there was not much about it online and I’m not sure why, I know what I hear and I know its real after hearing it several times. I even had this conversation with a friend and she too confirmed the same sound in her experience. Perhaps there’s something unique to living on a small island and the interaction between the ocean and the earthquake that produces the rumble, making for a rare effect?

        It’s also helpful to know that sometimes dogs, cats and other animals go wild before an earthquake and this phenomenon is much more documented and usually gives you more warning.


      • · fyi

        Quickref for Neovim

        I tried to setup a proper IDE experience with vim several years ago; however, I was also trying to dive into i3wm and use tmux at the same time and was too far caught up into configuration instead of working so I had to stop. Plus, running all of this in a VM inside my Windows laptop was clunky. I returned to using Sublime Text and later migrated to VSCode with WSL.

        VSCode has been great but now I’m running into problems with VSCode on WSL2 hogging memory and wish I could return to a setup that doesn’t require a mouse. As a result I decided to take a look at vim again and discovered neovim. I have forgotten many vim shortcuts so the Neovim quickref is very useful.





      \ No newline at end of file diff --git a/blurbs/category/til/index.html b/blurbs/category/til/index.html new file mode 100644 index 0000000..2ecc6eb --- /dev/null +++ b/blurbs/category/til/index.html @@ -0,0 +1,4 @@ +Category 'til' for Blurbs — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      til

      Short blurbs about something I learned but it's not interesting or long enough to be a dedicated post.

      • · til

        Force Quit Python PDB

        With Pythons pdb usually q for quit is enough to close the debugger and stop the program. When that’s not working you can follow it with a quick ctrl+c to exit the program. But I got stuck in a loop with a poorly placed breakpoint and I didn’t want to close my terminal or use another terminal to kill it so I learned you can call os._exit() to force the Python program to quit skipping all finally blocks and __exit__ methods.

        So for this situation import os; os._exit() force quits pdb and your program.



      • · til

        Using Syncthing for One-Way Transfers

        I’m looking into a backup solution for my family’s photos/phones. I’m familiar with Syncthing so I installed it our NAS server. The goal is our phones will transparently send new files to the server and allow us to delete files to make space on the phones.

        Surprisingly, this one-way data transfer is not recommended by Syncthing but it can be done by configuring the phone to send only mode, the server to receive only mode and finding the hidden advanced option on the server to ignore deletes.

        The name is Syncthing so I suppose I am trying to use it wrong.

        Read more details on their forums here.

        EDIT: I am questioning this workflow and suspect it is more complicated than just a normal two-way sync plus periodically pruning and copying files to the directory I setup in the NAS for backups. I would like to learn more about setups that work for others.


      • · til

        Importing System Site Packages

        You can use your system’s Python packages such as those built by your linux distrobution and installed like sudo apt install python3-pymssql inside of a virtual environment by adding the --system-site-packages flag when creating the virtual environment.

        sudo apt install python3-pymssql
        +python3 -m venv --system-site-packages .venv
        +source .venv/bin/activate
        +python -c "import pymssql"

        This saved me after spending far more time than I like trying to install pymssql on an old RaspberryPi which even the team at PiWheels.org don’t provide a wheel for.


      \ No newline at end of file diff --git a/blurbs/comprehensive-python-cheetsheet/index.html b/blurbs/comprehensive-python-cheetsheet/index.html new file mode 100644 index 0000000..9b9c4e8 --- /dev/null +++ b/blurbs/comprehensive-python-cheetsheet/index.html @@ -0,0 +1 @@ +Comprehensive Python Cheatsheet — Michael Toohig
      Just Arrived in the USA! Check my resume here.
      \ No newline at end of file diff --git a/blurbs/draft-magical-wifi/index.html b/blurbs/draft-magical-wifi/index.html new file mode 100644 index 0000000..939695e --- /dev/null +++ b/blurbs/draft-magical-wifi/index.html @@ -0,0 +1 @@ +WiFi Only Works in the Rain... I know why — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · til

      WiFi Only Works in the Rain... I know why

      I saw this post The WiFi Only Works When Its Raining on Hackernews. I think I can say I’ve learned a thing or two working at a WISP for a few years because I knew what the problem was by the title alone.

      Can you guess what it is?

      Share:
      \ No newline at end of file diff --git a/blurbs/force-quit-python-dpb/index.html b/blurbs/force-quit-python-dpb/index.html new file mode 100644 index 0000000..0e84340 --- /dev/null +++ b/blurbs/force-quit-python-dpb/index.html @@ -0,0 +1 @@ +Force Quit Python PDB — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · til

      Force Quit Python PDB

      With Pythons pdb usually q for quit is enough to close the debugger and stop the program. When that’s not working you can follow it with a quick ctrl+c to exit the program. But I got stuck in a loop with a poorly placed breakpoint and I didn’t want to close my terminal or use another terminal to kill it so I learned you can call os._exit() to force the Python program to quit skipping all finally blocks and __exit__ methods.

      So for this situation import os; os._exit() force quits pdb and your program.

      Share:
      \ No newline at end of file diff --git a/blurbs/github-markdown-emoji-list/index.html b/blurbs/github-markdown-emoji-list/index.html new file mode 100644 index 0000000..a965d20 --- /dev/null +++ b/blurbs/github-markdown-emoji-list/index.html @@ -0,0 +1 @@ +Github Emoji Cheatsheet — Michael Toohig
      Just Arrived in the USA! Check my resume here.
      \ No newline at end of file diff --git a/blurbs/importing-system-site-packages/index.html b/blurbs/importing-system-site-packages/index.html new file mode 100644 index 0000000..c2201b3 --- /dev/null +++ b/blurbs/importing-system-site-packages/index.html @@ -0,0 +1,4 @@ +Importing System Site Packages — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · til

      Importing System Site Packages

      You can use your system’s Python packages such as those built by your linux distrobution and installed like sudo apt install python3-pymssql inside of a virtual environment by adding the --system-site-packages flag when creating the virtual environment.

      sudo apt install python3-pymssql
      +python3 -m venv --system-site-packages .venv
      +source .venv/bin/activate
      +python -c "import pymssql"

      This saved me after spending far more time than I like trying to install pymssql on an old RaspberryPi which even the team at PiWheels.org don’t provide a wheel for.

      Share:
      \ No newline at end of file diff --git a/blurbs/index.html b/blurbs/index.html new file mode 100644 index 0000000..2793b7c --- /dev/null +++ b/blurbs/index.html @@ -0,0 +1 @@ +Blurbs — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Blurbs

      A collection of snippets and helpful references.
      • · fyi

        Borgmatic Backups with Docker

        I am currently implementing BorgBackup for my NAS to meet the recommended 3-2-1 backup strategy. BorgBackup on its own is just a CLI, and on top of it you can use Borgmatic for easier configuration via yaml files rather than manually scripting all the command line flags. But for my NAS I need to deploy Borgmatic with Docker so I’ll be using this borgmatic-docker repo maintained by the same borgmatic team.

        Being 3 layers of abstraction deep while trying to learn a new tool made for a few hiccups and what I found online required a combination of sources to piece all the parts together. While I was writing my own notes to document my setup I found this tutorial. It covers everything I needed and with more detail than my own notes so I recommend it for your own borgmatic setup.

        I’m using BorgBase for my backups, BTW.



      • · fyi

        WSL Clipboard for Neovim

        At home I’ve been using Neovim on WSL2 but out of the box I could not copy and paste from Windows to Neovim or vice versa. There are a few solutions but I found wsl-clipboard to be incredibly simple and apparently unknown due to lack of stars on the repo.


      • · til

        Force Quit Python PDB

        With Pythons pdb usually q for quit is enough to close the debugger and stop the program. When that’s not working you can follow it with a quick ctrl+c to exit the program. But I got stuck in a loop with a poorly placed breakpoint and I didn’t want to close my terminal or use another terminal to kill it so I learned you can call os._exit() to force the Python program to quit skipping all finally blocks and __exit__ methods.

        So for this situation import os; os._exit() force quits pdb and your program.


      • · fyi

        The Longest Shooting Star

        I enjoy sitting outside at night; often times sit on my computer for personal projects. Being in Vanuatu I can enjoy this perk all year long and it also means I’m much more aware of the sun, moon and other things in the sky I wouldn’t pay much mind to if I spent more nights indoors.

        Just now I saw the longest shooting star I’ve ever witnessed, and I’ve seen many. From my current vantage, it appeared dead west a tad north of where the sun sets about 15 - 25 degree above the horizon and traveled north across the sky while never really appearing to lose much altitude. I could not say why but it flew towards the northern horizon where it fell out my view 20 - 30 seconds after I spotted it. I comptiplated taking a video but never knew if I would have the time to record it so I didn’t. Anyways, it flashed a couple times and grew in light intensity too a couple times but did not burn out which seems unusual also given its duration in the sky.

        I’m curious if there is a website that collects known space debris and meteorites so I can read about what I saw.


      • · fyi

        Vue Ultimate Starter

        This project template, Vite Vue Ultimate Starter has been awesome. I’ve used it for one project and I am beginning a new project with it now.

        Given the JS ecosystem, I am aware this post is likely to be outdated before I publish this .


      • · fyi

        The Sound Preceding An Earthquake

        Living on the ring of fire I’ve had my share of earthquakes and we believe generally we think of them as coming without warning. But, I begun noticing earthquakes of 5-point-something or more often have a deep, wide rumble announcing their approach just a couple seconds ahead of the quake. I perceive the rumble to come rushing towards me (if that makes sense), then at its crescendo the quake begins. The otherwordly sound fills the sky as if coming from everywhere at once but it feels as if it is approaching too. A true reminder of how small you are compared to the earth below your feet.

        Recently, I’ve been quick enough to recognize this sound as a warning, “an earthquake is coming”, I would say. It felt like a super power the first time I predicted an earthquake and then I decided to lookup online the science behind the sound I was hearing. Surprisingly, there was not much about it online and I’m not sure why, I know what I hear and I know its real after hearing it several times. I even had this conversation with a friend and she too confirmed the same sound in her experience. Perhaps there’s something unique to living on a small island and the interaction between the ocean and the earthquake that produces the rumble, making for a rare effect?

        It’s also helpful to know that sometimes dogs, cats and other animals go wild before an earthquake and this phenomenon is much more documented and usually gives you more warning.


      • · fyi

        Quickref for Neovim

        I tried to setup a proper IDE experience with vim several years ago; however, I was also trying to dive into i3wm and use tmux at the same time and was too far caught up into configuration instead of working so I had to stop. Plus, running all of this in a VM inside my Windows laptop was clunky. I returned to using Sublime Text and later migrated to VSCode with WSL.

        VSCode has been great but now I’m running into problems with VSCode on WSL2 hogging memory and wish I could return to a setup that doesn’t require a mouse. As a result I decided to take a look at vim again and discovered neovim. I have forgotten many vim shortcuts so the Neovim quickref is very useful.





      • · til

        Using Syncthing for One-Way Transfers

        I’m looking into a backup solution for my family’s photos/phones. I’m familiar with Syncthing so I installed it our NAS server. The goal is our phones will transparently send new files to the server and allow us to delete files to make space on the phones.

        Surprisingly, this one-way data transfer is not recommended by Syncthing but it can be done by configuring the phone to send only mode, the server to receive only mode and finding the hidden advanced option on the server to ignore deletes.

        The name is Syncthing so I suppose I am trying to use it wrong.

        Read more details on their forums here.

        EDIT: I am questioning this workflow and suspect it is more complicated than just a normal two-way sync plus periodically pruning and copying files to the directory I setup in the NAS for backups. I would like to learn more about setups that work for others.


      \ No newline at end of file diff --git a/blurbs/neovim-copy-paste-on-wsl/index.html b/blurbs/neovim-copy-paste-on-wsl/index.html new file mode 100644 index 0000000..cea3248 --- /dev/null +++ b/blurbs/neovim-copy-paste-on-wsl/index.html @@ -0,0 +1 @@ +WSL Clipboard for Neovim — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · fyi

      WSL Clipboard for Neovim

      At home I’ve been using Neovim on WSL2 but out of the box I could not copy and paste from Windows to Neovim or vice versa. There are a few solutions but I found wsl-clipboard to be incredibly simple and apparently unknown due to lack of stars on the repo.

      Share:
      \ No newline at end of file diff --git a/blurbs/one-way-backups-with-syncthing/index.html b/blurbs/one-way-backups-with-syncthing/index.html new file mode 100644 index 0000000..25825a9 --- /dev/null +++ b/blurbs/one-way-backups-with-syncthing/index.html @@ -0,0 +1 @@ +Using Syncthing for One-Way Transfers — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · til

      Using Syncthing for One-Way Transfers

      I’m looking into a backup solution for my family’s photos/phones. I’m familiar with Syncthing so I installed it our NAS server. The goal is our phones will transparently send new files to the server and allow us to delete files to make space on the phones.

      Surprisingly, this one-way data transfer is not recommended by Syncthing but it can be done by configuring the phone to send only mode, the server to receive only mode and finding the hidden advanced option on the server to ignore deletes.

      The name is Syncthing so I suppose I am trying to use it wrong.

      Read more details on their forums here.

      EDIT: I am questioning this workflow and suspect it is more complicated than just a normal two-way sync plus periodically pruning and copying files to the directory I setup in the NAS for backups. I would like to learn more about setups that work for others.

      Share:
      \ No newline at end of file diff --git a/blurbs/patterns-for-personal-websites/index.html b/blurbs/patterns-for-personal-websites/index.html new file mode 100644 index 0000000..e938ab3 --- /dev/null +++ b/blurbs/patterns-for-personal-websites/index.html @@ -0,0 +1 @@ +Patterns for Personal Websites — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · fyi

      Patterns for Personal Websites

      I saw this link, Patterns for Personal Websites (2003), thinking it may outdated and interesting or I may actually learn something. The article on Cover Pages reminds me when they used to be popular and I bet they will make a come back again. But, the idea of a Secret Garden has gotten my attention.

      Also, here is a backup link.

      Share:
      \ No newline at end of file diff --git a/blurbs/quickref-for-neovim/index.html b/blurbs/quickref-for-neovim/index.html new file mode 100644 index 0000000..ec94c13 --- /dev/null +++ b/blurbs/quickref-for-neovim/index.html @@ -0,0 +1 @@ +Quickref for Neovim — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · fyi

      Quickref for Neovim

      I tried to setup a proper IDE experience with vim several years ago; however, I was also trying to dive into i3wm and use tmux at the same time and was too far caught up into configuration instead of working so I had to stop. Plus, running all of this in a VM inside my Windows laptop was clunky. I returned to using Sublime Text and later migrated to VSCode with WSL.

      VSCode has been great but now I’m running into problems with VSCode on WSL2 hogging memory and wish I could return to a setup that doesn’t require a mouse. As a result I decided to take a look at vim again and discovered neovim. I have forgotten many vim shortcuts so the Neovim quickref is very useful.

      Share:
      \ No newline at end of file diff --git a/blurbs/relative-datetimes-with-directus-now/index.html b/blurbs/relative-datetimes-with-directus-now/index.html new file mode 100644 index 0000000..a9a5db8 --- /dev/null +++ b/blurbs/relative-datetimes-with-directus-now/index.html @@ -0,0 +1 @@ +Relative Datetimes with Directus $NOW — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · til

      Relative Datetimes with Directus $NOW

      In Directus you can make relative datetimes from the provided $NOW timestamp such as $NOW(-7 days) or $NOW(+3 hours).

      Read more in the Directus Docs.

      Share:
      \ No newline at end of file diff --git a/blurbs/shooting-star-2024-02-28/index.html b/blurbs/shooting-star-2024-02-28/index.html new file mode 100644 index 0000000..b64895f --- /dev/null +++ b/blurbs/shooting-star-2024-02-28/index.html @@ -0,0 +1 @@ +The Longest Shooting Star — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · fyi

      The Longest Shooting Star

      I enjoy sitting outside at night; often times sit on my computer for personal projects. Being in Vanuatu I can enjoy this perk all year long and it also means I’m much more aware of the sun, moon and other things in the sky I wouldn’t pay much mind to if I spent more nights indoors.

      Just now I saw the longest shooting star I’ve ever witnessed, and I’ve seen many. From my current vantage, it appeared dead west a tad north of where the sun sets about 15 - 25 degree above the horizon and traveled north across the sky while never really appearing to lose much altitude. I could not say why but it flew towards the northern horizon where it fell out my view 20 - 30 seconds after I spotted it. I comptiplated taking a video but never knew if I would have the time to record it so I didn’t. Anyways, it flashed a couple times and grew in light intensity too a couple times but did not burn out which seems unusual also given its duration in the sky.

      I’m curious if there is a website that collects known space debris and meteorites so I can read about what I saw.

      Share:
      \ No newline at end of file diff --git a/blurbs/the-sound-preceding-earthquakes/index.html b/blurbs/the-sound-preceding-earthquakes/index.html new file mode 100644 index 0000000..43e8ea8 --- /dev/null +++ b/blurbs/the-sound-preceding-earthquakes/index.html @@ -0,0 +1 @@ +The Sound Preceding An Earthquake — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · fyi

      The Sound Preceding An Earthquake

      Living on the ring of fire I’ve had my share of earthquakes and we believe generally we think of them as coming without warning. But, I begun noticing earthquakes of 5-point-something or more often have a deep, wide rumble announcing their approach just a couple seconds ahead of the quake. I perceive the rumble to come rushing towards me (if that makes sense), then at its crescendo the quake begins. The otherwordly sound fills the sky as if coming from everywhere at once but it feels as if it is approaching too. A true reminder of how small you are compared to the earth below your feet.

      Recently, I’ve been quick enough to recognize this sound as a warning, “an earthquake is coming”, I would say. It felt like a super power the first time I predicted an earthquake and then I decided to lookup online the science behind the sound I was hearing. Surprisingly, there was not much about it online and I’m not sure why, I know what I hear and I know its real after hearing it several times. I even had this conversation with a friend and she too confirmed the same sound in her experience. Perhaps there’s something unique to living on a small island and the interaction between the ocean and the earthquake that produces the rumble, making for a rare effect?

      It’s also helpful to know that sometimes dogs, cats and other animals go wild before an earthquake and this phenomenon is much more documented and usually gives you more warning.

      Share:
      \ No newline at end of file diff --git a/d2/post/pwa-outbox-0.svg b/d2/post/pwa-outbox-0.svg new file mode 100644 index 0000000..5340725 --- /dev/null +++ b/d2/post/pwa-outbox-0.svg @@ -0,0 +1 @@ +FrontendQueueService WorkerBackendMakes RequestForwards Request to BackendRequest FailedStores Failed RequestNotify Request Added to QueueReads Queue for UI \ No newline at end of file diff --git a/d2/project/remote-video-monitoring-0.svg b/d2/project/remote-video-monitoring-0.svg new file mode 100644 index 0000000..e6cb04b --- /dev/null +++ b/d2/project/remote-video-monitoring-0.svg @@ -0,0 +1 @@ +NOCSatellitesCustomers1Customers2Customers3Decoder 1Decoder 2Decoder 3transcodersCASMuxAntenna \ No newline at end of file diff --git a/d2/project/remote-video-monitoring-1.svg b/d2/project/remote-video-monitoring-1.svg new file mode 100644 index 0000000..5f68c62 --- /dev/null +++ b/d2/project/remote-video-monitoring-1.svg @@ -0,0 +1 @@ +NetworkRaspberryPiDecoderHDMI-to-IP ConverterAntennaFetch ScreenshotsChange ChannelOn/Off Control \ No newline at end of file diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..578ad45 Binary files /dev/null and b/favicon.ico differ diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..9857f08 --- /dev/null +++ b/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..7177586 --- /dev/null +++ b/index.html @@ -0,0 +1 @@ +Michael Toohig — Personal Blog and Portfolio
      Just Arrived in the USA! Check my resume here.
      Profile picture

      Hi, I am
       Michael Toohig

      Port Vila Bay

      Featured Projects

      View all projects »

      Some of my professional and hobby projects are here.

      1NOMO

      1NOMO

      An automated linear TV channel broadcast FTA in Port Vila, Vanuatu.

      Featured Posts

      View all posts »

      I try to write technical posts on things I've learned, but I go off-topic sometimes.

      \ No newline at end of file diff --git a/landing/mobile-app/index.html b/landing/mobile-app/index.html new file mode 100644 index 0000000..fd4e68d --- /dev/null +++ b/landing/mobile-app/index.html @@ -0,0 +1 @@ +Mobile App Landing Page — Michael Toohig

      Free template for Astro 2.0 + Tailwind CSS

      AstroWind: Production-ready. Suitable for Startups, Small Business, Sass Websites, Professional Portfolios, Marketing Websites, Landing Pages & Blogs.

      AstroWind Hero Image

      Astro +
      Tailwind CSS

      Be very surprised by these huge fake numbers you are seeing on this page.Don't waste more time! :P

      \ No newline at end of file diff --git a/landing/saas/index.html b/landing/saas/index.html new file mode 100644 index 0000000..632ba62 --- /dev/null +++ b/landing/saas/index.html @@ -0,0 +1 @@ +Saas Landing Page — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Free template for
      Astro 2.0 + Tailwind CSS

      AstroWind: Production-ready. Suitable for Startups, Small Business, Sass Websites, Professional Portfolios, Marketing Websites, Landing Pages & Blogs.

      AstroWind Hero Image

      Ad vix debet docendi

      Ne dicta praesent ocurreret has, diam theophrastus at pro. Eos etiam regione ut, persius eripuit quo id. Sit te euismod tacimates.

      Per ei quaeque sensibus

      Ex usu illum iudico molestie. Pro ne agam facete mediocritatem, ridens labore facete mea ei. Pro id apeirian dignissim.

      Cu imperdiet posidonium sed

      Amet utinam aliquando ut mea, malis admodum ocurreret nec et, elit tibique cu nec. Nec ex maluisset inciderint, ex quis.

      Nulla omittam sadipscing mel ne

      At sed possim oporteat probatus, justo graece ne nec, minim commodo legimus ut vix. Ut eos iudico quando soleat, nam modus.

      Ad vix debet docendi

      Ne dicta praesent ocurreret has, diam theophrastus at pro. Eos etiam regione ut, persius eripuit quo id. Sit te euismod tacimates.

      Per ei quaeque sensibus

      Ex usu illum iudico molestie. Pro ne agam facete mediocritatem, ridens labore facete mea ei. Pro id apeirian dignissim.

      Cu imperdiet posidonium sed

      Amet utinam aliquando ut mea, malis admodum ocurreret nec et, elit tibique cu nec. Nec ex maluisset inciderint, ex quis.

      Nulla omittam sadipscing mel ne

      At sed possim oporteat probatus, justo graece ne nec, minim commodo legimus ut vix. Ut eos iudico quando soleat, nam modus.

      Ad vix debet docendi

      Ne dicta praesent ocurreret has, diam theophrastus at pro. Eos etiam regione ut, persius eripuit quo id. Sit te euismod tacimates.

      Per ei quaeque sensibus

      Ex usu illum iudico molestie. Pro ne agam facete mediocritatem, ridens labore facete mea ei. Pro id apeirian dignissim.

      Cu imperdiet posidonium sed

      Amet utinam aliquando ut mea, malis admodum ocurreret nec et, elit tibique cu nec. Nec ex maluisset inciderint, ex quis.

      Nulla omittam sadipscing mel ne

      At sed possim oporteat probatus, justo graece ne nec, minim commodo legimus ut vix. Ut eos iudico quando soleat, nam modus.

      Sed ac magna sit amet risus tristique interdum, at vel velit in hac habitasse platea dictumst.

      Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi sagittis, quam nec venenatis lobortis, mi risus tempus nulla, sed porttitor est nibh at nulla. Praesent placerat enim ut ex tincidunt vehicula. Fusce sit amet dui tellus.

      • 1

        Responsive Elements

        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi sagittis, quam nec venenatis lobortis, mi risus tempus nulla.

      • 2

        Flexible Team

        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi sagittis, quam nec venenatis lobortis, mi risus tempus nulla.

      • 3

        Ecologic Software

        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi sagittis, quam nec venenatis lobortis, mi risus tempus nulla.

      Astro +
      Tailwind CSS

      Be very surprised by these huge fake numbers you are seeing on this page.Don't waste more time! :P

      \ No newline at end of file diff --git a/landing/startup/index.html b/landing/startup/index.html new file mode 100644 index 0000000..63b4bf3 --- /dev/null +++ b/landing/startup/index.html @@ -0,0 +1 @@ +Startup Landing Page — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Free template for Astro 2.0 + Tailwind CSS

      AstroWind: Production-ready. Suitable for Startups, Small Business, Sass Websites, Professional Portfolios, Marketing Websites, Landing Pages & Blogs.

      Astro +
      Tailwind CSS

      Be very surprised by these huge fake numbers you are seeing on this page.Don't waste more time! :P

      \ No newline at end of file diff --git a/links/index.html b/links/index.html new file mode 100644 index 0000000..84282be --- /dev/null +++ b/links/index.html @@ -0,0 +1 @@ +Links — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Links

      Last updated: March 29, 2024

      A collection of links to other blogs or interesting websites.

      Tech Blogs

      AI & Chatbots

      • Phind is an intelligent search engine and assistant for programmers.
      • Perplexity is an alternative to traditional search engines.

      Interesting Visual Web Sites

      Teaching Children Programming (without them knowing)

      Sundials

      Random

      \ No newline at end of file diff --git a/michael-toohig-resume.pdf b/michael-toohig-resume.pdf new file mode 100644 index 0000000..daff935 Binary files /dev/null and b/michael-toohig-resume.pdf differ diff --git a/privacy/index.html b/privacy/index.html new file mode 100644 index 0000000..2c2e5f3 --- /dev/null +++ b/privacy/index.html @@ -0,0 +1 @@ +Privacy Policy — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Privacy Policy

      Last updated: January 06, 2023

      This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.

      We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy. This Privacy Policy is just a Demo.

      Interpretation and Definitions

      Interpretation

      The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.

      Definitions

      For the purposes of this Privacy Policy:

      • Account means a unique account created for You to access our Service or parts of our Service.
      • Company (referred to as either “the Company”, “We”, “Us” or “Our” in this Agreement) refers to AstroWind LLC, 1 Cupertino, CA 95014.
      • Cookies are small files that are placed on Your computer, mobile device or any other device by a website, containing the details of Your browsing history on that website among its many uses.
      • Country refers to: California, United States
      • Device means any device that can access the Service such as a computer, a cellphone or a digital tablet.
      • Personal Data is any information that relates to an identified or identifiable individual.
      • Service refers to the Website.
      • Service Provider means any natural or legal person who processes the data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, to perform services related to the Service or to assist the Company in analyzing how the Service is used.
      • Usage Data refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).
      • Website refers to AstroWind, accessible from https://astrowind.vercel.app
      • You means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.

      Collecting and Using Your Personal Data

      Types of Data Collected

      Personal Data

      While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:

      • Usage Data

      Usage Data

      Usage Data is collected automatically when using the Service.

      Usage Data may include information such as Your Device’s Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.

      When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.

      We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.

      Tracking Technologies and Cookies

      We use Cookies and similar tracking technologies to track the activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:

      • Cookies or Browser Cookies. A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service. Unless you have adjusted Your browser setting so that it will refuse Cookies, our Service may use Cookies.
      • Web Beacons. Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity).

      Cookies can be “Persistent” or “Session” Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser.

      We use both Session and Persistent Cookies for the purposes set out below:

      • Necessary / Essential Cookies

        Type: Session Cookies

        Administered by: Us

        Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.

      • Cookies Policy / Notice Acceptance Cookies

        Type: Persistent Cookies

        Administered by: Us

        Purpose: These Cookies identify if users have accepted the use of cookies on the Website.

      • Functionality Cookies

        Type: Persistent Cookies

        Administered by: Us

        Purpose: These Cookies allow us to remember choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these Cookies is to provide You with a more personal experience and to avoid You having to re-enter your preferences every time You use the Website.

      For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy.

      Use of Your Personal Data

      The Company may use Personal Data for the following purposes:

      • To provide and maintain our Service, including to monitor the usage of our Service.
      • To manage Your Account: to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.
      • For the performance of a contract: the development, compliance and undertaking of the purchase contract for the products, items or services You have purchased or of any other contract with Us through the Service.
      • To contact You: To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication, such as a mobile application’s push notifications regarding updates or informative communications related to the functionalities, products or contracted services, including the security updates, when necessary or reasonable for their implementation.
      • To provide You with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless You have opted not to receive such information.
      • To manage Your requests: To attend and manage Your requests to Us.
      • For business transfers: We may use Your information to evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar proceeding, in which Personal Data held by Us about our Service users is among the assets transferred.
      • For other purposes: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Service, products, services, marketing and your experience.

      We may share Your personal information in the following situations:

      • With Service Providers: We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You.
      • For business transfers: We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company.
      • With Affiliates: We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and any other subsidiaries, joint venture partners or other companies that We control or that are under common control with Us.
      • With business partners: We may share Your information with Our business partners to offer You certain products, services or promotions.
      • With other users: when You share personal information or otherwise interact in the public areas with other users, such information may be viewed by all users and may be publicly distributed outside.
      • With Your consent: We may disclose Your personal information for any other purpose with Your consent.

      Retention of Your Personal Data

      The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.

      The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.

      Transfer of Your Personal Data

      Your information, including Personal Data, is processed at the Company’s operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.

      Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.

      The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.

      Delete Your Personal Data

      You have the right to delete or request that We assist in deleting the Personal Data that We have collected about You.

      Our Service may give You the ability to delete certain information about You from within the Service.

      You may update, amend, or delete Your information at any time by signing in to Your Account, if you have one, and visiting the account settings section that allows you to manage Your personal information. You may also contact Us to request access to, correct, or delete any personal information that You have provided to Us.

      Please note, however, that We may need to retain certain information when we have a legal obligation or lawful basis to do so.

      Disclosure of Your Personal Data

      Business Transactions

      If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.

      Law enforcement

      Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).

      The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:

      • Comply with a legal obligation
      • Protect and defend the rights or property of the Company
      • Prevent or investigate possible wrongdoing in connection with the Service
      • Protect the personal safety of Users of the Service or the public
      • Protect against legal liability

      Security of Your Personal Data

      The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.

      Children’s Privacy

      Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.

      If We need to rely on consent as a legal basis for processing Your information and Your country requires consent from a parent, We may require Your parent’s consent before We collect and use that information.

      Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party’s site. We strongly advise You to review the Privacy Policy of every site You visit.

      We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.

      Changes to this Privacy Policy

      We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.

      We will let You know via email and/or a prominent notice on Our Service, prior to the change becoming effective and update the “Last updated” date at the top of this Privacy Policy.

      You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.

      Contact Us

      If you have any questions about this Privacy Policy, You can contact us:

      \ No newline at end of file diff --git a/profile.jpg b/profile.jpg new file mode 100755 index 0000000..b46bf41 Binary files /dev/null and b/profile.jpg differ diff --git a/project-ideas/index.html b/project-ideas/index.html new file mode 100644 index 0000000..bfc73bb --- /dev/null +++ b/project-ideas/index.html @@ -0,0 +1 @@ +Project Ideas — Michael Toohig
      Just Arrived in the USA! Check my resume here.
      Honeymoon beach, clear blue water

      Project Ideas

      Last updated: August 06, 2023

      You can find my writings about projects I actually have built at the projects section of my website. This page will list projects I think would be nice to have or are just outright silly ideas that I periodically come up with.

      • Wall-mounted Display

      To show expected weather, tides, upcoming celestrial events like new moons or full moons especially those that set early in the night over the water, sun set coinciding with the distant island in my view like a soltice or my own house, planes & ships traveling nearby, etc.

      • Robotic Signal Mirror for Morse Code

      • English-to-Aussie Browser Extension

      • Autonomous Delivery Drone (intra/inter island versions)

      Projects that aren’t computers

      • Sun Dial

      I want to build a sun dial for my home, but use modern software to construct one perfect for my location and to accurately tell the time, month, etc by the sun’s shadow alone. I hope to respect old tradition and include decorations and a motto like Tempus Fugit. My original spark for this project was realizing I can measure the travel of the sunsets day by day. The sun traveled north behind nearby mountains without me noticing this year, months later it returned to open horizons without me noticing how fast it was traveling south and I missed the day it cleared the mountains. I feel I want to track the progression of sun in a real way that only a sun dial can do. A great software called ShadowsPro would make it possible.

      \ No newline at end of file diff --git a/projects/1nomo/index.html b/projects/1nomo/index.html new file mode 100644 index 0000000..caf64ce --- /dev/null +++ b/projects/1nomo/index.html @@ -0,0 +1,4 @@ +1NOMO — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 5 min read

      1NOMO

      An automated linear TV channel broadcast FTA in Port Vila, Vanuatu.
+

      1NOMO is a locally run and broadcast channel in Port Vila with the intention to compete with the only other locally broadcast television channel. Since we would have an automated system that could handle playlist generation and video transitions without human intervention, we hoped to capitalize on cheap advertising rates and more advanced social integrations via Facebook inspired by popular streaming services.

      We launched quietly in the end of 2019 in hopes to test out the system and begin making some approaches to local businesses for advertising (our prices were an order of a magnitude cheaper) but we all know what slowed the economy and shut our borders down a few months later.

      I truly believe the project could have grown into a major business on its own with the deals we setup for local producers for news programs, cooking shows, etc. and our automated system meant we could afford to offer cheap advertising too. But, things didn’t work out that way so let’s focus on the technical elements of the project from here on out.

      The project is comprised of a few primary elements.

      • Media Management
      • Advertiser Campaign Management
      • Playlist templates and schedule solver
      • Live video overlays
      playlist template

      Architecture

      The core components of the system were:

      1. Media storage
      2. Playout server
      3. Management server
      4. Stream server

      Media Storage

      Our office already had 2 redundant FreeNAS servers which had some empty expansion slots remaining. Into those expansion slots I added 2x4x6TB drives giving us 16TB of usable storage on the ZFS filesystem. With a bit of configuration we then had a backup on the second FreeNAS system.

      Playout Server

      Playout refers the machine that actually plays the media content for broadcast. A mid-range desktop works well for this to prevent any hiccups in media playback. On the playout server we run OBS which actually plays the media content and other segments.

      I found OBS to fit our needs just fine, although at first we looked at multiple other options such as CasperCG, Nebula Broadcast, and VLC among others.

      OBS had a great websockets server so we could remotely control it, support for drag-and-drop layouts which even non-technical users could setup, and supported streaming right out of the box.

      Management Server

      The management server runs everything else from web interface to background tasks to Telegram bot for remote monitoring and management.

      Stream Server

      The stream server was comprised of an Nginx instance with the RTMP module installed and a small Flask app that provided authentication for the incoming streams. From this server we could provide an HLS stream to our public website and re-stream to Facebook via stunnel.

      Web Interface

      The web interface is built with Flask and allows us to build the week’s playlists, import new content and setup advertisements all in a morning.

      Media Managment

      media managementadvertiser campaign

      Playlist Solver

      Playlists are built following templates which are composed of clocks.

      Clocks

      Clocks are the building blocks of a template. Clocks are composed of a duration, an optional set of tags and a type of of content such as a series, a movie, or an OBS scene.

      During playlist generation, clocks are used to choose the actual media files to schedule into the playlist. A series would select an episode, a movie would select based on the tags of the clock and a scene would tell OBS to change its current active scene to the name given. Those scenes would often contain an external stream such as ABC news.

      scheduled clock

      Templates

      Templates allow us to build repeatable playlists for different days. Clocks are placed on a 24 hour timeline to build the template. Clocks can be either premiere, repeat or random types. Premiere is often used for series which means the playlist generation should select the next episode in the series incrementing one episode since the last premiere. Repeat allows us to play the last premiere episode again at a later time. Lastly, random selects either a random episode of a series or selects randomly from the pool available content that fits the given tags on the clock.

      playlist template

      Playlists

      Playlists are built from templates that are assigned to that particular date. 1Nomo is linear because playlists should be built one after the other so our playlist generation can accurate find the next episode to play for a premiere or schedule the correct advertisements for the date and time of day.

      However, playlists can be overriden and selected media within a playlist can be changed and the clock can be resolved to fill in the remaining duration.

      scheduled template

      Each clock in a playlist usually follows a pattern

      1. a bumper
      2. the main clock content
      3. a “be right back” bumper
      4. any advertisements for the clock’s date/time
      5. a promo
      6. any filler media to pad any excessive time remaining in the clock
      7. a PSA
      8. one or more dynamic segments that can exactly fit the remaining duration

      Dynamic Segments

      Dynamic segments are elements of a playlist that tell OBS to change to a given scene. These scenes have some sort of background video, music track and a browser overlay with transparent background that shows the background video. Some dynamic segments we have are an “Up next” which shows the names of upcoming series or movies, weather segment and a COVID-19 update segment.

      These were built as a collection of small Vue.js apps which pulled data from the our API to create the segments. A benefit of this approach was they had no particular duration and were always updated on the fly.

      These segments relied on our API and we would run periodic tasks to process weather data or COVID data from the WHO and local authorities so our segments had data they could fetch quickly.

      Up Next Overlay

      Weather Overlay

      COVID 19 Overlay

      Primary Overlay

      The primary overlay contained our logo and periodic news ticker.

      The logo would also periodically give the current weather or promote our Facebook page.

      • python
      • obs
      Share:
      \ No newline at end of file diff --git a/projects/2/index.html b/projects/2/index.html new file mode 100644 index 0000000..3180a4b --- /dev/null +++ b/projects/2/index.html @@ -0,0 +1 @@ +Projects — Page 2 — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Projects

      Some of my professional and hobby projects I have built.
      \ No newline at end of file diff --git a/projects/3/index.html b/projects/3/index.html new file mode 100644 index 0000000..f0c4d1a --- /dev/null +++ b/projects/3/index.html @@ -0,0 +1 @@ +Projects — Page 3 — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Projects

      Some of my professional and hobby projects I have built.
      \ No newline at end of file diff --git a/projects/bilolok/index.html b/projects/bilolok/index.html new file mode 100644 index 0000000..36886f0 --- /dev/null +++ b/projects/bilolok/index.html @@ -0,0 +1,4 @@ +Bilolok — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 4 min read

      Bilolok

      A Foursqaure inspired app for kava bars in Port Vila, Vanuatu.
+

      In my wife’s local language from south-east Malekula bilolok means kava; which gives the tagline of the project “it means kava” a fun double meaning.

      I wanted an app that focused on a map view of the kava bars since I wanted the app to be a tool to help people explore and know what is around them in the real world.

      Bilolok map view on desktop

      But as development continued I ended up adding more social features until it became a semi-Foursquare inspired app.

      Of course users check-in to kava bars they visit. So they can become the “chief” of the kava bar if they have the most check-ins in the last 30 days.

      Bilolok user check-in Bilolok user image

      Since the map was so important to the app in my early iteration I decided to play around with the web browsers location API so I could point users towards the kava bar they selected and record the path they took to get there.

      Bilolok user trip

      Most recently, I decided to see how easy it would be to use the web browsers camera API so I ended up with user uploaded videos on the app.

      Bilolok user video

      Frontend

      A Vue.js application using Vuetify and heavily leaning on Leaflet for the map interface. It also uses Workbox for the service worker to add PWA features to give native-like capabilities to the web app.

      We are using Vue2 at this point since Vue3 was only introduced soon before starting this project and packages I wanted to use were not ready for the migration either. But, when Vuetify, Vue-Leaflet, and a few others are stable on Vue3 this project should upgrade for the improvements to bundle size and speed. Plus the upgrade to Vite will be welcomed.

      Bilolok kava bar profile page

      Map

      Leaflet is a great mobile-friendly interative map library for most use-cases such as ours.

      Bilolok map view on desktop

      The only downside of leaflet for our use-case is the use raster tiles and no native support of vector tiles. Raster tiles do require more bandwidth but require less processing power to render. Since many users have mid level or low level devices and since I would be caching I figured that was a trade-off I could handle.

      Map Alternatives

      OpenLayers and MapBox are two alternatives to Leaflet that support vector tiles. I did build a simplified version with each but the extra complexity for me to learn a new framework when I just wanted to get something deployed meant I stayed with Leaflet. I would try OpenLayers again once I feel the value of more customization and vector tiles is worth the effort.

      PWA Features

      Progressive Web App features allow for the Vue.js frontend to use several browser/device APIs that make the web application behave more like a native mobile application.

      • Available for download on Google Play Store
      • Add to Home screen from browser
      • Geolocation
      • Push Notifications
      • Sharing
      • Offline accessible and delayed POST requests until network returns

      Originally, I wanted to explore using PWA features for the offline capabilities since network coverage in Vanuatu is poor and data usage is expensive. But over time I just kept adding more PWA features to familarize myself with them and see what could be built.

      Data Store

      Backend API

      The API is built with FastAPI and in the hopes to simplify development I decided to try out a few FastAPI specific packages:

      • FastAPI-Users
      • FastAPI-CRUDRouter
      • FastAPI-Mail

      The database is Postgres and I interact with it via the latest 2.0 version of SQLAlchemy which supports async just like FastAPI. Does it really need to be async, probably not but I’ll just go with the trend and be familiar with it.

      Resumable Uploads

      Tus is an open protocol for resumable file uploads. I wanted to implement the tus protocol to be resilient against network unreliability which is a persistent issue here.

      On the frontend I use Uppy to handle the tus protocol and provide a great drag and drop target for files and the option for more plugins that allow uploading images from the user’s Instagram or other social networks.

      Bilolok kava bar profile view on desktop

      Image CDN

      Thumbor is an open source project for on demand image resizing, cropping and filters. I like that once its up and running I only have to manage the original image uploads and let it handle the rest. I just have to specify the size of image I want when calling for the image.

      But to prevent misuse it is best to add HMAC. This way only the sizes you specify will be generated and only the images you specify will populate the image cache.

      In the docker deployment we run a Nginx proxy infront of the thumbor instance so thumbor will not save any files but lets Nginx cache the images.

      • vue
      • python
      • fastapi
      • leaflet
      Share:
      FYI: This project has the following related posts.
      Thumbor

      Thumbor

      Integrating Thumbor image thumbnail service into Bilolok.

      \ No newline at end of file diff --git a/projects/index.html b/projects/index.html new file mode 100644 index 0000000..9caca4b --- /dev/null +++ b/projects/index.html @@ -0,0 +1 @@ +Projects — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Projects

      Some of my professional and hobby projects I have built.
      \ No newline at end of file diff --git a/projects/integrating-a-new-sms/index.html b/projects/integrating-a-new-sms/index.html new file mode 100644 index 0000000..3242f81 --- /dev/null +++ b/projects/integrating-a-new-sms/index.html @@ -0,0 +1,115 @@ +Integrating A New Subscriber Management System — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 12 min read

      Integrating A New Subscriber Management System

      Writing the glue to tie two different systems under one managment software.
+

      Background

      $BUSINESS provides subscription digital TV services over the air called DVB to households and larger systems for hotels and resorts in $TOWN. From the source at the top of the town’s hill the TV channels are received, transcoded, muxed, encrypted and broadcast. Each customer has a decoder to decrypt the signal which is either a simple box connected via HDMI to the TV or a more advanced IRD for commerical installations. At the heart of all this is the CAS which encodes authorization messages in the broadcast signal and allows decoders to know which signals they can decrypt and for how long. You can read the patent for further details.

      Problem

      A new CAS was purchased but it came from a new supplier and did not mesh with the existing systems. At the time two CAS were already in use, one for hotels and one for individual customer use, and both were from the same supplier. Adding a new CAS was not going to be straightforward as this new system had a fundementally different approach towards accepting authorization information and did not provide an API for anything less than a pricey amount.

      The two existing CAS are tied together by an admin interface which most importantly ties invoicing to authorizations of the decoders. Finding a way to coerce the existing system into authorizing decoders on the new CAS would be the challenge.

      Also, $BUSINESS ran out of the old model of decoder and already started selling the new decoder models. So for a couple weeks juggling the invoicing system and the new CAS subscriber management system was necessary until a complete migration to just the invoicing system was possible.

      Technical Background and Attack Strategies

      The existing CAS at $BUSINESS each have a database and some of the tables therein are periodically checked by the CAS itself for tasks to be performed such as updating authorizations, OSD messages or mail messages to the customer decoder.

      Microsoft SQL Server is the RDBS for both the invoicing system and for the all of the CAS. On the invoicing database a SQL Server Agent ran a periodic task to run stored procedures which inserts the authorization information into the CAS database. Below is an example of one of those authorizations as received by the CAS.

      Note: decoders have a removable card inside them which behaves exactly the same as a SIM card for a phone. So I will continue use the word decoder but you may see card in some of the examples but just know that card is referencing a decoder. And on the new CAS you will see STB but that also just means a decoder.

      | CardID     | ProductNumber | ProductStartTime        | ProductEndTime          | SendTime                |
      +|------------|---------------|-------------------------|-------------------------|-------------------------|
      +| 4291000278 | 1             | 2022-07-05 00:00:00.000 | 2022-07-31 23:59:59.000 | 2022-07-05 08:39:03.347 |
      +

      The best outcome would be to pass the same sort of instruction to the new CAS so that it would broadcast authorizations to decoders. A simple update to the SQL Server stored proceedure and we would be done.

      Attacking the Database

      The CAS had two databases one for the EMMG and the EMCG which work together to encode the authorizations into the broadcast signal and a second for the subscriber management system.

      I first played around with the SMS to see how the database responded to particular actions. I was concerned when I saw some cryptic values in the tables that appeared when granting authorization to my test decoder.

      | Auth_Buffer                                                                                                                                        | AuthVersion | STBNumber            |
      +|----------------------------------------------------------------------------------------------------------------------------------------------------|-------------|----------------------|
      +| 0x040000000200E607051F000000E607081F000000010500E607051F000000E607081F000000010800E607051F000000E607081F000000010A00E607051F000000E607081F00000001 | 44          | -8974767510011026891 |
      +

      I couldn’t quite figure them out except I knew the Auth_Buffer was describing the products to decrypt. I did know this because it looked just like the authorization strings used in the existing CAS which could be generated with a helper tool provided by the other supplier and were statically set. But this CAS was hiding the generation of this string for each and every authorization change per decoder. I figured this was hidden somewhere in their backend when it was communicating with a supplied dongle that must be inserted for the software to work. And I do not know where these STBNumber values were coming from as they did not match any actual number of the decoders that I was aware of and changed with each authorization even for the same decoder. By messing around with the database and trying to force the system to accept known good values via a replay-attack and failing I knew this database was also not the source of truth for authorizations but instead was just a running log of authorization jobs that have already been performed.

      I then tried the same attacks to the tables in the SMS database to mimick the rows that would be inserted for a given user action on the web interface but nothing would happen. So the key was in the backend code and probably tied to the “softdog” dongle inserted to the server that without it the software would not work. I think it is also a backdoor for the supplier to provide support because when I asked general questions to the supplier they would respond with specific responses they couldn’t have known without looking directly at the data I had modified. Kinda scary.

      Direct manipulation of the database was ruled out and as much as I would enjoy learning to reverse engineer the software further it wasn’t the fastest way to a working solution when more options were still available.

      Attacking the Web User Interface

      Now that direct modification of the database is ruled out, the next step would be to mimick a user on the web UI. Looking at the browser’s DevTools it was easy to record the URLs and form data required for each action I needed to perform. But before I could submit forms using requests the UI presented a challenge at the login screen.

      Logging in with admin and the secure password 123456 in the form would submit the following to the backend.

      {
      +  LoginID: "admin",
      +  LoginPassword: "d3Sc32H1RQ0zJcAUc1mWCb/lEGJTHes8b+uykb3Xr+3djFmJAQsZBgEOLyz9Bwt7csBH2LjUr7XjoZnOCfJOUnfGB4U9TS5f+263ZQGwQcaqzvgmQhl6h/B+/krH4vBE+TMuAckEtpnT9c1Lk4iMuHS9qh2x46x5DRMpAoo4c9A=",
      +}

      The login form was not submitting my creditionals from the login form in cleartext but was encrypting it.

      To mimick a user logging in I had to port the encryption code from their site’s Javascript files and pull out an obfuscated public key to RSA encrypt the username, password and a timestamp to create the LoginPassword field the backend expected. Then I would be rewarded with authorization to access the SMS UI further and be allowed to submit other forms.

      I then found performing a single POST request to mimick a user submiting a form would invalidate the current user credentials. Each form submitted would reload the page and I found new authentication tokens were snuck into the bottom of the returned HTML body. Using Beautiful Soup it was easy to grab the <object> tag at the end of the HTML file and grab updated SessionID and AspxAuth values to use in the next request headers.

      The SMS didn’t stop complicating matters there, a single form could require up to 30 fields. Most of those fields were redundant since a CustomerID field would be required along with CustomerName, CustomerRegion, etc which all could be derived on the backend from the CustomerID so I have no idea why these forms were made this way. Also, every action performed on the web UI returned plain HTML so there was no raw JSON responses or anything easy to parse. I hooked into the SMS database with SQLAlchemy to have direct access the values needed for each form.

      Soon I had mapped out the actions I would need to perform on the web UI and the form data needed for each. This would include helper functions that would handle each step in the login -> subscribe to product -> make payment process.

      def subscribe_product(start: date, months: int, card_id: int, product: Product):
      +    """Creates an authorization for a card/STB for the given product."""
      +    end = get_end_date(start, months)
      +    subs = Subscription.get_overlaps(start, end, card_id, product.id)
      +    logger.debug(f"Found {len(subs)} overlapping subscriptions")
      +    for sub in subs:
      +        if sub.is_active():
      +            unsubscribe_product(sub.card_id, sub.id)
      +        else:
      +            sub.delete()
      +    # perform steps to create and pay for a product
      +    dibsys = Dibsys()
      +    dibsys.update_auth_headers()
      +    dibsys.make_product_order(start, months, card_id, product)
      +    dibsys.update_auth_headers()
      +    dibsys.make_charge()

      Although a bit cumbersome it was a working method to initiate an authorization to a decoder from a program.

      Mitigating Defined Limitations

      The last major issue I ran into was the web UI enforced the duration of subscriptions to be purchased in months. You can see in the last section the subscribe_product function takes a start date and a months integer for the number of months to subscribe. At first the solution is obvious, start subscriptions in the past so that the end date occurs when you want the subscription to end. But the logic hidden in the backend also enforced that no subscription may overlap a previous subscription. So when you set the start date 25 days in the past to give a 5 days trial to a customer then allow them to purchase the remainder of the month by setting a new start date 5 days in the past an error is thrown that the decoder already has a subscription in that timeframe.

      So I had to dig into the SMS database to find what is enforcing this rule. I found in the SMS database the subscriptions table kept record of… subscriptions. It had an obtuse way of separating cancelled subscriptions from expired subscriptions but eventually it was decoded.

      # simplified version
      +| CustomerID | DecoderID | ProductID | StartDate               | EndDate                 | OperateType | BackOrderID | Remark                                                   | AuthStatus |
      +| 1          | 134       | 5         | 2022-07-01 00:00:00.000 | 2022-07-15 00:00:00.000 | 2           | 128         | Unsubscribed IC Number [128] IC [13466-8373388000134669] | 0          |
      +

      I also realized the DecoderID had no constraint to reference an ID that actually exists in the decoder table. It appeared the backend was searching the subscription table for any subscription that matched the DecoderID and that naturally expired between the given dates. So I ended up deleting the subscriptions by changing the DecoderID value to 0 and the web UI no longer blocked me from creating overlapping subscriptions.

      Lastly, to handle subscriptions of any duration I added some helper functions to select a start date based on an end date that would match the logic of the SMS backend. Sometimes this meant a subscription for one day would be two months long because I would have to go back two months for a month that had 31 days so the subsciption would end the following day on the 31st.

      def get_months_between(start: date, end: date) -> int:
      +    """Return number of months between two dates. Good enough."""
      +    return (end.year - start.year) * 12 + end.month - start.month
      + 
      + 
      +def get_start_date(end: date) -> date:
      +    """Return a start date that ensures a subscription will end on given date.
      + 
      +    This is a trick I'm using to get around the one month subscription
      +    requirement by creating subscriptions with start dates in the past
      +    to trick the subscription to end on the date given.
      +    """
      +    months_with_31_days = [1, 3, 5, 7, 8, 10, 12]
      +    today = datetime.now().date()
      +    month = None
      +    for n, m in enumerate(months_with_31_days):
      +        if m >= end.month or m >= today.month:
      +            month = months_with_31_days[n - 1]
      +            break
      +    start = date(today.year, month, end.day)
      +    months_to_end = get_months_between(start, end)
      +    return start, months_to_end

      Handling Multiple Databases

      Since I was working with both the new tasks database and the SMS database I created a SQLAlchemy session class that would route to the correct engine based on the ORM model’s parent class.

      from sqlalchemy import create_engine
      +from sqlalchemy.orm import Session as SQLA_Session, sessionmaker
      + 
      +from dibsys_cli.config import TASK_DATABASE_URI, WEB_DATABASE_URI
      + 
      + 
      +task_engine = create_engine(TASK_DATABASE_URI)
      +web_engine = create_engine(WEB_DATABASE_URI)
      + 
      + 
      +class RoutingSession(SQLA_Session):
      +    """Decides which engine to use for database queries."""
      + 
      +    def get_bind(self, mapper=None, clause=None):
      +        from dibsys_cli.database.models.base import WebBase
      + 
      +        if mapper and issubclass(mapper.class_, WebBase):
      +            return web_engine
      +        return task_engine
      + 
      + 
      +Session = sessionmaker(
      +    autocommit=False,
      +    autoflush=False,
      +    class_=RoutingSession,
      +)

      This gave the advantage that I didn’t have to juggle which database I would connect to for the object I was dealing with. With an explicit base class I could also define basic operations that were allowed so it was difficult to modify a row in a table that I didn’t want modified.

      from sqlalchemy import MetaData
      +from sqlalchemy.exc import SQLAlchemyError
      +from sqlalchemy.ext.declarative import as_declarative
      + 
      +from dibsys_cli.database.session import Session
      + 
      + 
      +@as_declarative()
      +class Base:
      +    id: Any
      +    __name__: str
      + 
      +    def save(self):
      +        raise NotImplementedError()
      + 
      +    def delete(self):
      +        raise NotImplementedError()
      + 
      +    def update(self, **kwargs):
      +        raise NotImplementedError()
      + 
      + 
      +class TaskBase(Base):
      +    __abstract__ = True
      +    metadata = MetaData()
      + 
      +    def save(self):
      +        ...
      + 
      +    def delete(self):
      +        ...
      + 
      +    def update(self, **kwargs):
      +        ...
      + 
      + 
      +class WebBase(Base):
      +    __abstract__ = True
      +    metadata = MetaData()

      Solution

      To continue use of the stable, legacy invoicing and subscriber management system I had to connect it to a new CAS target that looked like a CAS from its own supplier. First, I added a new database with tables that mirrored the tables found in the existing CAS that I called the ”tasks” database. I updated the legacy invoice software so that changes to invoices that included a decoder from the new supplier would result in authorization messages arriving in the new tasks database. Then, a script I wrote would periodically view the entries to the tasks database and peform the necessary steps on the new SMS web UI to complete the authorization tasks. Essentially, making the new SMS believe someone was sitting at a desk going through the motions required to authorize a decoder.

      I wrote this solution with Python and used click to make a CLI for interfacing with the new CAS. This CLI could mimick a user on the web UI or browse and modify the tables of the SMS directly where possible. Adding the CLI command to cron runs the tasks periodically and keeps customers of the new decoders subscribed.

      • python
      • sql
      Share:
      \ No newline at end of file diff --git a/projects/pdf-hyperlinks/index.html b/projects/pdf-hyperlinks/index.html new file mode 100644 index 0000000..dc62d47 --- /dev/null +++ b/projects/pdf-hyperlinks/index.html @@ -0,0 +1,62 @@ +Automating PDF Hyperlinks — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 5 min read

      Automating PDF Hyperlinks

      Improving efficiency by rebuilding lost hyperlinks

      Background

      $BUSINESS is an engineering firm that has an extensive library of manuals and guides stored as PDFs. The PDF files reference related documents as sources of background information to the current document, troubleshooting guides, assembly instructions, etc.

      Problem

      Although related documents appear in the document as if you can click on them due to the familiar blue hyperlink color, it is in fact just text. The PDF files are stripped of all inter-document hyperlinks. As a result, navigating between documents is a time consuming task since the name of the referenced document needs to be manually typed or copy-pasted into the search bar of the document store.

      Originally the documents had hyperlinks but they were generated by a supplier who is no longer available so access to the original documents and therefore rebuilding the PDF files from the source code if you will is not an option. New hyperlinks will need to be added to the existing PDF files.

      Lucky Breaks

      The solution is actually quite straightforward thanks to a few reasons.

      1. The files are text-based

      Unlike image-based PDF files which is what you get when you scan a physical document, text-based PDF files contain easy access to the text and other information to each and every element or character in the document. We will be able to simply read the text from the document with some readily available tools. This way we avoid needed to use computer vision and OCR which would be more error prone and require extra steps to verify or clean the computer vision results.

      As mentioned just before, we can use readily available tools to scan the documents and locate all of the blue characters in the document. Only former hyperlinks have this shade of blue so it is easy to select them from the other text in the document.

      The hyperlinks used the exact name of the referenced document and never used anything such as “The guide here or “additional information can be found here. The documents explicitly state the referenced document name such as “See INI-054 Assembly Guide for details” whenever a related document is mentioned so we will not need to infer or do additional analysis on documents to try and match related documents that best fit the context of the document we are working on. This as you can imagine turns the project into a simple script compared to a full-on job it would have been if someone with technical background on the engineering subjects was needed to verify each and every suspected hyperlink target.

      Solution

      First, we recursively walk the directories of the PDF files available collecting file names and full file paths as we go.

      Then, we scan through the documents using pdfplumber to identify the former hyperlinks and collect the text of the hyperlink to match with a known file name. We also grab the position of the hyperlink characters on the page.

      Lastly, we can overlay the blue, former hyperlinks on the document with a new hyperlink using the coordinates collected in the previous step using PyPDF2.

      Technical details

      Finding former hyperlink text isn’t completely straightforward but it was relatively easy in this case.

      The first step is to filter the characters on the page down to just the characters containing the distinctive blue shade.

      def extract_blue_characters(pdf_file: Path) -> List[Tuple[int, List[Dict[str, Any]]]]:
      +    """
      +    Find all characters in the PDF file that use the unique blue color
      +    that distinguishes the text as a former hyperlink.
      +    """
      +    pages = []
      +    with pdfplumber.open(pdf_file) as file:
      +        for num, page in enumerate(file.pages):
      +            chars = []
      +            for char in page.chars:
      +                if char["non_stroking_color"] == (0.0, 0.4, 0.8):
      +                    chars.append(char)
      +            pages.append((num, chars))
      +    return pages

      Now a page may contain many former hyperlinks so this collection of blue characters contains characters from all parts of the page and needs to be grouped by the originally referenced document names as they are read on the document. Each character object returned by pdfplumber contains coordinates for the position of that character on the page and by shear luck we also got away with finding only one referenced document per row so we can filter the characters that share a unique horizontal row on the page to reconstruct a hyperlink’s group of characters.

      @dataclass
      +class PDFLink:
      +    top: int
      +    text: str
      +    chars: List[Dict[str, Any]]
      +    position: Tuple[int, int, int, int]
      + 
      +def find_links(chars) -> List[PDFLink]:
      +    """
      +    Find distinct links in the list of characters.
      +    This works easily since the PDF file is text based
      +    so horizontal rows are perfectly level and we are
      +    assuming a single line only contains a single link.
      +    """
      +    y_positions = set(map(lambda c: c.get("top"), chars))
      +    links = []
      +    for y0 in y_positions:
      +        link_chars = sorted(
      +            filter(lambda c: c.get("top") == y0, chars), key=lambda c: c.get("x0")
      +        )
      +        text = "".join(map(lambda c: c.get("text"), link_chars))
      +        left = int(link_chars[0].get("x0"))
      +        right = int(link_chars[-1].get("x1"))
      +        top = int(link_chars[0].get("top"))
      +        bottom = int(link_chars[0].get("bottom"))
      +        links.append(
      +            PDFLink(
      +                top=y0,
      +                text=text,
      +                chars=link_chars,
      +                position=(left, top, right, bottom),
      +            )
      +        )
      +    return sorted(links, key=lambda l: l.top)

      Finally, we overlay the referenced document text with a hyperlink annotation using the position of the characters in the link.

      def write_links(page_number, pageObj, writerObj, links: List[PDFLink], url):
      +    writerObj.add_page(pageObj)
      +    pageShape = pageObj.mediaBox
      +    height = pageShape[3]
      +    for link in links:
      +        # Modify top and bottom box values so that its (0, 0)
      +        # coordinates start from bottom left corner of page
      +        # instead of top left corner of page
      +        left, top, right, bottom = link.position
      +        box = (left, int(height - top), right, int(height - bottom))
      +        # Add the hyperlink
      +        annotation = AnnotationBuilder.link(
      +            rect=box,
      +            url=url,
      +        )
      +        writerObj.add_annotation(page_number=page_number, annotation=annotation)

      The task sounded more complex when I heard the problem described for the first time, but upon seeing the documents and finding a few lucky breaks it made the job far too simple for such an improvement to engineers’ productivity.

      • automation
      • python
      • pdf
      Share:
      \ No newline at end of file diff --git a/projects/remote-video-monitoring/index.html b/projects/remote-video-monitoring/index.html new file mode 100644 index 0000000..88998b9 --- /dev/null +++ b/projects/remote-video-monitoring/index.html @@ -0,0 +1,4 @@ +Remote Video Monitoring — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 7 min read

      Remote Video Monitoring

      Automated Post-STB QoS monitoring with a RaspberryPi
+

      $BUSINESS is a MVPD that broadcasts several dozen channels from their NOC located on the hill at the top of $TOWN. Some channels are weather affected and all channels or the hardware in the processing chain need some sort of periodic maintenance or recovery from bad states. The existing monitoring strategy has been periodic manual monitoring with increased attention during inclement weather. But, sometimes channel issues are brought to $BUSINESS attention by complaints from customers.

      Given a short time period, the probability of failure is low for a single component but, the probability of a single failure from any component is quite high.

      Background Information

      $BUSINESS receives dozens of satellite signals which are handled by decoders that decrypt the streams. The streams are decrypted then some of those streams are passed through additional processing such as transcoders. Analog streams have the extra step to encoded them into digital streams. The now standardized digital streams are then passed through a CAS which encrypts the streams again but now with a key held by $BUSINESS. Finally, the digital streams are muxed together and broadcast over the air to the surrounding area.

      Diagram

      At any point in the chain of processing some hardware could malfunction/freeze/reset due to random chance, power issues or weather issues. There is also the surprisingly frequent occurrence of an upstream provider unexpectedly changing their catalog and requiring an update to return to the original program.

      Due to all these points of failure and the total number of components there is always something to fix.

      Remote Monitoring Solution

      Given that issues occur at the NOC itself, such as power loss or broadcast signal issues, monitoring from within the NOC would mean failures that affect the entire broadcasting system would affect the monitoring solution as well or would go undetected in the case of broadcast signal issues occuring after the point in the processing chain that a monitoring solution would hook into. So a remote monitoring solution was preferred. We can remotely monitor downstream and isolated from the NOC itself using a normal decoder or STB that customers would use.

      Diagram

      Technical Details

      The solution was built with minimalism in mind and simplicity. To meet those goals a single RaspberryPi was used to hook into the STB for controlling the channel that was selected, fetching video samples, reviewing those samples and reporting issues immediately over the internet to those responsible for rectifying any problems.

      Software

      There are four software services on the RPI and each run independently so that they can be swapped out easily in the future. So if the method by which samples are fetched changes or a new method of reporting issues is required the code can updated/replaced without interfering with the other services.

      Given my simplicity principle they share information where needed via files on the system. Had the project grown any further it would be good to upgrade to something like a shared SQLite database but at the complexity requirements now flat files are sufficient.

      Collection Service

      An async event loop written with Trio controls the STB via the GPIO pins on the RPI. Then, a sample of the channel is fetched using ffmpeg. The sample is associated with its channel number by reading the channel number directly from the sample using an OCR tool. Lastly, the sample is stored with the timestamp it was collected with other samples from the same channel number.

      This loop will repeat and another sample is added for the monitoring service to review.

      Monitoring Service

      The monitoring loop reviews the collected samples for each channel on a regular interval. For each channel, samples are compared against the samples preceding it to check if the channel appears to be frozen or stuck. This would happen quite often during inclement weather. This is done using a similarity hash algorithm so the samples do not need to be perfectly identical but close enough to trigger an alert that the channel should be reviewed. There is also a cache of samples to be used for immediately triggering an alert for known bad samples and another that contains samples that should never trigger an alert for whatever reason such as the children’s channel displaying a static “We’ll be back tomorrow” message during the night.

      This process can also identify if no new samples have been collected or just a particular channel stopped gathering samples which is a sign of an issue with the STB itself and calls for a reboot of the STB.

      Web Service

      A simple web interface gives a view of all the most recent collected samples side by side and displays current statistics. The recent history of samples are also available if selected to help diagnose when an issue has begun and allows users to add samples to the “ignore” or “alert” caches used by the monitoring service.

      web interface

      Telegram Bot Service

      Finally, for remote monitoring and alerting the Telegram bot can reach users directly at their phone and has commands available to allow users to review samples, see current alerts, instruct the collection service or monitoring service to perform a particular action all from a remote location.

      This service could be replaced by or have a sibling service such as an email service or another messaging service to reach those who need to know about issues. Telegram was just a preference.

      Hardware

      The hardware is simple and consists of a few components.

      The hardware was scavenged as it was impossible to import components due to geography and border lockdowns at the time. Luckily, a RaspberryPi was on hand but it was a generation 1 so performance leaves a lot to be desired.

      • RaspberryPi single board computer
      • Standard customer STB
      • HDMI-to-IP converter
      • Relay Module

      The RaspberryPi uses its GPIO pins to both control the power to the STB via the relay module and to control the channel selection on the STB. Channel selction is possible due to a photodiode, taken from a broken security camera, shorts the STB’s physical channel control buttons when light is applied to it from an LED controlled by the RPI.

      Final Thoughts

      As a result monitoring QoS is much more of a background process than something that requires frequent mental interruptions to remember to manually monitor the channels. The Telegram bot also allows users to remotely monitor when no physical TV is available.

      Adding an additional STB or upgrading the RPI would greatly reduce the time between channel sample collections and help catch issues quickier. But at the moment there is a 15 to 20 minute duration on the collection loop which isn’t bad compared to the manual process before where an unknown issue could go unnoticed for longer.

      Personal Note

      The hardware was the most difficult part of the project for myself as I had not automated a hardware system before. I did enjoy the experience and see myself using these skills more in the future.

      • python
      Share:
      \ No newline at end of file diff --git a/projects/sabdivisen/index.html b/projects/sabdivisen/index.html new file mode 100644 index 0000000..1b05b31 --- /dev/null +++ b/projects/sabdivisen/index.html @@ -0,0 +1 @@ +Sabdivisen.com — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 2 min read

      Sabdivisen.com

      Vanuatu's map for subdivisions.

      The name Sabdivisen is the Bislama spelling of Subdivision and you can find the project at Sabdivisen.com or on the Google Play Store.

      The project was created so that the local population in Vanuatu would have a single map they can reference to find what subdivisions are currently selling land. Prior to this map, subdivisions would be listed sporadically on realitor websites, advertised via newspaper or most commonly posted in any number of local Facebook groups. As you may have imagined, this maked it hard to know what was for sale at any given time and therefore difficult at best to make informed comparisons against competing offers.

      The project aims to be easy to use by supporting the local language and having a straight-forward user interface. On the technical side, the first page download is less than 400 kB with subsequent pages only requiring a couple dozen kB each since it is a SPA. This makes it easy to load on all-to-often poor network conditions available locally and was easy to do thanks to no more than thoughtful use of dependencies and optomized assets.

      Technical Details

      The frontend is a Vue.js app written mostly in TypeScript and the maps are made with Leaflet. The backend is a self-hosted Directus instance, a headless CMS, and sits atop our Postgres database and provides our API. Lastly, another self-hosted service, Plausible, provides user analytics.

      All of the services are wrapped up together via a Docker compose file which allowed for easy development and production deployment. Where needed a few Ansible scripts handle repeated non-trivial tasks such as deployment, issuing SSL certificates and backups.

      The app is small and runs easily on a single 2GB VPS out of Australia.

      • vue
      • directus
      • leaflet
      Share:
      FYI: This project has the following related posts.
      \ No newline at end of file diff --git a/projects/ssc-container-tracker/index.html b/projects/ssc-container-tracker/index.html new file mode 100644 index 0000000..46234cf --- /dev/null +++ b/projects/ssc-container-tracker/index.html @@ -0,0 +1 @@ +South Sea Cargo Container Tracker — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 4 min read

      South Sea Cargo Container Tracker

      Replacing the foundation on a local shipping business's information system.

      I approached an acquaintence I had recently met with the intent of understanding his business’s problems to look for somewhere I could offer my services. Right off the bat, he told me he already knows what he wants based on the software he used at his old company and we started working together right there. That’s how I began developing the information system for South Sea Cargo.

      Background

      South Sea Cargo truck & crew

      South Sea Cargo provides inter-island cargo transportation. They operate a barge which can carry up to 33 standard TEU containers and other over-sized items such as trucks, construction equipement, various odd-shaped items and more. But beyond the barge they provide full-service delivery too. South Sea Cargo maintains an inventory of almost 100 containers and a fleet of delivery trucks which provide empty containers at the customer’s premise and transport the full container to their final delivery destination.

      At the point at which I arrived they have been using a popular chat messaging app as a centralized reporting channel which staff in the office would read to maintain an Excel sheet with the latest container locations as reported in these chat groups. Plus, there were chat groups for maintenance reports, bookings, and for providing updates to individual customers.

      South Sea Cargo’s operation has been growing making it harder to maintain this system accurately. Eventually, it won’t be tenable. In fact, a contributing factor for starting the project was earlier that year they unknowningly lost a container on an outer-island and only recovered it a few months later after a driver just happened to come across it and called it into the main office.

      Scope

      South Sea Cargo has a decent sized operation maintained by a dozen staff and equal number of drivers so replacing all of this in one step is not going to to happen nor do I believe it to be wise to attempt. Together we opted to begin by replacing only the Excel sheet which maintained the current locations of their fleet of containers. From there we would build upon that foundation to add new featuers down the road.

      Solution

      I setup an instance of Directus on a VPS and built the required tables for tracking the container movements. Then, I built out a UI via a custom Directus module plugin.

      With these tables included structured location data to help identify containers by island or region. Locating empty containers used to be a tedious job which is important so a customer can be provided an empty container to fill with their goods to be transported.

      Areas Page

      With the database in place we can recreate the original Excel sheet and present all of the current container locations.

      Containers Positions Page

      However, with the database we can now click on a conatiner to explore the container’s movement history which was not possible in the Excel based system.

      Container History Page

      Because we are collecting so much more information compared to the original Excel based system we can answer more questions than previously possible such as:

      • Which containers are located at X?
      • Driver Y transported which containers {today,yesterday,last month}?
      • How many empty containers are on island Z and their exact locations.
      • How does our container utlization compare to last month?

      Container Maintenance

      Container Maintenance Page

      Given the simplicity of development afforded by Directus I quickly threw in a container maintenance feature. Similar to tracking container movements this feature records container maintenance jobs for the duration of the container’s life.

      With this feature comes the ability to begin scheduling maintenance pro-actively rather than responding re-actively to unexpected damage reports.

      In addition, I added container maintenance warnings to the container’s detail page to make staff aware to the container’s known maintenance state so staff can take that into consideration when deciding if a container is fit to carry a particular load. This saves time and delays from drivers calling into the office to say they can’t take an empty container to a customer because someone forgot that the container had a hole in the floor or such damage making it unfit for the particular goods it was selected to carry.

      Conclusion

      I look forward to continuing the development of the container tracker to help South Sea Cargo improve their services. It was a relatively painless transfer from the old system to the new Directus system and not long after the staff have begun praising the benefits provided by the database.

      The next steps will continue to migrate more of South Sea Cargo’s operations from chat messaging and informal systems to the database to improve their services.

      • directus
      Share:
      FYI: This project has the following related posts.
      \ No newline at end of file diff --git a/projects/ssc-drivers/index.html b/projects/ssc-drivers/index.html new file mode 100644 index 0000000..396f417 --- /dev/null +++ b/projects/ssc-drivers/index.html @@ -0,0 +1 @@ +South Sea Cargo Driver's App — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 2 min read

      South Sea Cargo Driver's App

      Local-First PWA for delivery drivers in remote areas

      After transitioning South Sea Cargo from an Excel-based system to a more robust database-driven system, the next challenge was to streamline how delivery updates were reported. Traditionally, these updates were communicated through chat apps, which required manual transcription into the system. To address this, I developed the South Sea Cargo Drivers App, enabling drivers to report container movements directly into the database.

      This app is particularly designed for the South Sea Cargo drivers working who regularly make deliveries to remote islands of Vanuatu, where cellular service is unreliable nor non-existent. The app functions independently of network availability, ensuring drivers can always report their status accurately and without delay.

      The introduction of this app has significantly eased the workload of office staff who previously spent hours monitoring chat logs and manually entering data and correctly for typos or inexact delivery descriptions. This shift not only enhances efficiency but also reduces the likelihood of errors that could affect delivery tracking and ultimately, the company’s bottom line.

      Background

      Previously, the client relied on a shared Excel file to monitor inventory, a process that was not only cumbersome but also prone to errors. Miscommunications or missed reports in the chaotic environment of a group chat could lead to significant discrepancies in container’s reported and expected location. At worst, a container could go missing until a driver may come across it again by happenstance, which has occured.

      Solution

      The solution was a Progressive Web App (PWA) designed with a ‘Local-First’ approach. This means the app prioritizes offline functionality, allowing drivers to log information into the system without needing an active internet connection. This approach ensures that no data is lost and all entries are synchronized with the central database once a connection is available.

      Results

      Initial feedback from the app has been overwhelmingly positive. The app has notably reduced the administrative burden on staff, freeing them up for more strategic tasks. This improvement has been a significant step forward in optimizing operational efficiency and freeing resources for future expansion of the information system.

      Looking Ahead

      The next phase of development will focus on integrating job scheduling directly into the app, allowing drivers to receive and acknowledge their assignments without the need for face-to-face interactions. This enhancement will further streamline operations and improve communication accuracy.

      By continuously improving the app, we aim to provide a comprehensive tool that not only simplifies the reporting process but also enhances the overall workflow of South Sea Cargo’s delivery operations.

      • vue
      • pwa
      Share:
      FYI: This project has the following related posts.
      Local First PWA

      Local First PWA

      A webapp built for offline users which gracefully handles expired authorizations.

      \ No newline at end of file diff --git a/projects/vanuatu-energy-dashboard/index.html b/projects/vanuatu-energy-dashboard/index.html new file mode 100644 index 0000000..83894e8 --- /dev/null +++ b/projects/vanuatu-energy-dashboard/index.html @@ -0,0 +1,7 @@ +Vanuatu Energy Dashboard — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 3 min read

      Vanuatu Energy Dashboard

      An open source dashboard on Vanuatu's energy sources and
+prices from information obtained in public records.
+
      plot of renewable energy production

      This project was my first dive into data science and can be interacted with at vanuatu-energy-dashboard-app.herokuapp.com *(this may have been true but now I’m migrating to my new domain so it is offline now) or source code can be found on Github. It’s also an excuse to finally play around with Dash, a tool for building data rich websites using Plotly. All-in-all it took only a couple days to put together once I had the data. The data took a bit longer to gather since I had to collect it from various sources and mostly from PDF reports. But, I did automate the data fetching and processing steps in my code so that future updates are seamless.

      Background

      In Vanuatu there is pretty much one utility company, Unelco, and electricity prices have been rising steadily even before the war in Ukraine began. I thought investigating the relationship between electricity prices and fuel prices, the majority of Unelco power produced is from diesel, would be an interesting topic for learning a bit about data science.

      Data Sources

      All sources and raw data are available in the Github repo.

      At the moment I have a few sources of data. I have two local sources, both monthly PDF reports, one tariff rate report from Unelco and the other an affordability report from the utility regulator (URA).

      My third source of data, crude oil price averages, are a substitute for local diesel costs until I can obtain that data locally. To help make the price comparison more accurate I used exchange rate data to match the month of the crude oil price and converted from USD/barrel to Vatu/barrel.

      The Department of Energy has been slow to return my messages for the data they use for their sporadic fuel price announcements. I do not want to deal with trying to scrape the announcements as they are inconsistently released and some such as this one show they clearly have daily fuel cost information internally. But, I guess it could make for an interesting NLP project.

      Data Issues

      First of all, Unelco has been great through this project. They outright delivered past data upon request which is an unheard of level of service in Vanuatu. So no issues there.

      I first found problems with the URA since they have completely stopped releasing their reports since March of 2022. They also seem to have at least one monthly report missing every year. Not to mention something happened in March of 2020 where simply no data was really collected but a report was still released.

      Conclusions

      plot of tariff rate

      I don’t believe much can be concluded, I’m not a professional just some guy playing around with public data after all. Although you can see that Vanuatu is not actively transitioning to renewable sources of energy, over the years the total amount energy produced by renewable sources has stagnated. But, I will say I am most surprised that the tariff rate appears to rise slowly with fuel costs and drop quickly with fuel costs. So good on you Unelco, I never expected that.

      • data-science
      • python
      • dash
      Share:
      FYI: This project has the following related posts.
      \ No newline at end of file diff --git a/projects/vmgd-api/index.html b/projects/vmgd-api/index.html new file mode 100644 index 0000000..0ad8f09 --- /dev/null +++ b/projects/vmgd-api/index.html @@ -0,0 +1,18 @@ +VMGD API — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      · 3 min read

      VMGD API

      An unofficial API for Vanuatu Meteorology Services.
+

      I wanted weather data for Vanuatu but I wanted the data in a machine readable format which is not provided by the Vanuatu Meteorology Services. I was using DarkSky but they were bought out by Apple and my API key stopped working so I decided, obviously, to make my own weather API. Of course, I know it won’t ever be as easy as I think it should be but I know this ahead of time and just enjoy the adventure.

      I do wish to write a nice frontend for it one day.

      Homepage UI

      Legally, I believe I must say my copy of the data from the VMGD is only for my personal use, and anyone wishing to use VMGD data should use the official website as my API is not intended for use by anyone for anything.

      Tech Stack

      The app is written in Python. The API is built with FastAPI and uses the underlying Starlette framework for the simple frontend. The web scraper is built using Anyio, HTTPX, Cerberus and BeautifulSoup. Data is stored on local disk with SQLite.

      API

      API Swagger UI

      The API provides endpoints for querying forecasts, media releases and weather warnings and I’ve tried to include many query parameter options with defaults returning the latest available data. Plus, I included an endpoint to query the raw, unprocessed data scraped from the HTML pages so you can do what what you want with that or verify my aggregated data results. Lastly, I included an endpoint to track the scraping sessions themselves so I can alert when a particular session is failing.

      For demonstration, a weather warning query such as the following…

      http://localhost:8000/v1/warnings?date=2023-08-03&name=warning_marine
      +

      …returns a weather warning object.

      {
      +  "meta": {
      +    "issued": "2023-08-02T23:14:00+00:00",
      +    "fetched": "2023-08-02T23:45:11.701411+00:00",
      +    "attribution": "The data provided was collected on the `fetched` date provided from the Vanuatu Meteorology & Geo-Hazards Department website at https://vmgd.gov.vu/. This service should not be used by anyone for anything; always get up-to-date and accurate data from the VMGD website directly."
      +  },
      +  "data": [
      +    {
      +      "date": "2023-08-02T13:00:00+00:00",
      +      "name": "warning_marine",
      +      "body": "Marine Strong Wind Warning for all Vanuatu open waters!! SE winds 26/30 knots with very rough seas to 3.0 meters over The Channel & Southern waters while SE winds of 21/25 knots with rough seas to 2.5 meters is expected elsewhere. Moderate to Heavy swells expected."
      +    }
      +  ]
      +}

      The meta object contains both issued and fetched dates, the former for the date the VMGD published the data and the latter for the date the data was scraped. Using these values I figured a user could find both the most recently available data and check that it is up-to-date for their use case or search for historical data using the fetched date.

      • python
      • fastapi
      • web-scraping
      Share:
      FYI: This project has the following related posts.
      \ No newline at end of file diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..6f27bb6 --- /dev/null +++ b/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: \ No newline at end of file diff --git a/sitemap-0.xml b/sitemap-0.xml new file mode 100644 index 0000000..97725b8 --- /dev/null +++ b/sitemap-0.xml @@ -0,0 +1 @@ +https://michaeltoohig.com/https://michaeltoohig.com/abouthttps://michaeltoohig.com/bloghttps://michaeltoohig.com/blog/2https://michaeltoohig.com/blog/3https://michaeltoohig.com/blog/4https://michaeltoohig.com/blog/astro-is-not-maturehttps://michaeltoohig.com/blog/astro-rehype-pretty-codehttps://michaeltoohig.com/blog/design-of-a-web-scraperhttps://michaeltoohig.com/blog/directus-extensionshttps://michaeltoohig.com/blog/directus-insights-are-not-maturehttps://michaeltoohig.com/blog/fastapi-is-not-maturehttps://michaeltoohig.com/blog/local-first-pwahttps://michaeltoohig.com/blog/migrating-to-astrohttps://michaeltoohig.com/blog/open-graph-facebookhttps://michaeltoohig.com/blog/pwa-outboxhttps://michaeltoohig.com/blog/scraping-pdfs-with-opencvhttps://michaeltoohig.com/blog/thumborhttps://michaeltoohig.com/blog/tus-and-uppyhttps://michaeltoohig.com/blurbshttps://michaeltoohig.com/blurbs/2https://michaeltoohig.com/blurbs/awesome-vue3-starterhttps://michaeltoohig.com/blurbs/borgmatic-dockerhttps://michaeltoohig.com/blurbs/category/fyihttps://michaeltoohig.com/blurbs/category/tilhttps://michaeltoohig.com/blurbs/comprehensive-python-cheetsheethttps://michaeltoohig.com/blurbs/draft-magical-wifihttps://michaeltoohig.com/blurbs/force-quit-python-dpbhttps://michaeltoohig.com/blurbs/github-markdown-emoji-listhttps://michaeltoohig.com/blurbs/importing-system-site-packageshttps://michaeltoohig.com/blurbs/neovim-copy-paste-on-wslhttps://michaeltoohig.com/blurbs/one-way-backups-with-syncthinghttps://michaeltoohig.com/blurbs/patterns-for-personal-websiteshttps://michaeltoohig.com/blurbs/quickref-for-neovimhttps://michaeltoohig.com/blurbs/relative-datetimes-with-directus-nowhttps://michaeltoohig.com/blurbs/shooting-star-2024-02-28https://michaeltoohig.com/blurbs/the-sound-preceding-earthquakeshttps://michaeltoohig.com/landing/mobile-apphttps://michaeltoohig.com/landing/saashttps://michaeltoohig.com/landing/startuphttps://michaeltoohig.com/linkshttps://michaeltoohig.com/privacyhttps://michaeltoohig.com/project-ideashttps://michaeltoohig.com/projectshttps://michaeltoohig.com/projects/1nomohttps://michaeltoohig.com/projects/2https://michaeltoohig.com/projects/3https://michaeltoohig.com/projects/bilolokhttps://michaeltoohig.com/projects/integrating-a-new-smshttps://michaeltoohig.com/projects/pdf-hyperlinkshttps://michaeltoohig.com/projects/remote-video-monitoringhttps://michaeltoohig.com/projects/sabdivisenhttps://michaeltoohig.com/projects/ssc-container-trackerhttps://michaeltoohig.com/projects/ssc-drivershttps://michaeltoohig.com/projects/vanuatu-energy-dashboardhttps://michaeltoohig.com/projects/vmgd-apihttps://michaeltoohig.com/terms \ No newline at end of file diff --git a/sitemap-index.xml b/sitemap-index.xml new file mode 100644 index 0000000..7cd9247 --- /dev/null +++ b/sitemap-index.xml @@ -0,0 +1 @@ +https://michaeltoohig.com/sitemap-0.xml \ No newline at end of file diff --git a/sunset-favicon.gif b/sunset-favicon.gif new file mode 100755 index 0000000..853052e Binary files /dev/null and b/sunset-favicon.gif differ diff --git a/terms/index.html b/terms/index.html new file mode 100644 index 0000000..e89d260 --- /dev/null +++ b/terms/index.html @@ -0,0 +1 @@ +Terms and Conditions — Michael Toohig
      Just Arrived in the USA! Check my resume here.

      Terms and Conditions

      Last updated: January 06, 2023

      Please read these terms and conditions carefully before using Our Service.

      Interpretation and Definitions

      Interpretation

      The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.

      Definitions

      For the purposes of these Terms and Conditions:

      • Affiliate means an entity that controls, is controlled by or is under common control with a party, where “control” means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.

      • Country refers to: California, United States

      • Company (referred to as either “the Company”, “We”, “Us” or “Our” in this Agreement) refers to AstroWind LLC, 1 Cupertino, CA 95014.

      • Device means any device that can access the Service such as a computer, a cellphone or a digital tablet.

      • Service refers to the Website.

      • Terms and Conditions (also referred as “Terms”) mean these Terms and Conditions that form the entire agreement between You and the Company regarding the use of the Service. This Terms and Conditions agreement is a Demo.

      • Third-party Social Media Service means any services or content (including data, information, products or services) provided by a third-party that may be displayed, included or made available by the Service.

      • Website refers to AstroWind, accessible from https://astrowind.vercel.app

      • You means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.

      Acknowledgment

      These are the Terms and Conditions governing the use of this Service and the agreement that operates between You and the Company. These Terms and Conditions set out the rights and obligations of all users regarding the use of the Service.

      Your access to and use of the Service is conditioned on Your acceptance of and compliance with these Terms and Conditions. These Terms and Conditions apply to all visitors, users and others who access or use the Service.

      By accessing or using the Service You agree to be bound by these Terms and Conditions. If You disagree with any part of these Terms and Conditions then You may not access the Service.

      You represent that you are over the age of 18. The Company does not permit those under 18 to use the Service.

      Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Service.

      Our Service may contain links to third-party web sites or services that are not owned or controlled by the Company.

      The Company has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that the Company shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such content, goods or services available on or through any such web sites or services.

      We strongly advise You to read the terms and conditions and privacy policies of any third-party web sites or services that You visit.

      Termination

      We may terminate or suspend Your access immediately, without prior notice or liability, for any reason whatsoever, including without limitation if You breach these Terms and Conditions.

      Upon termination, Your right to use the Service will cease immediately.

      Limitation of Liability

      Notwithstanding any damages that You might incur, the entire liability of the Company and any of its suppliers under any provision of this Terms and Your exclusive remedy for all of the foregoing shall be limited to the amount actually paid by You through the Service or 100 USD if You haven’t purchased anything through the Service.

      To the maximum extent permitted by applicable law, in no event shall the Company or its suppliers be liable for any special, incidental, indirect, or consequential damages whatsoever (including, but not limited to, damages for loss of profits, loss of data or other information, for business interruption, for personal injury, loss of privacy arising out of or in any way related to the use of or inability to use the Service, third-party software and/or third-party hardware used with the Service, or otherwise in connection with any provision of this Terms), even if the Company or any supplier has been advised of the possibility of such damages and even if the remedy fails of its essential purpose.

      Some states do not allow the exclusion of implied warranties or limitation of liability for incidental or consequential damages, which means that some of the above limitations may not apply. In these states, each party’s liability will be limited to the greatest extent permitted by law.

      “AS IS” and “AS AVAILABLE” Disclaimer

      The Service is provided to You “AS IS” and “AS AVAILABLE” and with all faults and defects without warranty of any kind. To the maximum extent permitted under applicable law, the Company, on its own behalf and on behalf of its Affiliates and its and their respective licensors and service providers, expressly disclaims all warranties, whether express, implied, statutory or otherwise, with respect to the Service, including all implied warranties of merchantability, fitness for a particular purpose, title and non-infringement, and warranties that may arise out of course of dealing, course of performance, usage or trade practice. Without limitation to the foregoing, the Company provides no warranty or undertaking, and makes no representation of any kind that the Service will meet Your requirements, achieve any intended results, be compatible or work with any other software, applications, systems or services, operate without interruption, meet any performance or reliability standards or be error free or that any errors or defects can or will be corrected.

      Without limiting the foregoing, neither the Company nor any of the company’s provider makes any representation or warranty of any kind, express or implied: (i) as to the operation or availability of the Service, or the information, content, and materials or products included thereon; (ii) that the Service will be uninterrupted or error-free; (iii) as to the accuracy, reliability, or currency of any information or content provided through the Service; or (iv) that the Service, its servers, the content, or e-mails sent from or on behalf of the Company are free of viruses, scripts, trojan horses, worms, malware, timebombs or other harmful components.

      Some jurisdictions do not allow the exclusion of certain types of warranties or limitations on applicable statutory rights of a consumer, so some or all of the above exclusions and limitations may not apply to You. But in such a case the exclusions and limitations set forth in this section shall be applied to the greatest extent enforceable under applicable law.

      Governing Law

      The laws of the Country, excluding its conflicts of law rules, shall govern this Terms and Your use of the Service. Your use of the Application may also be subject to other local, state, national, or international laws.

      Disputes Resolution

      If You have any concern or dispute about the Service, You agree to first try to resolve the dispute informally by contacting the Company.

      For European Union (EU) Users

      If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which you are resident in.

      You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a “terrorist supporting” country, and (ii) You are not listed on any United States government list of prohibited or restricted parties.

      Severability and Waiver

      Severability

      If any provision of these Terms is held to be unenforceable or invalid, such provision will be changed and interpreted to accomplish the objectives of such provision to the greatest extent possible under applicable law and the remaining provisions will continue in full force and effect.

      Waiver

      Except as provided herein, the failure to exercise a right or to require performance of an obligation under these Terms shall not effect a party’s ability to exercise such right or require such performance at any time thereafter nor shall the waiver of a breach constitute a waiver of any subsequent breach.

      Translation Interpretation

      These Terms and Conditions may have been translated if We have made them available to You on our Service. You agree that the original English text shall prevail in the case of a dispute.

      Changes to These Terms and Conditions

      We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. If a revision is material We will make reasonable efforts to provide at least 30 days’ notice prior to any new terms taking effect. What constitutes a material change will be determined at Our sole discretion.

      By continuing to access or use Our Service after those revisions become effective, You agree to be bound by the revised terms. If You do not agree to the new terms, in whole or in part, please stop using the website and the Service.

      Contact Us

      If you have any questions about these Terms and Conditions, You can contact us:

      \ No newline at end of file