From dd0f1380d4ab9ff55991707837ac47e661d00abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 8 Nov 2025 12:47:16 +0000 Subject: [PATCH] Import CSV --- bun.lock | 27 +- package.json | 7 +- .../js/components/app-sidebar-header.tsx | 2 + .../js/components/sync-status-button.tsx | 6 +- .../transactions/import-step-account.tsx | 115 ++++++ .../transactions/import-step-mapping.tsx | 208 ++++++++++ .../transactions/import-step-preview.tsx | 144 +++++++ .../transactions/import-step-upload.tsx | 142 +++++++ .../import-transactions-button.tsx | 41 ++ .../import-transactions-drawer.tsx | 386 ++++++++++++++++++ resources/js/components/ui/drawer.tsx | 133 ++++++ resources/js/components/ui/radio-group.tsx | 43 ++ resources/js/lib/file-parser.ts | 369 +++++++++++++++++ resources/js/lib/indexeddb.ts | 133 ++++-- resources/js/services/transaction-sync.ts | 162 ++++++++ resources/js/types/import.ts | 51 +++ storage/local/movements-3112025.xls | Bin 0 -> 70144 bytes 17 files changed, 1928 insertions(+), 41 deletions(-) create mode 100644 resources/js/components/transactions/import-step-account.tsx create mode 100644 resources/js/components/transactions/import-step-mapping.tsx create mode 100644 resources/js/components/transactions/import-step-preview.tsx create mode 100644 resources/js/components/transactions/import-step-upload.tsx create mode 100644 resources/js/components/transactions/import-transactions-button.tsx create mode 100644 resources/js/components/transactions/import-transactions-drawer.tsx create mode 100644 resources/js/components/ui/drawer.tsx create mode 100644 resources/js/components/ui/radio-group.tsx create mode 100644 resources/js/lib/file-parser.ts create mode 100644 resources/js/services/transaction-sync.ts create mode 100644 resources/js/types/import.ts create mode 100644 storage/local/movements-3112025.xls diff --git a/bun.lock b/bun.lock index 9c737603..a00a70b0 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,8 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.2", @@ -40,7 +41,9 @@ "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", + "vaul": "^1.1.2", "vite": "^7.0.4", + "xlsx": "^0.18.5", }, "devDependencies": { "@eslint/js": "^9.19.0", @@ -269,6 +272,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], @@ -449,6 +454,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -501,6 +508,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001735", "", {}, "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w=="], + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -513,6 +522,8 @@ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -525,6 +536,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -633,6 +646,8 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -959,6 +974,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1031,6 +1048,8 @@ "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vite": ["vite@7.1.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ=="], "vite-plugin-full-reload": ["vite-plugin-full-reload@1.2.0", "", { "dependencies": { "picocolors": "^1.0.0", "picomatch": "^2.3.1" } }, "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA=="], @@ -1045,10 +1064,16 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], diff --git a/package.json b/package.json index a9c044c1..49ec204d 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.2", @@ -63,7 +64,9 @@ "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", - "vite": "^7.0.4" + "vaul": "^1.1.2", + "vite": "^7.0.4", + "xlsx": "^0.18.5" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index f8038af8..e2883a6a 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -1,6 +1,7 @@ import { Breadcrumbs } from '@/components/breadcrumbs'; import { EncryptionKeyButton } from '@/components/encryption-key-button'; import { SyncStatusButton } from '@/components/sync-status-button'; +import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; @@ -16,6 +17,7 @@ export function AppSidebarHeader({
+
diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx index eb15e8b3..f506a68d 100644 --- a/resources/js/components/sync-status-button.tsx +++ b/resources/js/components/sync-status-button.tsx @@ -50,9 +50,6 @@ export function SyncStatusButton() { }; const getVariant = () => { - if (syncStatus === 'error' || !isOnline) { - return 'destructive' as const; - } return 'ghost' as const; }; @@ -65,6 +62,7 @@ export function SyncStatusButton() { size="icon" onClick={sync} disabled={syncStatus === 'syncing' || !isOnline} + className={`${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`} > {getIcon()} @@ -73,7 +71,7 @@ export function SyncStatusButton() {

{getTooltipText()}

- + ); } diff --git a/resources/js/components/transactions/import-step-account.tsx b/resources/js/components/transactions/import-step-account.tsx new file mode 100644 index 00000000..6b1d62f5 --- /dev/null +++ b/resources/js/components/transactions/import-step-account.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import { accountSyncService } from '@/services/account-sync'; +import { type Account } from '@/types/account'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Button } from '@/components/ui/button'; +import { EncryptedText } from '@/components/encrypted-text'; +import { Building2 } from 'lucide-react'; + +interface ImportStepAccountProps { + selectedAccountId: number | null; + onAccountSelect: (accountId: number) => void; + onNext: () => void; +} + +export function ImportStepAccount({ + selectedAccountId, + onAccountSelect, + onNext, +}: ImportStepAccountProps) { + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadAccounts = async () => { + try { + const data = await accountSyncService.getAll(); + setAccounts(data); + } catch (error) { + console.error('Failed to load accounts:', error); + } finally { + setLoading(false); + } + }; + + loadAccounts(); + }, []); + + if (loading) { + return ( +
+

Loading accounts...

+
+ ); + } + + if (accounts.length === 0) { + return ( +
+

+ No accounts found. Please create an account first. +

+
+ ); + } + + return ( +
+ onAccountSelect(Number(value))} + > +
+ {accounts.map((account) => ( + + ))} +
+
+ +
+ +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-step-mapping.tsx b/resources/js/components/transactions/import-step-mapping.tsx new file mode 100644 index 00000000..25722da3 --- /dev/null +++ b/resources/js/components/transactions/import-step-mapping.tsx @@ -0,0 +1,208 @@ +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { DateFormat, type ColumnMapping, type ColumnOption, type ParsedRow } from '@/types/import'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { parseDate, parseAmount } from '@/lib/file-parser'; + +interface ImportStepMappingProps { + columnOptions: ColumnOption[]; + columnMapping: ColumnMapping; + dateFormat: DateFormat; + dateFormatDetected: boolean; + parsedData: ParsedRow[]; + currencyCode: string; + onMappingChange: (field: keyof ColumnMapping, value: string) => void; + onDateFormatChange: (format: DateFormat) => void; + onNext: () => void; + onBack: () => void; +} + +export function ImportStepMapping({ + columnOptions, + columnMapping, + dateFormat, + dateFormatDetected, + parsedData, + currencyCode, + onMappingChange, + onDateFormatChange, + onNext, + onBack, +}: ImportStepMappingProps) { + const isValid = + columnMapping.transaction_date && + columnMapping.description && + columnMapping.amount; + + const previewTransactions = parsedData.slice(0, 3).map((row) => { + const date = columnMapping.transaction_date + ? parseDate(row[columnMapping.transaction_date] as string | number, dateFormat) + : null; + const description = columnMapping.description + ? String(row[columnMapping.description] || '') + : ''; + const amount = columnMapping.amount + ? parseAmount(row[columnMapping.amount] as string | number) + : null; + + return { + date: date ? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Invalid date', + description: description || 'No description', + amount: amount !== null + ? new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + }).format(amount) + : 'Invalid amount', + }; + }); + + return ( +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {isValid && previewTransactions.length > 0 && ( +
+ +
+ {previewTransactions.map((transaction, index) => ( +
+
+ {transaction.date} + {transaction.description} +
+ {transaction.amount} +
+ ))} +
+
+ )} + + {!dateFormatDetected && ( +
+ + onDateFormatChange(value as DateFormat)} + > +
+ + +
+
+ + +
+
+ + +
+
+
+ )} +
+ +
+ + +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-step-preview.tsx b/resources/js/components/transactions/import-step-preview.tsx new file mode 100644 index 00000000..4706f348 --- /dev/null +++ b/resources/js/components/transactions/import-step-preview.tsx @@ -0,0 +1,144 @@ +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { type ParsedTransaction } from '@/types/import'; +import { useMemo } from 'react'; + +interface ImportStepPreviewProps { + transactions: ParsedTransaction[]; + currencyCode: string; + onConfirm: () => void; + onBack: () => void; + isImporting: boolean; +} + +export function ImportStepPreview({ + transactions, + currencyCode, + onConfirm, + onBack, + isImporting, +}: ImportStepPreviewProps) { + const stats = useMemo(() => { + const newCount = transactions.filter((t) => !t.isDuplicate).length; + const duplicateCount = transactions.filter((t) => t.isDuplicate).length; + return { newCount, duplicateCount, total: transactions.length }; + }, [transactions]); + + const formatAmount = (amount: number): string => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + }).format(amount); + }; + + const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( +
+
+
+

Total

+

{stats.total}

+
+
+

New

+

+ {stats.newCount} +

+
+
+

Duplicates

+

+ {stats.duplicateCount} +

+
+
+ + {stats.newCount === 0 && stats.duplicateCount > 0 && ( +
+

+ All transactions appear to be duplicates. No new transactions will be imported. +

+
+ )} + +
+ + + + Date + Description + Amount + Status + + + + {transactions.length === 0 ? ( + + + No valid transactions found + + + ) : ( + transactions.map((transaction, index) => ( + + + {formatDate(transaction.transaction_date)} + + + {transaction.description} + + + {formatAmount(transaction.amount)} + + + {transaction.isDuplicate ? ( + Duplicate + ) : ( + New + )} + + + )) + )} + +
+
+ +
+ + +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-step-upload.tsx b/resources/js/components/transactions/import-step-upload.tsx new file mode 100644 index 00000000..3ee3f780 --- /dev/null +++ b/resources/js/components/transactions/import-step-upload.tsx @@ -0,0 +1,142 @@ +import { useCallback, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Upload, FileSpreadsheet, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ImportStepUploadProps { + file: File | null; + onFileSelect: (file: File) => void; + onNext: () => void; + onBack: () => void; +} + +export function ImportStepUpload({ + file, + onFileSelect, + onNext, + onBack, +}: ImportStepUploadProps) { + const [isDragging, setIsDragging] = useState(false); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const isValidFile = useCallback((file: File | null | undefined): boolean => { + if (!file || !file.name) { + return false; + } + const validExtensions = ['.csv', '.xls', '.xlsx']; + const lastDotIndex = file.name.lastIndexOf('.'); + if (lastDotIndex === -1) { + return false; + } + const extension = file.name.toLowerCase().slice(lastDotIndex); + return validExtensions.includes(extension); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile && isValidFile(droppedFile)) { + onFileSelect(droppedFile); + } + }, + [onFileSelect, isValidFile] + ); + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile && isValidFile(selectedFile)) { + onFileSelect(selectedFile); + } + }, + [onFileSelect, isValidFile] + ); + + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + + return ( +
+
+ {!file ? ( + <> + +

+ Drop your file here, or click to browse +

+

+ Supports CSV, XLS, and XLSX files +

+ + + ) : ( +
+ +
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+ +
+ )} +
+ +
+ + +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-transactions-button.tsx b/resources/js/components/transactions/import-transactions-button.tsx new file mode 100644 index 00000000..f1941bb4 --- /dev/null +++ b/resources/js/components/transactions/import-transactions-button.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Upload } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { ImportTransactionsDrawer } from './import-transactions-drawer'; + +export function ImportTransactionsButton() { + const [drawerOpen, setDrawerOpen] = useState(false); + + return ( + <> + + + + + + Import transactions from CSV/Excel + + + + + + ); +} + diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx new file mode 100644 index 00000000..b562536d --- /dev/null +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -0,0 +1,386 @@ +import { useState, useEffect } from 'react'; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerDescription, +} from '@/components/ui/drawer'; +import { ImportStepAccount } from './import-step-account'; +import { ImportStepUpload } from './import-step-upload'; +import { ImportStepMapping } from './import-step-mapping'; +import { ImportStepPreview } from './import-step-preview'; +import { + ImportStep, + DateFormat, + type ImportState, + type ColumnMapping, +} from '@/types/import'; +import { type Account } from '@/types/account'; +import { + parseFile, + autoDetectColumns, + convertRowsToTransactions, +} from '@/lib/file-parser'; +import { transactionSyncService } from '@/services/transaction-sync'; +import { accountSyncService } from '@/services/account-sync'; +import { useSyncContext } from '@/contexts/sync-context'; +import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import AlertError from '@/components/alert-error'; + +interface ImportTransactionsDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ImportTransactionsDrawer({ + open, + onOpenChange, +}: ImportTransactionsDrawerProps) { + const { sync } = useSyncContext(); + const { isKeySet } = useEncryptionKey(); + const [isImporting, setIsImporting] = useState(false); + const [error, setError] = useState(null); + const [selectedAccount, setSelectedAccount] = useState(null); + const [state, setState] = useState({ + step: ImportStep.SelectAccount, + selectedAccountId: null, + file: null, + parsedData: [], + columnHeaders: [], + columnOptions: [], + columnMapping: { + transaction_date: null, + description: null, + amount: null, + }, + dateFormat: DateFormat.YearMonthDay, + dateFormatDetected: false, + transactions: [], + }); + + useEffect(() => { + if (state.selectedAccountId) { + accountSyncService.getById(state.selectedAccountId).then((account) => { + if (account) { + setSelectedAccount(account); + } + }); + } + }, [state.selectedAccountId]); + + useEffect(() => { + if (!open) { + setState({ + step: ImportStep.SelectAccount, + selectedAccountId: null, + file: null, + parsedData: [], + columnHeaders: [], + columnOptions: [], + columnMapping: { + transaction_date: null, + description: null, + amount: null, + }, + dateFormat: DateFormat.YearMonthDay, + dateFormatDetected: false, + transactions: [], + }); + setIsImporting(false); + setError(null); + setSelectedAccount(null); + } + }, [open]); + + const handleAccountSelect = (accountId: number) => { + setState((prev) => ({ ...prev, selectedAccountId: accountId })); + }; + + const handleFileSelect = async (file: File) => { + if (!file) { + setState((prev) => ({ + ...prev, + file: null, + parsedData: [], + columnHeaders: [], + columnOptions: [], + })); + return; + } + + try { + const { headers, data, columns, headerRowIndex } = await parseFile(file); + const autoMapping = autoDetectColumns(headers); + + const columnOptions = headers.map((header, index) => { + const columnData = columns[index] || []; + const middleIndex = Math.floor(columnData.length / 2); + const examples = columnData + .slice(Math.max(headerRowIndex + 1, middleIndex), Math.max(headerRowIndex + 1, middleIndex) + 3) + .filter(cell => cell !== null && cell !== undefined && String(cell).trim() !== '') + .map(cell => String(cell)) + .slice(0, 3); + + return { + value: header, + label: header, + examples, + }; + }); + + let detectedFormat = DateFormat.YearMonthDay; + let formatDetected = false; + if (autoMapping.transaction_date) { + const { autoDetectDateFormat } = await import('@/lib/file-parser'); + const detected = autoDetectDateFormat(data, autoMapping.transaction_date); + if (detected) { + detectedFormat = detected; + formatDetected = true; + } + } + + setState((prev) => ({ + ...prev, + file, + parsedData: data, + columnHeaders: headers, + columnOptions, + columnMapping: autoMapping, + dateFormat: detectedFormat, + dateFormatDetected: formatDetected, + })); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to parse file' + ); + } + }; + + const handleMappingChange = ( + field: keyof ColumnMapping, + value: string + ) => { + setState((prev) => ({ + ...prev, + columnMapping: { + ...prev.columnMapping, + [field]: value, + }, + })); + }; + + const handleDateFormatChange = (format: DateFormat) => { + setState((prev) => ({ ...prev, dateFormat: format })); + }; + + const handlePreviewTransactions = async () => { + try { + const parsedTransactions = convertRowsToTransactions( + state.parsedData, + state.columnMapping, + state.dateFormat + ); + + const account = await accountSyncService.getById( + state.selectedAccountId! + ); + + if (!account) { + setError('Selected account not found'); + return; + } + + const transactionsWithDuplicateCheck = await Promise.all( + parsedTransactions.map(async (transaction) => { + const isDuplicate = await transactionSyncService.isDuplicate( + account.id, + transaction.transaction_date, + transaction.amount, + transaction.description + ); + + return { + ...transaction, + isDuplicate, + }; + }) + ); + + setState((prev) => ({ + ...prev, + transactions: transactionsWithDuplicateCheck, + step: ImportStep.Preview, + })); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to process transactions' + ); + } + }; + + const handleConfirmImport = async () => { + if (!isKeySet) { + setError('Please unlock your encryption key first'); + return; + } + + setIsImporting(true); + setError(null); + + try { + if (!selectedAccount) { + throw new Error('Selected account not found'); + } + + const newTransactions = state.transactions.filter( + (t) => !t.isDuplicate + ); + + const transactionsToImport = await Promise.all( + newTransactions.map(async (transaction) => { + const { encrypted, iv } = + await transactionSyncService.encryptDescription( + transaction.description + ); + + return { + user_id: (selectedAccount as Account & { user_id?: number }).user_id || 0, + account_id: selectedAccount.id, + category_id: null, + description: encrypted, + description_iv: iv, + transaction_date: transaction.transaction_date, + amount: transaction.amount.toString(), + currency_code: selectedAccount.currency_code, + notes: null, + notes_iv: null, + }; + }) + ); + + await transactionSyncService.createMany(transactionsToImport); + + sync(); + + onOpenChange(false); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to import transactions' + ); + } finally { + setIsImporting(false); + } + }; + + const moveToStep = (step: ImportStep) => { + setState((prev) => ({ ...prev, step })); + }; + + const getStepInfo = () => { + switch (state.step) { + case ImportStep.SelectAccount: + return { + title: 'Select Account', + description: 'Choose the account where transactions will be imported', + }; + case ImportStep.UploadFile: + return { + title: 'Upload File', + description: 'Drop your CSV or Excel file here, or click to browse', + }; + case ImportStep.MapColumns: + return { + title: 'Map Columns', + description: 'Match your file columns to transaction fields', + }; + case ImportStep.Preview: + return { + title: 'Preview Transactions', + description: 'Review transactions before importing', + }; + default: + return { + title: 'Import Transactions', + description: 'Import transactions from CSV or Excel files', + }; + } + }; + + const renderStep = () => { + switch (state.step) { + case ImportStep.SelectAccount: + return ( + moveToStep(ImportStep.UploadFile)} + /> + ); + case ImportStep.UploadFile: + return ( + moveToStep(ImportStep.MapColumns)} + onBack={() => moveToStep(ImportStep.SelectAccount)} + /> + ); + case ImportStep.MapColumns: + return ( + moveToStep(ImportStep.UploadFile)} + /> + ); + case ImportStep.Preview: + return ( + moveToStep(ImportStep.MapColumns)} + isImporting={isImporting} + /> + ); + default: + return null; + } + }; + + const stepInfo = getStepInfo(); + + return ( + + +
+ + {stepInfo.title} + {stepInfo.description} + + {error && ( +
+ +
+ )} +
{renderStep()}
+
+
+
+ ); +} + diff --git a/resources/js/components/ui/drawer.tsx b/resources/js/components/ui/drawer.tsx new file mode 100644 index 00000000..580c3e37 --- /dev/null +++ b/resources/js/components/ui/drawer.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/resources/js/components/ui/radio-group.tsx b/resources/js/components/ui/radio-group.tsx new file mode 100644 index 00000000..dc682eb9 --- /dev/null +++ b/resources/js/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/resources/js/lib/file-parser.ts b/resources/js/lib/file-parser.ts new file mode 100644 index 00000000..c3c67356 --- /dev/null +++ b/resources/js/lib/file-parser.ts @@ -0,0 +1,369 @@ +import * as XLSX from 'xlsx'; +import { DateFormat, type ParsedRow, type ColumnMapping, type ParsedTransaction } from '@/types/import'; + +function detectHeaderRow(columns: unknown[][]): number { + if (!columns || columns.length === 0) { + return 0; + } + + const firstRowWithValue = columns.map((column) => + column.findIndex((cell) => cell !== undefined && cell !== null && String(cell).length > 1) + ); + + const percentages = [0.95, 0.75]; + + for (const minPercentage of percentages) { + const uniqueRows = [...new Set(firstRowWithValue)].sort((a, b) => a - b); + + for (const rowNumber of uniqueRows) { + if (rowNumber === -1) continue; + + const columnsWithValues = columns.filter((column) => { + return column[rowNumber] !== undefined && + column[rowNumber] !== null && + String(column[rowNumber]).length > 1; + }).length; + + if (columnsWithValues / columns.length >= minPercentage) { + return rowNumber; + } + } + } + + return 0; +} + +export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number }> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result; + if (!data) { + reject(new Error('Failed to read file')); + return; + } + + const workbook = XLSX.read(data, { type: 'binary' }); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as unknown[][]; + + if (jsonData.length === 0) { + reject(new Error('File is empty')); + return; + } + + const maxColumns = Math.max(...jsonData.map(row => Array.isArray(row) ? row.length : 0)); + const columns: unknown[][] = []; + + for (let colIndex = 0; colIndex < maxColumns; colIndex++) { + const columnData = jsonData.map(row => + Array.isArray(row) ? row[colIndex] : undefined + ); + columns.push(columnData); + } + + const headerRowIndex = detectHeaderRow(columns); + + const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; + + const headers = columns.map((column, index) => { + const headerValue = column[headerRowIndex]; + const headerStr = String(headerValue || '').trim(); + + if (headerStr && headerStr.length > 1 && isNaN(Number(headerStr))) { + return headerStr; + } + + return letters[index] || `Column ${index + 1}`; + }); + + const dataRows = jsonData.slice(headerRowIndex + 1) as unknown[][]; + + const parsedData: ParsedRow[] = dataRows + .filter(row => Array.isArray(row) && row.some(cell => cell !== null && cell !== undefined && cell !== '')) + .map(row => { + const obj: ParsedRow = {}; + headers.forEach((header, index) => { + if (header) { + const value = row[index]; + obj[header] = (value === null || value === undefined) ? null : value as string | number; + } + }); + return obj; + }); + + resolve({ headers, data: parsedData, columns, headerRowIndex }); + } catch { + reject(new Error('Failed to parse file')); + } + }; + + reader.onerror = () => { + reject(new Error('Failed to read file')); + }; + + reader.readAsBinaryString(file); + }); +} + +export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): DateFormat | null { + if (!data || data.length === 0 || !dateColumnName) { + return null; + } + + const formats = [DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear]; + const sampleSize = Math.min(10, data.length); + const scores: Record = { + [DateFormat.YearMonthDay]: 0, + [DateFormat.DayMonthYear]: 0, + [DateFormat.MonthDayYear]: 0, + }; + + for (let i = 0; i < sampleSize; i++) { + const dateValue = data[i][dateColumnName]; + if (!dateValue) continue; + + for (const format of formats) { + const parsedDate = parseDate(dateValue as string | number, format); + if (parsedDate) { + scores[format]++; + } + } + } + + const maxScore = Math.max(...Object.values(scores)); + + if (maxScore === 0) { + return null; + } + + const bestFormat = formats.find(format => scores[format] === maxScore); + + if (maxScore >= sampleSize * 0.8) { + return bestFormat || null; + } + + return null; +} + +export function autoDetectColumns(headers: string[]): ColumnMapping { + const mapping: ColumnMapping = { + transaction_date: null, + description: null, + amount: null, + }; + + if (!headers || headers.length === 0) { + return mapping; + } + + const lowerHeaders = headers.map(h => { + if (h === null || h === undefined) { + return ''; + } + return String(h).toLowerCase(); + }); + + const datePatterns = ['date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor']; + const descriptionPatterns = ['description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción']; + const amountPatterns = ['amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad']; + + for (let i = 0; i < lowerHeaders.length; i++) { + const header = lowerHeaders[i]; + const originalHeader = headers[i]; + + if (!header || typeof header !== 'string') { + continue; + } + + if (!mapping.transaction_date && datePatterns.some(p => header.includes(p))) { + mapping.transaction_date = originalHeader; + } + + if (!mapping.description && descriptionPatterns.some(p => header.includes(p))) { + mapping.description = originalHeader; + } + + if (!mapping.amount && amountPatterns.some(p => header.includes(p))) { + mapping.amount = originalHeader; + } + } + + return mapping; +} + +export function parseDate(dateStr: string | number, format: DateFormat): Date | null { + if (!dateStr) { + return null; + } + + if (typeof dateStr === 'number') { + const excelDate = XLSX.SSF.parse_date_code(dateStr); + if (excelDate) { + return new Date(excelDate.y, excelDate.m - 1, excelDate.d); + } + } + + let str = String(dateStr).trim(); + str = str.replace(/\//g, '-').replace(/\./g, '-').replace(/[^\d-]/g, ''); + + let year: number | undefined, month: number | undefined, day: number | undefined; + + if (str.length === 5) { + const dateRegex = /^(\d{1,2})-(\d{1,2})$/; + const dateArray = dateRegex.exec(str); + if (dateArray) { + month = Number(dateArray[format === DateFormat.DayMonthYear ? 2 : 1]); + day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]); + } + } else { + const parts = str.split('-').filter(p => p.length > 0); + + if (parts.length === 3) { + switch (format) { + case DateFormat.YearMonthDay: + [year, month, day] = parts.map(Number); + break; + case DateFormat.MonthDayYear: + [month, day, year] = parts.map(Number); + break; + case DateFormat.DayMonthYear: + [day, month, year] = parts.map(Number); + break; + } + } else if (parts.length === 2) { + month = Number(parts[format === DateFormat.DayMonthYear ? 1 : 0]); + day = Number(parts[format === DateFormat.DayMonthYear ? 0 : 1]); + } + } + + if (year === undefined) { + year = new Date().getFullYear(); + } + + if (year < 100) { + year += year < 50 ? 2000 : 1900; + } + + if (year === undefined || month === undefined || day === undefined) { + return null; + } + + const date = new Date(year, month - 1, day); + + if (isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return null; + } + + return date; +} + +export function parseAmount(amountStr: string | number): number | null { + if (typeof amountStr === 'number') { + return amountStr; + } + + if (!amountStr) { + return null; + } + + let str = String(amountStr).trim(); + + const dotPos = str.lastIndexOf('.'); + const commaPos = str.lastIndexOf(','); + + const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos : + commaPos > dotPos && commaPos !== -1 ? commaPos : + -1; + + if (decimalSep !== -1) { + const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, ''); + const decimalPart = str.substring(decimalSep + 1); + str = integerPart + '.' + decimalPart; + } else { + str = str.replace(/[^\d]/g, ''); + } + + const amount = parseFloat(str); + + if (isNaN(amount)) { + return null; + } + + return amount; +} + +export function validateTransaction( + row: ParsedRow, + mapping: ColumnMapping, + dateFormat: DateFormat +): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!mapping.transaction_date || !row[mapping.transaction_date]) { + errors.push('Missing transaction date'); + } else { + const date = parseDate(row[mapping.transaction_date] as string | number, dateFormat); + if (!date) { + errors.push('Invalid date format'); + } + } + + if (!mapping.description || !row[mapping.description]) { + errors.push('Missing description'); + } + + if (!mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined) { + errors.push('Missing amount'); + } else { + const amount = parseAmount(row[mapping.amount] as string | number); + if (amount === null) { + errors.push('Invalid amount format'); + } + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +export function convertRowsToTransactions( + rows: ParsedRow[], + mapping: ColumnMapping, + dateFormat: DateFormat +): ParsedTransaction[] { + const results: ParsedTransaction[] = []; + + for (const row of rows) { + const validation = validateTransaction(row, mapping, dateFormat); + + if (!validation.isValid) { + continue; + } + + const date = parseDate(row[mapping.transaction_date!] as string | number, dateFormat); + const amount = parseAmount(row[mapping.amount!] as string | number); + const description = String(row[mapping.description!] || '').trim(); + + if (!date || amount === null || !description) { + continue; + } + + const formattedDate = date.toISOString().split('T')[0]; + + results.push({ + transaction_date: formattedDate, + description, + amount, + validationErrors: [], + }); + } + + return results; +} + diff --git a/resources/js/lib/indexeddb.ts b/resources/js/lib/indexeddb.ts index 0a95a37b..1f574eee 100644 --- a/resources/js/lib/indexeddb.ts +++ b/resources/js/lib/indexeddb.ts @@ -1,5 +1,26 @@ const DB_NAME = 'whisper_money'; -const DB_VERSION = 1; +const DB_VERSION = 2; + +// Force database recreation if needed +export async function resetDatabase(): Promise { + return new Promise((resolve, reject) => { + const deleteRequest = indexedDB.deleteDatabase(DB_NAME); + + deleteRequest.onsuccess = () => { + console.log('Database deleted successfully'); + resolve(); + }; + + deleteRequest.onerror = () => { + reject(new Error('Failed to delete database')); + }; + + deleteRequest.onblocked = () => { + console.warn('Database deletion blocked. Please close all other tabs.'); + reject(new Error('Database deletion blocked')); + }; + }); +} export interface IndexedDBRecord { id: number; @@ -9,12 +30,17 @@ export interface IndexedDBRecord { [key: string]: any; } -export type StoreName = 'categories' | 'accounts' | 'banks'; +export type StoreName = 'categories' | 'accounts' | 'banks' | 'transactions'; class IndexedDBService { private db: IDBDatabase | null = null; private initPromise: Promise | null = null; + async checkStoreExists(storeName: StoreName): Promise { + const db = await this.init(); + return db.objectStoreNames.contains(storeName); + } + async init(): Promise { if (this.db) { return this.db; @@ -75,6 +101,21 @@ class IndexedDBService { }); } + if (!db.objectStoreNames.contains('transactions')) { + const transactionStore = db.createObjectStore('transactions', { + keyPath: 'id', + }); + transactionStore.createIndex('user_id', 'user_id', { + unique: false, + }); + transactionStore.createIndex('account_id', 'account_id', { + unique: false, + }); + transactionStore.createIndex('updated_at', 'updated_at', { + unique: false, + }); + } + if (!db.objectStoreNames.contains('sync_metadata')) { db.createObjectStore('sync_metadata', { keyPath: 'key' }); } @@ -102,17 +143,28 @@ class IndexedDBService { ): Promise { const db = await this.init(); return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly'); - const store = transaction.objectStore(storeName); - const request = store.getAll(); + try { + if (!db.objectStoreNames.contains(storeName)) { + console.warn(`Object store '${storeName}' not found. Returning empty array.`); + resolve([]); + return; + } - request.onsuccess = () => { - resolve(request.result as T[]); - }; + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.getAll(); - request.onerror = () => { - reject(new Error(`Failed to get all from ${storeName}`)); - }; + request.onsuccess = () => { + resolve(request.result as T[]); + }; + + request.onerror = () => { + reject(new Error(`Failed to get all from ${storeName}`)); + }; + } catch (error) { + console.error(`Error accessing ${storeName}:`, error); + resolve([]); + } }); } @@ -142,17 +194,26 @@ class IndexedDBService { ): Promise { const db = await this.init(); return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite'); - const store = transaction.objectStore(storeName); - const request = store.put(record); + try { + if (!db.objectStoreNames.contains(storeName)) { + reject(new Error(`Object store '${storeName}' not found. Please refresh the page.`)); + return; + } - request.onsuccess = () => { - resolve(); - }; + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.put(record); - request.onerror = () => { - reject(new Error(`Failed to put to ${storeName}`)); - }; + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to put to ${storeName}`)); + }; + } catch (error) { + reject(error); + } }); } @@ -269,22 +330,26 @@ class IndexedDBService { ): Promise { const db = await this.init(); return new Promise((resolve, reject) => { - const transaction = db.transaction('pending_changes', 'readwrite'); - const store = transaction.objectStore('pending_changes'); - const request = store.add({ - store: storeName, - operation, - data, - timestamp: new Date().toISOString(), - }); + try { + const transaction = db.transaction('pending_changes', 'readwrite'); + const store = transaction.objectStore('pending_changes'); + const request = store.add({ + store: storeName, + operation, + data, + timestamp: new Date().toISOString(), + }); - request.onsuccess = () => { - resolve(); - }; + request.onsuccess = () => { + resolve(); + }; - request.onerror = () => { - reject(new Error('Failed to add pending change')); - }; + request.onerror = () => { + reject(new Error('Failed to add pending change')); + }; + } catch (error) { + reject(error); + } }); } diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts new file mode 100644 index 00000000..74c9938d --- /dev/null +++ b/resources/js/services/transaction-sync.ts @@ -0,0 +1,162 @@ +import { SyncManager } from '@/lib/sync-manager'; +import { indexedDBService } from '@/lib/indexeddb'; +import { encrypt, importKey } from '@/lib/crypto'; +import { getStoredKey } from '@/lib/key-storage'; + +export interface Transaction { + id: number; + user_id: number; + account_id: number; + category_id: number | null; + description: string; + description_iv: string; + transaction_date: string; + amount: string; + currency_code: string; + notes: string | null; + notes_iv: string | null; + created_at: string; + updated_at: string; +} + +class TransactionSyncService { + private syncManager: SyncManager; + + constructor() { + this.syncManager = new SyncManager({ + storeName: 'transactions', + endpoint: '/api/sync/transactions', + }); + } + + async sync() { + return await this.syncManager.sync(); + } + + async getAll(): Promise { + return await this.syncManager.getAll(); + } + + async getById(id: number): Promise { + return await this.syncManager.getById(id); + } + + async getByAccountId(accountId: number): Promise { + try { + const allTransactions = await this.getAll(); + return allTransactions.filter(t => t.account_id === accountId); + } catch (error) { + console.warn('Failed to get transactions from IndexedDB:', error); + return []; + } + } + + async create(data: Omit): Promise { + return await this.syncManager.createLocal(data as Omit & { id?: number; created_at?: string; updated_at?: string }); + } + + async createMany(transactions: Omit[]): Promise { + try { + const timestamp = new Date().toISOString(); + const created: Transaction[] = []; + + for (const data of transactions) { + const tempId = Date.now() + created.length; + const record = { + ...data, + id: tempId, + created_at: timestamp, + updated_at: timestamp, + } as Transaction; + + await indexedDBService.put('transactions', record); + await indexedDBService.addPendingChange( + 'transactions', + 'create', + record, + ); + + created.push(record); + } + + return created; + } catch (error) { + console.error('Failed to create transactions in IndexedDB:', error); + throw new Error('Failed to save transactions locally. Please refresh the page and try again.'); + } + } + + async update(id: number, data: Partial): Promise { + await this.syncManager.updateLocal(id, data); + } + + async delete(id: number): Promise { + await this.syncManager.deleteLocal(id); + } + + async isDuplicate( + accountId: number, + transactionDate: string, + amount: number, + description: string + ): Promise { + try { + const existingTransactions = await this.getByAccountId(accountId); + + const keyString = getStoredKey(); + if (!keyString) { + return false; + } + + const key = await importKey(keyString); + + for (const existing of existingTransactions) { + if ( + existing.transaction_date === transactionDate && + parseFloat(existing.amount) === amount + ) { + try { + const { decrypt } = await import('@/lib/crypto'); + const decryptedExisting = await decrypt( + existing.description, + key, + existing.description_iv + ); + + if (decryptedExisting.toLowerCase() === description.toLowerCase()) { + return true; + } + } catch (error) { + console.error('Failed to decrypt description for duplicate check:', error); + } + } + } + + return false; + } catch (error) { + console.warn('Duplicate check failed, assuming no duplicates:', error); + return false; + } + } + + async encryptDescription(description: string): Promise<{ encrypted: string; iv: string }> { + const keyString = getStoredKey(); + if (!keyString) { + throw new Error('Encryption key not set'); + } + + const key = await importKey(keyString); + return await encrypt(description, key); + } + + async getLastSyncTime(): Promise { + return await this.syncManager.getLastSyncTime(); + } + + isSyncing(): boolean { + return this.syncManager.isSyncing(); + } +} + +export const transactionSyncService = new TransactionSyncService(); + diff --git a/resources/js/types/import.ts b/resources/js/types/import.ts new file mode 100644 index 00000000..f5c7b595 --- /dev/null +++ b/resources/js/types/import.ts @@ -0,0 +1,51 @@ +export enum ImportStep { + SelectAccount = 'select-account', + UploadFile = 'upload-file', + MapColumns = 'map-columns', + Preview = 'preview', +} + +export enum DateFormat { + YearMonthDay = 'YYYY-MM-DD', + MonthDayYear = 'MM-DD-YYYY', + DayMonthYear = 'DD-MM-YYYY', +} + +export interface ColumnMapping { + transaction_date: string | null; + description: string | null; + amount: string | null; +} + +export interface ParsedRow { + [key: string]: string | number | null; +} + +export interface ParsedTransaction { + transaction_date: string; + description: string; + amount: number; + isDuplicate?: boolean; + validationErrors?: string[]; +} + +export interface ColumnOption { + value: string; + label: string; + examples: (string | number)[]; +} + +export interface ImportState { + step: ImportStep; + selectedAccountId: number | null; + file: File | null; + parsedData: ParsedRow[]; + columnHeaders: string[]; + columnOptions: ColumnOption[]; + columnMapping: ColumnMapping; + dateFormat: DateFormat; + dateFormatDetected: boolean; + transactions: ParsedTransaction[]; +} + + diff --git a/storage/local/movements-3112025.xls b/storage/local/movements-3112025.xls new file mode 100644 index 0000000000000000000000000000000000000000..d97f005868039fd98a506248034566381894268a GIT binary patch literal 70144 zcmeFZ2UHYIw>CNo3W^C3P_iH(StNr5l^{6{Ip-u2B_{<0L_mchBM1x`hn#~F1td!r zkeorn5M~(Wufgv<=l#xi&RPF|*Ijq5d+%x1)6>1Xx@vcI)!w`I-c5fW@vl!Ml(Ufk zu!%zOAg9<%kc)q#pa0u2Kmc(1KimIHdO#iG2>#q4^bl#t zbMWf|@d9t~e}5x~TsZgFqyE$Q_;0ER#a+V<7O-i9)?Fa>5VJEr?Eet|5B4OG^Z(X9 zj*tJHTXB3kUHHSm1&9XZ1;hdTnVvnn9OwnBGoEl1H;64be*WK7dJnXZs|gR_f46;H zbqTl~*e*b%Knvz)JXoH+f&Vjpg#Ir)KaN+Sv)2F2T?}9xN5enk2XZ#&3BWNSz$Jjo z07L*+0ImXD1Go+VL;!>sfCPXP;3fbW0672!z%78=0F(e!0Mr090JH#f0Q3NN02lxm z0qz1Y0Wbrw0Nev$1z-bU2jBqU1mFVT2DlHv1MmQV7l03dA3y*=5CHd#!T=%wq5uy8 z!~nzrBmg7<9sxWCcmf~=APpb`APevmKn_42KmkAzKnXw@Km|Y*Kn*}0Kmz~@pb4M_ zpbek{pbMY}pbuaGU_tfW5`VML-9~*t0!us6zq)<*!{JI0?j^fP*hzzJQ-VNMa%ulE`ZXihhTD z2j#869~RIdR^a?Qs0fq?HJ;V^r@pfq&g#Im0&Y6H?>o33i%of}WK|Je5Uj0e+mI3C2#_9=jJ4a^!G1;{qACq3IE&aT$~DG9FFoV#|; zK%|ioH^ywwQ<6Kz2o*7yZ?US5=6uFxvPeyi>(!o<#bTVrT?rt+=YL? z>mLtEi%$uj7f2|Ey9|sRjEwhKr8|QX?*YRcd^m``z>tr|5RQKh)yt2{@&0Z&i|<`98-U5PD0|}8{`A>7f9TH zYw)kT|7!Cex1Kfo_xr9xvT)kN!h%~#iQ5Zozs{5d1cJnZ9{RWK3q6jG8%w1j*8hUe@n6u{{tNo0|CEkv z?Js@^@d@!E_?OP!`-L+)n0VC?NH~5tpkF?t<5bZ(oB;voe=kq)U(gB9=)hnB<ge8`PJJS^^)IPl`xFG(Q&*)M4z!PzfP@8YiC47`9fIa3ZHK*?lw z;`)pTqU7jq>tJj7+|AMTEdSan?$6S?rDh2GUi@?4*A4iBA8vU7cY~bgT(^Fkw{BSYiaLyAhfwO*YyGiS~X*gQB zd6>FbLLON-npx7zIha~os#}^`csX}k0#mSrR8Hy<)N^8E>gqd#MK2bVwh^`LT_W|) zy7%TpR|V|f?^?YtE=&GxdeySnv8yhwJmPBgW82{BbyX(Ggu*X^%6z*Y_-)J?-{uom zz2m#dCCTH%^j>N4ygQF?Sh4-vR;zJ(@)pAJM0Eum0dtcv&e>z2VlH(KZks**T`@y(J1lw&woM8#J2RHKn99Ymp z{}lfwnTzXq2;|*MRoqm9K?CLCeW{+O(N z#OW`;kO0nyv$b`vHN2f#O0a$6mJb`Sg2u{w6b{O1n2BrwhglQv4J@sE^i<8Wxd3!p zYRsw@QJ=W_*k>&4bhDv=2uoFwc`hA$rU(KLA}&Vy2? z{*y28SZO8#=0=wr50_W*mN%5KD;VQvrFvPvA&%PLT4!>yIiIkKz3@2j30rm=&yMY- zVGCHSZMKY|xemcdpXrkFJ~3@S;fCKU6|27*$N3Q+ZIzubTX$&3V+S##!~QAP{Z8s$ zXd8{@hP`B z4|WqVwJR^bO)KP{I>pH9UNr*V7?^C!}X1x`-tF+wQ(K_Oz>f z?|NSmdPE@(UT$T*xLx#7bH=Z2(1Z@vIz>9$6~)b^C~Jfc+CO>1J~X?X=Jpf0#L4km zIL3im(RRjsoX&nKla-UQ@T8$CDr>)=4$IeYbTsSMb>c^A?6X{VEhpe8dRryl*Rt-( zMab@-ZsRk*PqQxVGSaGj5ShKd(HncPQTUSx9;e`_w3<=D;@>c>3g@7lRN0(;FfPna zbSUPsfi2mj;?CXX$q!+xdmW(=ucEzy! zgg3V7>0Sm8_c z{h;HMC)PsMko3b#I0X>+?749z%ch@`v!0U{Pgt1vUFSqWCtsBhc?>pTOcmE2#&0^a zA+>F_b6wE0=Fy~+UdW%QZN_}VxnsJqdgg7`wIAQQrTU%3(R+5xS$%}hFWZ!;R)=UuRD{jt~`eZd;L>Aqc zC+|*eTU-!Un^3=%#kZD@7dU?{jAJNLuQ$K__gdn`7gy=D2OC z`aFi|8wpoF)7^hG{@};hpdm7sCR>rbQ-javwt|OYBO~Y)T|K@d1Me9Gw+j37iS1wb zfm?s9E|xX-^ar}Z?4bu$r{bB83S)wLM~4?uy|@-n*>z90)3AJE*l&G<>{w*{%<065 zwCs=sdLiGfQ@c|1>ACbhqM|@)pFDLxoW9A>-!{d)2j7@#VoQU_POzN z&GPP#DWn0~5Z=v0?9#9^;kUXSaPV<|9O2+nd|uMJp#}KsfctN~LK1FB9{U*9(>>X= zpk2_`cs#OMmF-Zl?eYZ_`}3AF60*`Ry_6Sny$?r^WV`*UIS~(KsrsyZXn?dN;DWeC zeZ-lUl02&6a;QWG@8lmBE|5v%mA0nm*4VgwlQ8J z9xHFl=dv*u@gNi3yT+i3`!UkjVOW>#s}~tMt_5JT_UGnRs0bl85-?l?_cb<;=9>45 zCXH?7uEQ=^!@+Bi&w){XX%dec+!x>)6JZC$(jk?i7m$b{_KnCBhdPCM2T zsdjYNIOK6Om5+Cjx9!}W7@ZVM8YBwo{+N3F{Mt-+MpM@>O*$otnUk!qkidg~aO~D$ zEi=SBqcib)lTlGuz@Sh$PrK#~6IZB}B-Oj|9P*CU?_Xn;Ke}$LtB`-hYIsx~l!$wt zesyb^-*yhflk_ABpV4*lSFb6*NxfX`KtK^bEZN`abaX!k1~PwOW6^ zPtgjZ=V3Qs)M&f@g z=*7ltB8`j>W(u<+1=chHb3z!6}B& zlHq<{RToq9YXZRqb45ng+tbNK9`bydO^%y8e(DTTwPtrGH)llDJqw$+M9U>6D_4uo zLpcAzvDvm%XT0foIqEP|WHGOf#mqn z8QCnVjK@rI%lVeIjrWMZOC80+yO>@jMp9VJG|n{6p5PR_$kDlU{xd0ZpZ5wG*W~o6 zv0|^R^wSCxrnpj(B6+oLH!`+iDbE{UAH_K_abMjq?=01W2_d0V7DamFLOP;1k~f~v zVV|RL!uJH!wg0DgO^-GM+DM>ko2m|G;*=fQL{OutNu6|(s7AFbw??ks3`*2v&OGDOn;Yzm}fud19R!moE(Cg&4eKy39iZa~SMjVoTk2(K?8 zo!+;3J%KgRD#Q3{eXxjm{;wA-AC9b|<-itd~}@ToxTv`DE_#tz!;r94#P zhhFd6dx4t|sXafB#Wp5+HwP)dDQ0<`<{mXPqS+RhJ=aM2-Kfar2+acbLFleSA%RP0 z@+>_KSZmqgw9G#4N&!;}IhXLV*O3FcxjN%w>c>6<_>B|=ns%H@m5${vqKERo0NaIM z`69$-x^QR5ITOqU2*wUs_^jzjR^y1D|8a~C%D&~pfy45vGIh#neGHly+A7ehZc1=K ztN(zbk=*a8-W|%mv`Qhk7anVaPdW-Yx5F5i@18nbrY9<({fzy!0<3k{RyW=Py-C%p zf|EZn=OL6HA8^wSlF!AfInuQjA#ZlOSdTfb4rfV(8*yiLyo!Hdd_1K4 zRa-?+Y~cit@<4f`UM|Tn8x{9AcL@pKN%t;>&3A5*H$CEaqf7D#t zzx}*}f+9Q9@$*wh8lxotgQ1$$o}h?973>xTyV0Rx(|!!jx^cu}e;kVrZD_b>kqK@J zzWbuce5Q)uFa+mn5j)!y`bATHl7t$RXQLZ7VuKD;&rbBjF0WCi@nIvocU_L1Px#H7e%H&JlKV>6v~nX2`sznt0bs{`nMlL+`h=}1YH*0NuNqK zMOwnlNC(cyy02(!x>~w_G`~}lmLn=&CfbAjB|m$)A2Dr;b5I6WZPMnQAnnLFkfK>)dh@sITxF) z`RFDqS?Aj|dJGG7v-{>)Y=1{nvNAR@I#2H$D*UWRQMwJE zUdTJI<|P)5o50m`Ne%@Ko046%$m8`zr$01XB2oui%ybcSbf~HQmUxLNlbGX?y5AFX zH3kKGOwFU69Y=q+swVR!CC>D{q=fxlXr958lOkVvbRZMTtCeM_AcUjDpr4G9w@L*5 zK_&=hhrX+jOOl~}FaL^}@y&Rnh~2n5qFEh3eR?L`oMw`mg^>k@c`}{e97dIS-osst zA=^^{Q#;1V|1gK}+5g_wrFGh#>BGC{j>V&v~50nM)wHnn}l7V8h0f9^t9yB(GQG1cAk!?&MY;Xhehss%{*@=shHdZ zQ`}*ljl&K}$I0heR8&h|hsDpnA5NBQBqlMb9&K^ucHJ;{zIWDt#J%>U)(ro^ms;Xs z<{kU+Q(Cn)7i8~&U`8}sB8%*sXhARa>i6(=Hcg+_oSn?`ET=|gWyoQH5)6SwFaGsEGd`3@;Gyy~;cauMnSX*NOM%rc$cxLdV4 zr(z=8<3{v|!1Q*G(JM0gjx#mf@2E$d8=UYiQk-_v;4*teu1wxdcf}E#^aq;w>^rRJ zyiZDuWcPJ{pro)CRdnLc5*C{|>aKKY4?c>c0-h_U9nagR2#GOM4mTYg4Xw7ZELk@y z;RiaWNVR=S3_^xveEOJ<&<$$xamC4~u>r~HKRPhY;wsc^mtRrv6WwTpCFt=&sV^lj zY*6rUy@p5_r9-GU>bnL1)B_XZXKkg%MW59ZhY0p>o(E<%-W$L>-ZE62+F>*~b!xdearlInU}K?qZfsGTGlH$<*rXI=U#DA|tpi>p47}`d|b>FF)S(dxvTHgAGP-Wo#it<`S9e-IPS< zGt8L8S5McbdmpAvF%B8CusxgrZI(ER2QLzs&mR+7b3CM0 zoDP~!J^T*qqZ)e=hlyIm#WXc?+hV{dzTiXw+?*9ox? zgX`uM2-0q|;mdcD$hujEFDqvdd)7Tp%@ZNcRTKyq7$~Y=dj9`4G^hA5@3qQjoRS3e-0RRz6#_($YSTRK?y%~rH!FIz^wW-}cajQxDe)-m^y zS`HqpR3~Zmgq@preCi2owC9SOVa)H&~w%Xe&dBk*_ixZ^n0udS{_tn1$Epen{>GE2uRgyxl(` z1~2y`pPpppUi~Xmu0!FHJcAWW|W?sds zzCP$tmi%pqcfy%I*xgvnnMqsqCX)Xo&leD2p&_3fFF38TxQTwKpaRp1u**2w4?u*Q zv;})?huOh!tIesZ2ouKMN%x~Ahc?yX3Fii@<^4lvoN-7&7cg<{qW8GmYtVrZ7dAG6 zQh~y0@UE9q+f-mx@*L|L3LHc~*vgj65fjW2gqdb}WrR5sC2#e{$Gan9h8kAJf)9OC zd=1Hm*<_9CM8#@eyjYqvz>OMMzFdZ}3VuP6uNdLt<$_^G7Ju&E8I>ZH)vs48w&#K5 zP}$e6SyLg{v`MGvp`@b|ybM@)CyR3+U=~N?JvMg`V_F-->OHkDN0cYpgP+A^j~0=W z_EQl=Bj|O@%Wr*_`bIJ=;%uw=tJGV|ks=i!z)I058#{va?{E5ct;mu+OO+CWeb zD_rc8E8GG&I*Ff|f(q_hc_VTcNRZD(a&5nPShyD6|yzUDZ0;9XmQoHJ8a* zs=j*jlrq;D#u&o$(>}|lD@gO$N2CbzSvpauI;zqMHlWbCitaL-+UW)O3Q9+rFx4OT3v|P!6fj| zxVa4$19KBm@KTBrNdx997jiS`eM`BI9hctWWWqA=XJYLXRTnO)S#lZhWwlyzQ1k|jPR}?2kWU% zXKAzLamivwoW&KJIesoS=T{~dR)}%hdWE#i%I`CELMzj$SOO)XLXw-^Y^mEIHePG2 z_lVufqg|cHEj;AsVN~>t@Pu`pg-OyjxBHL5*i1*?2NaXHV{(Z<$NC7C=bvXxYOfN3 zvZ_3;VpC15Dz1*)Iq9P=G#ri*4A?t)%wno?n+x+(;(#|mZ8af6T*~>GM4c;&X(gko z>t#b*S8TN+S%)j5!cg0TK<9BVQ<74VsdcZ&(mQa9jy_Z##pyidmRdynsHMX{T% z$f|IgF>&Pvr=g51ZTS_&J&XoTC*NX6s6V^lBqeF?&gv1N&gYMlb(6PAyRJXC=>aZQ z*%lxDq3T8on$uG`(r#tjyVLA*c$Y&+IX3f-rd+;+Bh)!Q*lSIGd zyEaa9$j}`EpF3b<60o5gP5J8Q8d9cohicNhjx|P5o>Yf9X^&C7eQ9VKb3D;@6Mo%< zgWIrB(|Bs~jzZoCOKlPI(znQO0wRYtO$q&5l}A}Vd?}x0p+SW+F@sBsJ(`6aHX8aF zHg_ewnmyE39iEECd~r^ep&D#TDyz!N*Yw^yY?&=HQdZFVoXB|q?+ElYJEIvAVP4yCUMp+V}z?{9&f1D zdBdQW?K4UaUcD_SQhIjS+fIk=*J+GuBRt%yS?gBr`uHHOw`sSu+`7Y4Pw?HKpM5k^ zF}!}*xXbPSV6xLUCX>tri zGl@4)@+LDHdNu$K8`|Iw!Kf2VpFccrQZ~W)V0Ug|ZA3G2rtc-QauK2QoW}mZ7k`yi zzA%+u6~oe8^~Lsj0joZZmLzS3o+ZY6w~Y+EY>N|O#a)Y!43ajxNnm(hdtCvTspd3e zC$ochKzRDiZR6a&etVU=WC+qnrql>$-zu}tI^_sS?vFDI%S8R>fT*xy`+B)w4Nl9t z)9C`tKjpWs&{KoS&OrFhq)Xj4xRp^+tt06pDMeH~Rc;5%h#3g*kxxP^5}$5i+Qci zXed2Ic=|ii)I4>hOV{AQUihMD#d<$|^D=_xNy%N!*)+smz=?*i$+*TCoq0%iwtD~Y zH6&lcZ{DzWYQK5E7cCeaoG6t)yJo*%aLs^=O|tH*`G0LkHxU=G-AT?CJHLV(N}U zY}mZ8>EBv=Bi|kI!uuGZ9QGzn2gQCcnncq!Er@(Wzf{U)RR{b=L(09h9>ZzZvZD?C z42`)7w4R$ZIUo()h5D~i4nMIryx(&;KZTxS!yXcO?3p>wl{7Q?vnT&dcVEM@uOc6; zzHhcH?<(O{t!>5nnbivPtosa2xXtYf1(BVK#Tc>7+-rEQM}Q~9GpJd0|F`v+pOm3ylm@Fn=!na5y}LdA!z9S>`+YxgE=dv-Tko{V9Q z9e!=vK!3MBaHJqNY6kA~<+PS=m8O|X@p!m!Xt=^YeK=DYO0A}^Yj*o;_E5mQIVns8 zQiKw7^7Z|BPtr(vY>sk@heuqeR;R48Ho=LS)9?VNJ?)+{M|~33h`M8HXu0#+$9cAz z=g#JfQtgKVi~Kz$;}~;?dzUn<@^yG^IcSLZ=B?LvnWIC7h?bEFpP0&YMwVs~(`l=< zYBj~qU9)5`I_*A(=Tr=1VQ<2ix+m$Ju5gR{*OW?!R28pJoE-Qg3+J&-TLb1|wj!3= zvmM`qsMhLw;8odf0~2%NMuPpdjl#&Iqh;>YmDxw)4?(DTby$pX^*B_bzp9TX-^G9a z!;G`R`JxQ<3ocpv-R=g=;!L_p58tXtUt=O8wB*?lQomI|{6uZ%)|>FLfk8NR>6N&s zJKe+7q@?DHZ^&Xsj{{Pv*$j6Dj>UGo$@|)z$3*8|+#&O^Ix=bb{z7YYuq!PY4?fEE zZ3!1c`aWu?{z<9-CHc(-nRkO*mtj>^9`e}u3tIL?`P3(^r{Vqf>-oKoEm#pJ%~_S0 zQ7>oU0w+Ix$T$UDB+d3MSAmBDrNlu%{%Aki-^msit8o|U2f0cMI<~t`n}xb*RLX5m zmY6HFQMnwq)ZeQg^4uh?;5n{$9b9mwR(M=H7o2DUmoQ?P=XVj3g{c4S44 zgx!}8p?k+|eXVlYKl4x)`lP&09zIB-gQ?!vW^&~JO}<|)_fjL+s16vzqqB_*5+|Rp z(=Mn1R<>Vs!PrY;^*KHbv#D{qbRkZJ8rI|eKPV?QA?998Kdipja2%wuzIIzPy6FV9 z+iX8-zZyh`QR;qquIEO+B;7rwqxN7^#VCqt{=RCJzG=f-L3d;y4|NRatO~sm9BRp{ zjynP3Xcb}Zo3$@8UU0|F_lif6Q*~7=8*1)_^WqiHT9|h<%;!*1EKNpXo>ynF@gn<) zz9VAI>{$xh;&7-5os)>r&r@F#LRLKHj4H zDxdQNhUx2d{;;G|<6?>P)iNS5k7k8V1QQQeabB-F|J5AZEp+?fhDQ5frz@R8lhZCQ z99^iwml$yR2}Y{PTyzsAMDWpviNOTcu7l3cdfvo zL;YmrmU)4(q;si$@nArQ2R=u`gRkt=TRrm@LP%?*5l3-T)xQ57U`A61_=x(RUXA72 zaWqwsr@D+b8Pk=u(oI&-UKewjnHb^Yk+8OuFPJ>h^4;47@ote*`_vW3(%NQ%{mf{^ z;HTEyjsXc!2P(T`;M9y$@?!P!N(vCXrc>4o7Q(2da0P2jkVoUlh!NA~ zW$k8Jag0taZgFLhzBOYD?4VGVgYTEL6d$`U>AabVMd}k5?$7TSo?3WTvA81GskNDD zyw}HWDPqXjWW!3!-0HVCEdtd$60T>NRX@UeG*95*VPzhShIxo*vtD1*DTa`u)1uQf~HEGN}Y_W@#A(9&GUV*6;&SqIjM2 z+|96>knT5weQAmJJ9$>FWI33rV}cPCPQtme6Z_^I=V6gzzIN1pnK~B+x$0!sntEQf zvG{L>3~arPZe%Oh3wEQcL+$T>iVmoPA5tkK`>h{n4+0M^&o@b}$7CxSaJd-&kGWK( z@uJ=j+dPT1@Yyk*&!V&ET3rPN%Zyz>}VrKeLu{HX2otXaBCd4~j}d%6;L z332c+l`p4IflFKeOD@c$;vo9s#> z$oYkqDX-MKT{SE0zf(+a6z;FuTjoS>CNNPxKuu}qD~I3iIARh(_=z#>H_|tU=K8+L zj~0KZau}Ddb5Whgq(&5}Exi?3_P(~>%Sml%eJhb9zufzS*sJK3fx)+gmhRCv(K%As zXaYg#b?DNI+;{imZt0h0y6hd6xbt*a~Xkc2T$YlAxD$UaNy7jMsRW8%Nx3GV1Zb%;2_ad>%|169jWt zER}fSv1r+zO{&RCOm84oH6~$U_XL%fL5yxM>5X*d%QP0$+t7Sa_jrqT_--UVeT5XP z_tQmj3J<*ycoRdq@Q=2>F`9{{!A^O7L$AI!e4fLyEU1j~uD$C4^qA8`N1PEC*yp2`9{KGi+l#GG|Bz6F+s6-4yY@SemXXMm5*}>r)NALIaKTW zrgh#}Yq97tG)M~GlO){M1rxh^f+8c6isZ^9SIB7a`PGW>$dL2nh;~Gg>BCindw=fh9FF1AUms60P`V+Xf|1g&8fBd^me+Xn~QR@8KoDmFWyU)l>F2bWW zb_uO;)owL%p3UF9qIZMm73@^#v$>2};(QkO;pWq>QG6fILZ{oirmwOOd=zt~-c|Au zfI!rpI+@2XB81ZF#^U&^w^kNK9O0LaY@Qk`KpzzrIO}cD+WI-&Fnt=#zI;{lny$b# za-X~(yj$@~8ds?LW>42CXO=wQ-rB|`JHdzipv(leYR;sSHv(kadbv+``-bQOwo{e$ zM4nHs)eU;kRx{wzZWOk*>)9&_wE`b>2YkJvin3TY_xlZ1vz3ZR_)Zxn{eu`$DMOxZsQuM?ieZlt)#L{lDJL4;=!%rldujah%zG-+#*yfgu zIjVD#Znjt2UPI5&Q@_6CzWcJVmpW<>Yr>G!8v)6Jb-x6zNu-o2-aF9OF&ju~n-ak1 z`LAzA6O-CIwr}RUspiR>7CH&D%O*DzINf$CEMNQ?F+VGi`SmSX%w{A<;1}-Fm-;f2W8xNB+)u zr>nZ2K*}HsmfKHrB?>HK#rziLj1}k}Voembz#xw$)HGSTk>9TlS5c9$zN<{H(|PDD zHql-e@SV^@IWIt-@5VO;Y^)0-YSBBVeDg$Hy}I?v<_B8LF~14-fV+=K!l+C!iPGdc zal@mG&@QK02W-ugHo4=x>9yYq7SQR9kNYE^sc%Y=$>2-uW$Ru&{At@xANOdT;IOEu z!ZAY+sv=x#-ll{ex*FZY!g&g{Um^veI+FY^iVsEz`X1#D`p8^;v}jCyfxYG+Wshm* z^0IN^lTuFW4g(HkMd!4AlMV?7S^UIWMLz7DPiUomIk9)9nQ(sYzGp@AlnZ7j#^=oMuVrO>ZikEu&rhz`&Wn`}bDx>e8`asu&noyl zU15>IEzeGRXKR3OE;{T7qX zvttr*Z!(oik2EV4^Da~*>$Yhl(HmHJs9=sR_UuIO5t$2c%0dK zJR7W(a3KFM6zHb(^^Cr*U)$3uR%s8W_x+@cUHUYQ1#<86UeYwVs6@MP!m4AHFBnPr zDv3eow!oE|GC}J@1@F2$e)Y_Yd3P`lap*M^I#bB%qW6MdyO~dLBiUDZ`0MTDXvG;< zi~I`_(bu_{lb^GGmTB;zpG3|Hw+&iS80i~O7HSRmEG?!YsMmr)A`f|C3Jkh1K*ZHF zPegik1K4UO@gR*87r01o{tonGB2!<*B6Etjsx3o5i?6-D@!IB>D*+9x$U7NV8C2!k z-g($Y3HWH{slTE<($%V#N_X~%7hHt;1T~K2`*W3SXhB01uW9vyq$f@UNYKqfPSOnX zA-Uwp$=E8q^P~`ocz!2|C4^cak){PUAMd~C8a;jEj1g)u^}Xjlqw(4A@12p=Wo?ZbATf8_njPx2i5IB|PU>TAQ>`Pj}G*%)my2rZvV`p1|#L9Att% zR$<3a_%Ttciy7oSdGSFoyb!way^B$rAE+tqxBN2)E$!};!gyG!oT?dBz1Fr)C1f(S z6@%Ghdc+r#fcnJ)$+Br~BttKV3xgdUvECvlnD%Aa?yp_k-~C03s%@KIh9o|MUw%gs z*HhxS`#kv#nN3l%F#o$8WH!{&1`OW<%ossQnDOV3n zI#}E}Erz@3GBjh{54c1~&%^A@c8>!d?pj|n@|MA8U%r<5eu-OTV!s4~OVo7t9SGGi zeGOt-UZ%&~=xjPdtzFZ?tB1mNI{F1pTx~8&#A{2essr!D2`;1WjohPDjH}Ah#^nZI z{PbpA37=jK?(&>!I$Jscq;pnNk9;&dP=o3^==;ULOt+SL(J9MyT9F+D!-b@9@tVNq z?;zlnpeqw&nPk(z^F);bNZaZaKN57p zeD#s|YZunTP1Ae60?nMSOo^N$-5c8MKFBKY;N;2ep8=j9KKt)Cekc_`*vnn9d#`>S zCgidErqowj#AR<}(d-Hh+h52h;*zcgXZ$zR6-|QNd8B zE3Fs$G_a8odMpM$t0HY|^rF-PZbS z5&rIu)XELhmc*b)&6+|$T-3B*Eg~dkP_#XCErbqeu%^FRrtK5u+S!(gyKiqH(Xs2G ziyHnTz}Wt2D1->ztZY2i^{^OJH`#o6ZTXf%ZMFfd^jWpHW2_#-6(K%%jvL`{gjuur zJ0D(=Jw#`tYNs;;^o#Ik|6+etg zSLGEYI&r9X6pvQqLnD0gZCW<1!PaV(V0H&W9kWTZ!nzXtah%&_R^`*>23GHklLcAp zEbGRBqiuZL#{yeN-)(S9=BxMiDS&g)4J0Z`xAmn1f6F)`l0Ai@q#x5o?cf3x=8V4N z>4V_zTPzU}L>Sw}t|kQh=rHeOFpcnZ&9Pz}4m#3sq$RBe`3VJbhq9%B?|e5>f^(B1CJIJ|ea z;=_0HT%UE_`S!z!?V}Gq=OXQcia4fZ>S8G}AbNYaX2=eQyWTMuw9HUBJs8{*$M)Y| zJ?!&k$0ah&qCMBP5ZU878~w%?P#w5H$NpQF;tVw&UL)HanPwwYO@UOGsSfWN?N>k4td$YwdfZP^pmY#vp~qIxMN{@&!!tN2wy z%aNN~%Lgs2h9yN~zZ-57TS?s~G52p-ZCmWhpitx}(NcJ;{nq$dN6v_#J4bBW9fti8TdJaIA9IVJmcI->1<93x(+oPM?d(cy%bY1;(TCS2 zeRw;-^kNKYni!b!`m*vtPBC3@f^I=FBx7ufv7efoonxPYI2hSNZW%o?D4k_b2BSKPQB* zC3tcKYqmOCw<47_ zAo5G-zFMN!Wwh@-GCcm3LLDx)n|YYt}48hR-M2 z7|qzJ>5`F&Da6sBCuXo4V#;6MZVa>XJes*yasq|Q1DVpTp89jP zMdZ~hLh8BQXCc?)WRV?y_LEICC2zIrykn)Bl67b~4(7L*qr%YzZ=c{lh-rzolAcMd z?#o>XKjmDf<$V1dWPGl*GHZ(;4a_{tZ|XX=Ue2oHnlYungVTTgllbi43E}3x`qu>g zzD;NTFotW=G=#80$?r%j4pzyF=4*g-Ff~>lVa4#Scd`N%*=i>X=(p&IH_4GfZHpyZ zeQa7TQonA*uPCX@apaLzg->qL+V-d$!eWyw1Qa~D*<5Y!*?^EYc7hYKzmy}>bs@?9K z&E959iOD>mXL=IStwZGh`qF^#fMu1RASPx{{AbxDg(jiedlJ$`4QUU=!d~A$Jngj1(DyLEH zD1LF9J_lq__1j-=x=`unfbv(nrsex8(Lc6BoNY&>V&rpX;Z5)BRF@{^(ozmBRGw=q zR(Cv5UYAk10%bS}>qO;JCQ{S44(jWNt_2hls%1n!{W*gft+2`n$R}jZC}l!@=c_B! zdWeQE)W9(KwJJ|%% z_}y^pfOqdyKzPwx$!-7bOxFc=mN&*C7ufe<%{$aejoM32LxM1G+b@%sJEZV~dwTb; z-I8NlDH=_dX^!n=xDo#5&1x&YuL1Y@S3T@+6f|GR^LE>{xy#;jMgE+dDcKnR5tvCj zD{rvo)mT!5*b+NkFS?>>=3~}bK^g>mlp|EL-zQ#^pEoISXx$X=?(6*NW$+0xuD33B zbZoz!S@Jz!|KRIoKgN!3f9aLFPTH(Dv&@c;E+O&<2d!EhLIvIl>enRA%*49~wXQ$U zrPY#x5JxUB_J^$xlU^&ISm=u8z?hg(GYARkp?pgl)H?b7-A zd6{!oU6&-*U3U~tQ7O+)Ujr==y4m(otg?Nw1VMDDe-z0TA6X!Jr?&grTFGFgxrqOe z35^2s`ct#aZPorVzXns+5D%zf_itG!(053&nwcgC^{`Z;zy(9w{li|rQB zMCgTWhTX^l9y7|x_-zk6EUYktLcWIX8~Aj}h$f~DLra;wzs>wOiSX9vTX{c8v#E2X zobFHgLDwIVnm!4poVxNsWU<*kV%}*tMJSBGdVjnD;>;Vf~JrDC#d?WrqiH! z<la#!qtEjKRwJ_BW^Y~Y~H zlVhDFwpVdSXvx{#=9QAQ4;8ZT@*I3wos-a0NllYc>pg5rL~VX^1eYms8UA z<{Z{bK5&pY1TrnlOY<%r1JQdQvlc2dxai^gwc8>dm){FMJ#uugqnrL+osE2#bBKOS zmY~-3tE0~U)8$Q}O1oVrun&YYTS8|Um2?mkfgGug=YyesT|?$cA8oE0MQ^5=meG;PT~1*sYcoDvdljUnoIaW#PCQ^>x$} z4kW*K$2{j%D037Xgzl{yOZd)W3p8oBhan9uxb$x&eEmpo+e7al!ed~f&{N>E^Iy{X z7TN49l905*KVZptK>y<8r^sL{PuUou`q%g2ooM&E{gF8-fB0e}aLKb^CvR`Ww!}u7 zx2;9G6R-}q?nTqHv@^U)9n1F8T*_rMv8yDI$LAx~+Wh>xv;_P^hlg<-uH*}b_-r%N zr^qUFR(Z>U6H^>TCU1s_%}x#_H~S=ZgBne=`y}_gF5nK&1CsmB><14aLc~!Hx0e~P>(hL0~=EJ5^Zlc)55>V^9kECDS~}ye5(+mv>^-AvWWPnh`aq9 zj0UC_8%5O4edutg$JUzR`Rp>~Lo-COXa~4i*xrd(h{G>9*hy1#zl=QXjAUb&SSe?U zOe@aUOkW0u>Nl~k@=CABYV(~=uC_ZTfY+h=iM>NT?>aFxBJl+(hZIIJrL$$9FE1wL z-BV_W8US9c3+%@(3+Vqv(^oJ=^?h9f0>U7o!hn*Bf-rPQmvr~grAT)-11KONh)UPc zFmw#v3Mk!3D+ogk&Cu}P`Td{w3*2+>x%=!`d#!zDY}%cVe;S_JD%12ugB1rNV%j{M z{R$q})yXuCfXaP^aha8z`6c2|53H?!pp&^`{mj$-~ClWc=+TJRV(kZEtVD9 z`Bd>7`wF<|v~oSawT_#L*B`KAVR+@YutZb&T*fX?ra;CK+NXUK(0V=Lw`lyyoXjtN3a3{5P(06H4~5#AV5&a;z4Qyqpg< zwjSX^lVg&!O0*#a*yMHb{=BRIFt;v6SAW$T_#&upR;u48QF&A&T#u$?wfRm&j9t$M zd)PnXF-JVMFe*8~3eVhrTI;(09R}riRC63ov zatH6dx>?uZ2Fauw>RKARgOQ??*`V)=qmLdI{`g?;X9!QBSbnu2Il`5t^5NN<)S7K( zk@~UtY-{dg-o*}qODAcu>FJ#QdC$5&^}l)?a^B1QmY#Ji`H ziF3Ez`doeGo%CXU`>FPaWpzuNw^tg}?M}K6W~oL!uo~*)JR&{iYuc5D)!Nvf#P#6b zm%VmBG)L_VAc?I$_0R%o(fD$9RHXb##s<~xn`1G9w2I$J<4rPsFfNE_97fBUYNWs< zeC+3u{kznPUto@hy|O>GooVTpFV~%}103bDlZ?o(c-$DCb?z^%{Fd)gZ}A`ak$=+_ zbXs)l^#{R!`<`tgI{k1qehZ-0u)XS#JfU@#-^nh1JKK!#y8Hvkqe}NH`C9kuJAoYJ znK+R13XTP~Ka9lmk<5Ya5Vv8Y(DI`)Rh25~mgDbf8`9u8reJ0(V(9u~h`JbL>1b~v ztX40Z`^kJ1;qKz7s^X+#WkQKcfVi;sJ?Kii0N38_>Ndbb?_lUNP+jON0OU5Ja#Q^a zr|sY3TD^UYkZb)WpRCv|Dd)|H!fNd!4IXZ^X*SXV!u=iLp6yHp-LIMOcFS5!DbL)sP-a{wREsKiyQN7i*IzT~>V zDh*CoL+C9HR;S4RZ60(A6ZyOSkdPKxvoOzy?}r>tJ|H44RF`Lc9-i& zf4qI>6MIcsRK(xf_}CQ+x^HZF{fNBO-T;cG0G1^=1_W-WA5fGMWN33UC zaE8BBA`H8q=l$I2Ce`zGM9eR8h_UN5O!BY8^`uGxxDX@p>rvHC>HfI+F3P>r_-94T zT5)bd5I!X!sG17cDIDw!tM9OwWEzcHBTO&@`!mh_|MWu zi3WmTe7e=cGm=i!BYAwVC*XF!jOXL1D3(GQYr$Hz2?N(rJmwfDBk%g(+x&Z+_oVM7 zK2HuJdgbgOW$y9?b4lK#f@yaZE?psxRd2k+CG856|L_aB_ag``yfg z=5@m2xBKO!ix@z9AvsnhQ7Ik)9|2@D7QHFD)hnNDtsr`~%74$Idgrbm7;P2d9oJYo z-26C{JoS1yAYo(WmRqmCQ*yv-THY|2WoI^LSyCDN>T>cXKwV)uBM~FfD0JP!p`pr7 z>J2E5n5E;qu|pfk63UDal4T%9k zp1EIEYSdUW#ZU$#kqnHvSiWrt5c<%ZAhW|sd@lwD=wqq5D<&U zsbC1$1Iqlnr#+XKYnz*uw_-q@fGKL)qj6HQk~^;U-QFMX=DAG|_dx42GuH>0wqV?1 zk7D8_P0wZmT5$w(QX@_z&$6@mLq=%;*kn*>EW{tcslyV$&OI}|eO87$ZFwCPz zl{%S#MXUZ)@O=m{pf~wy_Nw2KMH^sP>e-So(SM540QE~C2gD$^xgZ^oaWNa$)}Hw| z4(ujOtHqj|HZB80F1Sc2sZLEL3E8Z)UICN>29_%}&eDnR=53N3VwZ~kGhEj%GI?Rk zKfIRCE52|3Yl?3RN(X2ol4_FYV^7F$B`$g|$K%bjlb5dM&7F(!3YOad93V3gQ*k#@(dTZGxmi+>U3fE+0G|7UW#{hts28jePW}Rj6@snh`5D6 zfBeG;VaToa7XNyr^@#@=8p!rT2;dNG)IM4jXsYaQ@AJ2WCDDB9tCxdn>i&^!Jp2=} zzIz(&)Te(aTlMI&V)=by2}aUbeSTfTZ&_Mq?;E{|K=r+}8_1_9dZ_?!`pn`hJ6Sj4 zDmKy=p2@&eia~>d&Zof>f!}R2o-+3-sEG4EjR{W8c#LEV&1xw6UY>21`9)9x=o{B> zKP0~ZlAdeBW~L;Ln5PASn_wN8oX5*R|8LK(l|dOFl8Dg~Hb_PD$u&KkGRQVEZ~bvz z3m-sl_g_P87dAT%G`}|>@gE=K1JRl<4O`oSQokkL7){^RSo_rN(48UjzM!FBN#pOxm?Uo-(d_fy@hGd#Vsmy}tk5mO#oPq%-UcJ^xi7l<`SICQ zi_E3L*SaBrLTe1S&_0G*Z6-NgnRlkcrP|!&p|1sBhKsTyr0v6>Bc(r2Q2Ub9zfn>) zj({w?>ZB$0VyKsNWHD|9l0k^X*$0SdER1 zyi8Mzf#Vu1kJfrhrJgl^9`UqA0Rj`3Q0sWH8Xv0MX7N_rcS-7Yua*d&H0hh}2PbX1 z(ea`Bo;Xz-*WuU-_w{p?sYZk^JxDay3L5|DKm1*94q&!>0*+^=mGJ62ED-j+L41Fq z^0CwgC$O$6uS77)_HQNhzr--{Sm%MTwGUp?J}^vW>&$X@HgS9#YQ|jo_*Z@G(|ir{aoDZ zw9E;OjhkCw_MofjNh<0*Z`SjrJ5l|B6m1yC0WXjEEUU_kwshP_F?NcD4h>bNDuSPl z`fD;FksP8Q)svsSnyqe?(eH%vGd4ySg65NzsRYRD9aGf2UNVQ@a}8L;U%xI}u456e1Z`~YZ~RD~;b>ug3B+5h)5tAY~I|{uO+1Q3qC7xS3>;=Mt9_d3l0sA9Vszb zuK(Mh-Hy-?b`?$}FEQ(z=)lPmYmNq%9*O)7s7s>Ua3 zrTqm(fXD8)`qcxQy{bK;5~6LKAk_@3_^8hmLFl9~v^knV7LMOc=I7OV(bsRBliN`` zrT+kW=(y)|8t;>13Y!TYZ)ZF9u5imR?hT79bEr1=kmTo+?OKq{y8tT-sDdYiQC*EK z@f2_6{VNRS4=;Yit!G=(PQIvI%|1IFk`yeuSoo?CfSRi)?}&W)Xp{h}wtWy9ov3J} zDmh858|RN07fYO=RmUk9Z#fGvy_{>7zVTJ0ESIt{9P|y3>b+Xt)+|{eeNsUiePcjC z8X(x5NVKG92YTVWptXaZ6n^L<2Cmqk)mrOkt}qrUs*z1(ZAq6jLN^aHYO+2hEx#Q` zFR#Fv;ZpC;5w#_fw{5AFHRPeYSb6AE!Y<_(CsmJM8kCghfSd+|BFUvn7CiSzN+M|+ zws)TRj7i!Oel+Bd&n?|YhL}Y9K@dy(MsEyF@)3zGoZ=0pX&8ru_e7agh58kAJN z0?$!Gpg7+&L}lXU3kZ%Ex8I{eicO)q$Y=@uE@e7|?$zWL>#tEvk&npmM(0)gcsqxG zcw|ZaZ2e@=&XVxci-sHXy%|4EpE-Sz`Z>WjPJjFVP0lm8W1ND&-ATvsfQD!@0w3F{ z&1T^DS^VKgivsJkKL6skBQDkm9F}Zv%2j8-PPck`Ph;hhv|PcpkL(uRImQD(Ktp{+=UjFsS1GM;`t23w;5A_vc{q_nYE>S-V~hPIli;py<_j-kCs2 z5hk02PqHByXJLsKkBdj^H4E_I8T|Td z3b3251;#>-aEdpHkBl;cHZ_||*O7lJ?4!GoaULq$4lE(If@hi^S4g3r9c+CIa+b&h z`(#6SyIzunGd8y1@2DkwdZ^w9nV6y&3C>TdCHo)QqS z4GG3qzr-5@mC;8)>NxO2aQaL4_#}}x2FNkqn3C6%z1yh*qM3*b**nu-<}tiyT2CE! zqjy^tDGiKFmL!}{t8b*_94Q-zo*sI%J1a?pp+&aom3DM0*Fn&zz7keh9x$|}aDmcL z&M6OYp#B-4S_;Tk(hJRmrV&#<7&~~}5jO26|8lWYPLNIqi96^o$!*M(O2Va)t=As# zr3oTMh1HpVegquOutbmpz5cacN27GQYyl|b(8&G3+z3{N5DnYt4VDae}ZQUL*Zl=kC#}y&qwUEoMk8|dsvS;(muneFwliKDaeoxgKfzWKpFrx( ze1&pSjb1rzqsL7KFIZ^tp5A2IcDv;wv2H8MZyLR*u6pCrs%U(6*f6hRxcKG-N8}^W z)D5`@_%EaRiQ0lL=i+an(M}^2krYxTrdM3ORDd9*q$bZ~&97y7HoIc? zfje=vC!nUy{Dg7RWgmwSo#WYB=V>j5T1EEJjXkyx} zu0d9wF3Gu=@gxspVn1Xh$|o0Zm?!{}6+C5cElJE?DgtrQ^_`N(#tk$Z$$g(;xSb>7 z8mCMOK72o!y?wv*?&xdmSp^(TS?gdHQC%o7dgWr407vsFL(4|UR9{I?GNl3d1xr2) zIaF)sMBI8_`604o&h16x%D)eC#fMwIXP1r)G6p1kLG5!+U^|@Fu=>UA#qjgI)dPk~ zZmFCQVts!VUT-I7QE`VcW`i-YRP~zKKHf3c_+O^U^$@2l5?I!Dkj9>8{AzuH^Joq1 zg#`P=dCEzX@8O1?Sw2U`?ZN4sP%_t>yLg${S&CBi$LX)Sl)1waw+u|H(Wxt+@mP@Q zd$8^ch)p;V=w-il&Po9U1bh5Whc&n7u*&{xapwGr*hz8QcU&x(JUTGr~yuSutKyxynL+P1VyLYtd}K zQBp%s^SSk^khO?d5*4%auC?K>KIQ`?Ua?16_<&L%PES(Ons=C3SH}GY!M}eu zR$SA-vSq&_h+!&PDdt=*z4k388}eNDSH^C z;K*J)Xu%GCWHs2_oE^U!njdpfES-H$!eYRK9@mt(-oe9Rfm;Qop(!Qb{gWlAxEuvv z=0Xy{cbo}SXgN$SjlIhDxq;)Vz<AT@S?RjO|RS|B@QRefNSg%zEm1p~RjsbxW&H9m=jvl|7 zYkR)iU5oc2xJIV4R+0S+XyI9Db{*H$ zwCW*|o~L>Wa46OXAO+NJX8$@$3T%6D3Mxi~qU;|-}H&i%MuT3#cf*vg{9CgmH>CEJuuB{872G#{32|FO7 z#OuJv)qh$Tv7l)^sk-$&52&f>NQQ7`{eJ@4Y8W%r_}iP1R>~u5J3Yat) znR!<3%gL;PFPy|Tgamh8Gq(2>sXRA@5O0~-4Ya^&^pnA*6N$o^n7N~Y%=gPX9uM5< zvtd=`Ba$BObfOP8Xt4*z9bkK_bb$)C*2YFSaYDkh9Ws}WZ1_5s;RKiDU)SaYnU6K) zLhR`D2<+*=U_4*e;@@?ZV)KOvn(*gt6!NykE!KU-C#hj%1ibcZ1yY*NRK6gR8!ZUX z8I@Oq4A$4;6Fc~Dv6K?PiQKKTF5`#sWu#Zvq=?sa(hXhn%`Rw=dJ|}pmCyVL#>l>D z*Z5RJ1(D|H#2)h0Mf2ZNH+?S$8eHl*ORQgoNQa=WotgUkQ^ssSkQ!Q(Y(at-r%Ja}Q(LNi3%A0V5@f+QWMl{LND~w;<#C$e!1$k#`+0Af zD8axjPIb=*C@pRXoH(YF3%qnq>HPYg4hob>7sC+jgMa*?SdPG3ijuc$1v0xP-`F?A zpdVMt4Vv607bTSjp}3}~VIN}96H)XQsq;x3Y&s#U)Y^E8Pz@F0gBhy{Zus&mKq6&C z%l3G*d05v&?G}Ce^DF-kIsFW`LUUz(%0$c&PQG*j2)Ktysz;d}0^HQ3IN*dk-EI{` zLgoDG0d0?sb!>4zV_D5ld^S0Rf!2eS?3uifryQA;OIuXo?1)Ll)zrIGdL5q8aS76y zmvK$#`Ku8XNdg^^rf1-gS~k^muj=3aijv_BlA@)X&qqIIT-KF9@ImZ&jg3eD9Yul( zL=_$mMW#^*$MwIvPRsEf%5wU(ItALCApJ>@%HMuj%QpcW!C)i@Yw*iKJa>5xoo>mH-7N)%Y8k>4xKt>oGng$>RuHr|8dNTTz-hmrD4o( z{}vW&_OSK0`NYsxewhMA58bd70gpGK(S$V)=D#!aMOauu^`4AqK@W;S z3aHrnt`9CCDWv!fg4Zd#ex`!~?{u#@km2%IUK=*JZ)5A-R-$(sJ1xrHy}uty-Tb$8 zxOGOMpMcSnW1xFDAupWUZ;5Sy^4V!IlG08sT=`3QAhg&w@_nh3!R#@ocd7hg?&Wmg zy@_e1nBOr;zv;d*BDeZuy1x~J5{-L}_++xY;-MLhhEmO3nGIICQgR+1lS}9dm2}j2 z2u)#e^UE@|4~a`TS~vuuL{PoHoCMk{bP4noPNE*v-pC z5Q)dZOV_JIS0bmIX*17q#SMLe z*wtE-Gf7l3Kt?Brb>`OL*5;r>lGO;bzl7*qA{G(F8%s_? z!!W?yVhUA<9Jl8IjGu$YG^=$3PpM8UiDw)uKmqka5 zGxhBrmkqu-7@?RH^v8$o4$Oe_%tprKNw@%_}_?`adCb;JMx(^Js%)n}Iw?NJaAeb%nW%hi+ZtUUDImo(ViR zNx^T}k0_VGaUSxBL)yhYFQ(M`y=|AM^TD!8`LDW>W3OarD_d>{Y5I#)rM#`U;?IM1 zdfSx9jC9+PV`g1-X!wMca{9a~C!J~IG znnwawf*mql(ew^;JuV!+rA#T5Ew3bHPf?jVCIMh=z3^ddGv2_MnrJ&5|3*Vr~iX8 z_D@zotKDA)Fg^gnVVIeqMgu|*F?ZjQE)?|W{KuZ-z*?{x{E_#ofG^YLZlTe|uk_}G zA2q{}apUEJJ?yp|ReS2(=F%}3w4PXs>so~hitRDK26OC^4d(pMoPE~7>nTwY8-B}P zZ?P%SI85lz?*t2H(fQ-FOlg;68uU19Bw<-#6RU^elh?S#9@hwHuqZO zgy@NQ+<#=06FUuc43HPjSG4Nsyv=P{)S$VXW!4KH z$TT#i|JxJB^3sYEeTW|Ag<944f`-n{{TEdqOf@%WhDz4A!y-s_iaXcEdbVk_=NnOM z!^;v)zl1NHHH=R^uIZeK9!(XhaHQo@mVpa5Bv#fxYy?jpvMCirN`EW<%)I3`%Nv7E zd-GNX_nURizeV{HPEYmN2ouyBzJT5UPJC!7i*Eo6W4yj?&Pa91kN!CmTV8I-%OBRC zTeB1pfBd`_Nb%>1!AI~W$lZ=vR9S~j^EB$D5_aO2wIJn(jjWoy07ybnMr)7s5EPWAQOpm)D8pX-94Y23Oq;*6%kY z>NYa#@Sa07$5sP)@^Z}y*y>~EtmXSQqz)+S1{D8(BbL3Qp#44GRB$vHAPIU(5h9oA zvn17*QJVJp%U!4$YioowvG{5HaH252QtLz1_;W1hJ_$BXA;S=wRS2*Dh znLqE396d-c#enT;AG>`g;__e$YVmkIiD83O&5CBAC8kyaOlbIJ=1(OglisbDXbHa9 z-@A;Guem&k;1dY#Uucrp4R#tkFMO(j-aRaqtr72VWVJ9f?9^NvRAQ31%=qnu2tbnk z4?6hqiRX=kOw`>-+uN7(oqKwtU~}~fMn9*9L|w(%?0-=} z3v!dR47vOr4oqYAscq?}L8>JCAe`ng2%^7k5B!DIlN;TFx%ZNcp2(y zx(rwb)mgD!vpgKNK4X;XHVuzX7R!2%_CPD+_d=Yrn4K(`$m-)!nkB}(JQ^8?vQ%vLUWKcqI4*N=wMR`(n(^3Kenr_O#kNNa2L;&r3W8e ztjuMP+#Z`tbbqC0A;ZQbueo&n zX`nferWut;b}tnUVfR3K0;fX5!>{{h+m=jyZGx3=D9UWJ8+82Ketc~z1^=#K^|{gb z_8oWX`I{+aw5F}k?;346sMfl7An%1`1AYHXeC4*xJ&cgXQjk;m$eQ>2g7(tl;k*9> zUj{&r8GD5 zm)YefO=fE|cJMH4USAwC4gP(h7H7fqxJ%i)m#x|K4(*&#%D<*aqp`&0%Bw$z{m$8x z3we|gNVqCev8gAV#xRvgC|=jk{hAIKVyOGNhPQ};=@@xGcJMB%NM!?uS83h~Bv-d9 zFP<#7FJLuph1S8xAV^&N28kKil(-tZ?I$4|6~;n(y7+)}FbpiaX+t%MSI(o!wb$yW zQP7~l$4wa?Xlx@Cc+RRi*4@$lsnOd+U~+eBi?nM`^bxQ3HsYgKFbo;pk@i9bq@eow zOWjneG!+t`Bo|V-6c)=@asd4i?JxU_Hb;?O`jea zD<_zP+iTHTM3|vham7EInarh(-Fqs0Z8I&YuqwJX_wq=IC@h zD}`DY_BUjMmG4vLOZ{6kpY$=f#ySJ^OM4RWzyw74@_{Yc ziU+GX4MuBI2P?Z1R6x~?2>Sqe8IPI9WQ^o0+l*-|ABEhng82f_ zE+-(Hh>nBMyg2HOnbCD`QH4L^H($Y*#OQ|MCBo~SmF{*qtPjUMAIiRvM@vYQ5g1Uo zAe9Yb${96B8)5$zysdhp@ve!Ef%30nO*mUFark!MSQ6Fq|GzETf@zvCEOD}ztx+u| z9VH_fS%0WZqgrP+fh&3`Ai0&{e<_nV&q)A580-_WDxp;1zb6aX*u@OL!2?PWM)EJe zlE!OH;Hjz>ySM&Xew*bt8njd_o`v^5Sw^;pJUh~AZi{R*OL>@G~Zd5c8dP@f z8?K?m7INshp6{RVX=*D2cIEIKFr;yf;Le!4|3mL|GhQ0l-So;;Jpy37CPe)+?PB+j z-$~$;byCUy5H@_B#xb?lqxCm8DkIA$w+bx_73L(PXzyx9OW#0oFkr*swPmS%l zeX(HrB55!dZ1mkZN-TR=ci)71Wdo0;?W;lq&{(O^UY_g!f+TXH%>6FUTxvBc`##C| zXS{SP^x3m309fwxl5lWF0Js5iUJauyf8N@(k zrru%$|Nb8urDpqV!Hw!u(;(ktKD{p`Z;_zyTe#omyIe5BfRAu5jfcj(fEK?<)d3D# zICrxV{m<{RKjPnJQseJki|kR^W=wJLOmTz4jx*ZbA}_DxgmqT-fqo=3#BNR;76-SW6_z%?Bvy94&(D#-0pI!ASk z-98Th8`M#qK=T&W(LI{mku=p)(l7_(*=^aXy8~bSF956Yw9-ErMaWF)jRP40j`Ait z`$lP}&z5KQUh_1!v+0oJjGkS;X)ES_<1}79<-b*<`>DeBn=8eKE+ve|wqNC1JJ?E= zH0K(tiZ)bjJ-_Z)a*1n+YdDbp?q(KPmU4OZQzwpZE^w#CDE@Ss$H%->++e{{uarCU z>w8?8Z7}a|!k2QXB`cEk{!6=bS+L=)rna(klU8?@z?8PEQmVS8nW3Xs!$3k9HP`BV zyOY>nzh}A0q!V}tUnCw|huD`vt9b{rq?A{|3xz%9RS$9c*M=BOtxw#jyW2|7d3|OI zoL_}d79l%#NOk|E-d&7OHJ+q*cLAw&M}z}`K4v-XT2oF-W*j-X3{dbn3LQG;>pdpA zo23anbZ7*TB*t2!jHcnwmmtr?gO5^HF@6SKV~ND=Lzmf9iz`mI1?cgkvCH4bw9_7b zd-8p=OUq`Le4Y_bUT-p(sBW1=$v4EXNrt_%H|F8>G%`Uvlmc-Y4Pk{@my)M{8ag^J|(jgT9gmT{&*sB1rkyLPQ735WrWMC1G!??~v~ zf5>uB$O12qXtK5(^_pbt&f+*~Hv5$G3&zqdpn^d}aJeF`QHg6UX?5EIC zh!9|=9%IN0Ur;AinA!RCJ&ELxk!Hu67uPf6(n#qiw-|>5kxFGUs6(te;av#AL{m=Q z0Ia4BdK5hg+WGOW`83rsGbyaVNhPM5i~=8`s&%3?&h2`u=(gT%vGkwE5SyFFT$@~N z+IcgN(qVI!KY~PhpYb?4ap39Jg)JV!ZIli%lZ;A7Zyeu;d)vT^+rFfl=0p?L+AgXa zkoVrpAp;Q|BcbAXNsj(4UQsP)^bHKTap~>Dh4QD?KhEp|y6IgOyd#EES&bYjg+M$L zdSt90W+k;N(AR&mDMg_^0G4LsE~&!x1eN~u|K5NWpH0!Xokdqzr%R1y9q{|%>_$KH zb9&|$w{t7Q%%_)U`zTI*W4%kE_rlo_CR%du+w14}tmx#6-G49?r}Ps9Io1s$ryxa3Ta{Nl zzqY(CEvW>~n29|9yAjVI#DahrT5S|gX@L_xQXd{WCu~Rg@znd84&C544n8(&B{Qy? zij4!{W^)(AyS*+$(9ieggB+)02JK%LeqMEqn0dkiKN$J0`4leoqQd>}hKKX3PY8X_ zSc;2{qecSa8;`Q|IZ2IH%71~oDAkXUd>0Y6vpM}mDE~a^y zIT^X9LP!^N6LW5L6uEW2xEO+3>&%gK2<~6c?~S&P-Vlp8i}>i3v-Y-Y0%LMwn5hqP zOmxB17#lhpS0^`!o5<@v!#9l0d!MK(fBg$iIuY+3#&5<>F~E^c%M6kw8Mqpa6M{P8 zPR~r#T7}4vz(21T{bYHn#7iafx^H)4H_Rig-oog3y2^LxXcXtPxCxv|P2r5-_tcS7 zo3(vra(Uu7oHtPC;<=!UT#o(Mgi204cUGfb^Z2vLDptd&tUV~AnlZDIE z55d(fd13Pf;9qaA&AVxK7E>w?ej zxqk_n-EcF)G$4A-D4WW6M|^8m*#TcOi9M;Xcfxf>hk+xb!5^|2hL$%nAp)%C*j*!5 zcr5kz2l6J`;S#LCr!9aK)Q%4;TrkZj_rExXu?~tsYA%CFz^ps zUDxzF-s(8uM`^AY!AiOJza(h3k})v+CIO6$5?$`Z?^T%wzuhvPC4`qTD5L zyTfOq^3Dft&irX8`J)EEBKwZ6QUZhb&+oyxftAl~N^Hjr^j96U=J2VTo3E3qJ&d0b z@Sr>S2gT4PkA~bE-ED&t&VIb;8;$5DM>hZ+dBwtp*L`dinP2B*hE#`cFKyMs3qb6; ze?2jt66iX9DyD4+iTHZ%@Vt`fNN*kggj(NCg^C}i7dF{^HG-TK9<^#F_ueI zWLn5Mb$Swr=dvn3F+oMJ)^jY3v$t>4YBd&kNOeh5zK+>di4W4rYx7`IrFSZg7BQEd zvUUqCB3+Dx=+)eB^2{2SA6)u1L+^nlmHi4DyC7k~|Ib);Z3!YC*n6AxEY~Oq)!~3% z>x=cBtj%z5wb3kwPZo!3v}wdF3!vjE%8K7IIR(5Nsh3_=22&eN&8K*K!! z-ndfgOMXXW?oH-H;#yP63wY&&P{Diy-;g?PN}FR^xLB-Jg&*=QDoc^W#OmWP{gEX>v2 z;k&c*m9v#W_y^CiHj~1FDh+`tIVk}242w%-B95E92AznfhYzOeOPWi#FI&8VI}E}Y zbSU5(g~~NH^s!l5d1x&bYRa;A(DK|=VM5^gilvJdnN(;AySdkN)8-P@?-D5)8?^+=|n@5>>gJF^gyVc;db)M@d~V{nQuby@@^( zJ#IB(_T-45_v4{0*BR1{Rhm78NOKD0&0+R)zYh?UDYs6P<-uv>t)+J*7zhhA zr-9*AYH%T}8xQX5!o)kJ(dh_*Ko?enj%E#rP$`1%T6cm z*|X-$c`fF4CnnyZ&CXYazqPjZbm)y7CZn?J_p7HlJ}RJu8oI}Rc6r{8nDeu-!A;<@KhiUJk=QlHpk>~>7T-Kr|?Le zQDJJ|z(ZS?KbN`(@aRX#(}N`dR04gV@yX8)g3H+sdHfdMz;XnJk5X*$N1P0L2Hw5} zLHS?Gp{JY0`4{nR5uQXfjIV92jY9mw7r6pQR)coSGYy&i;|T3+vJ&V=p3ZzV=+L_z zTJ^E=&!#fNsClz%vSZWIryf}rR%vZo0hVBKGI`-T9FZ-6BpuPN!QtBtk@I9aUtkX} z!E}Six?8Lr0Ae^BTdRKi_V&ij8yi3zSxjNqJ7`knZtB|jrH=7UuG?-$K(g!L>V}^^ zqFesu1Xib;S$Y ze5v<2isY?}j44DVocaa6?wF5LyF38M7lF^`Q~581hT;yE%v%zrGt2lde)gGhq1|-n zE7>C_M#OyxBmKf~da&dc9Cto|1)Oiw3+hr18hIaq_i^2PM%QtoH*Mal7@A6eb3~Cp zs2VTa7kpJPnq0>Xk6aSpgL#2zt9IpWoH9HC8CAnR19;BaH zs7%1Z{GY#N3j^2LhNBgG>XlGOwhr^aZN2}oY0eTNj{3yc4Xejoeo!cJ9^ z&Gn2%_n$$fh-U<}B9wNJkE&MM+K7t%;khx(eE&@ot0(pZmgp=klf_3wxOO*rrw z>guo@t;gT=3twY&hV|QPm(KfqB4i||?|qudEoq`QjoGY!zk1?^Fd2hxXf#v@Q%x@^ zku+=6a;8LqOBd@knfb?-y<$YCZ%!FpppcSomXXfwMlRhk;kWbMi2JH=UctS0<3TqO z;yx34A8cVZO(Rnxu9M{X#jgyf{YNAXnk~=w8RA4IxU7tm0C_GKEFktJ=zp&ce-5~x zMw#u%6Nk4w^`&F2Cmz0`Y1#wHGdEib9*N9`+CoP&u=ZiUQ>seQ4w1{T(RiHz4phbNy37onUrPn28GEsg+}Ex5dD2G@m#n{HWb?vem0$<8~Zb^gnz z*2PUF5u|C0-DkKZAe)C>X*5`uiWyg{Z96#67cjE-@uX zZVV7>#FD2IoYTmUM?&~?x^VkzZVnl2pdW3QQy>Y&L3T!tsr@O;N~xi#JrH3oM4`?#-Fr8@qVbdykH)G} zw>s1BO-k2_V4XWADk4B-E=;NK^^{~L^>Ia|C7uw0w8s>i2GS=RH0R1m;OVdKxECLs zb)Kz(xOZ?rN}q_5vOgdB%OflOT=7j;^jwJ5Ir39X1}!3E^c3lv?^XKctSI3e z<%AcZ$`3fyU0imP(Id^}t&VoMZRAtLp?#(2&*!FP;yjjl`kLGnYWmP?WFH(+SGuWT zAMg0|N{$={M*C;kFK@NZ)p1=P9B8*l-JTbEoF?7vZg@_HjkwXPwg?K1w#@6EtviD1 z|26+LDB-{Vi@ExBVTqr1qUP*x3U@O~ehb(^^SW{fl;vYAZ!!I+nN%3(db6h!iNCBs zx~+AO5v3yyoNhMH-wB_toW0w><$}CfDpO8Sd(xiIra2no0&V}ngYI?QI13Lqe65R{ zvsy8&cF|hAS}ErpF6zGk(*I(_)48o8x-#dm=~wW_az?j#bya+(4a#jMLY92ub?A4h z$J%c5ZwjM@9^pJs-WEC!UBU9}aZCY^uK4|#LCJRYOusSM=5ra)9qD!i!`UavB==HT z)HgYCb=tfiBA1;fciPzZ$uT_qTc~7EY;z*i&P13Z zs3MYp(@!&?hjAbvZra9qZX4)xr+sB$kpNUXI3X3l;BTzlYfbRPpZ;>)<#0Fx2$*+< z(9|vj3OLcqq5XhvWu$;1I5ou;Xk2ifIp9LeyXOiB{5d9HW-^Vlg0wvoJ5uT1m`s7F z2st<@^$Q}qN%y3@IW$i>qq3ODUsXzX#aSm5E5D9kmF;YJ6eNDjeQrbVxhLox@hPry zcYe8DsVf65l9H6Fm3-+B*-ZD3fT$oYr0$6`PGds%>5S5Z?*?Mc0tTwkrX zZ)BfM-MQvQ*|u@}hQY7Z-kT=w_kNU7=zZM_-0eO!^7x}+RlGGx^f6K_#q~+ve{HO~ zsj?*hb%vNPTPwtQ5l_lZZ1?gTNu#l^lha?0Ej043_ulnH^n&Q#{6Oxdb?4WfqJ5_I z+KTM&J_2qBlFz! zY6kr;8?`iFM7fU|IB}zR&Qr^GmlD1l=leT{-z#se?MV;FD5_X{ zQ94uM{IfGzJ!fi4be2h}>?!Tte%E1`wC4HE;H$o%JJVTzyPK(KeDQES^yTfznEH)V zR#%GK=wm*&HfF_X(`#bbYaTY`^hdV%$EM`T9O^hGa#HN?)4iTbdD-J%_DM^c7I}?i z^_T|TiJP37*K;WUZ6^5N96I(EY^c3*TALxQ&(<@ln0c#srP)D;{o7WVhguX*(pNon zH+!N}3O%K2QO0Rj;iL!G{Ld___yI->{S(ZDBTJ9o$vDw>)2lyAa_l-64$BPOizBu9 z>C^9*m9pPQJ$>CX!gjLC)-40eUL1~BZMe88d`ns0`=_l*D?&sT-o6!kVnyCokINGZ z(sJ_h#GY%&w*2gnuY7v<;yae&*9i9G&pN3MdDT^oU$fuWh^~5^#!H>!+7qq>aRU;E zU!84EvGyw*h?jdg@VLy*qpM-#@E+4On=ds6&(TjTjy!yWAsd7JWW`i-QYVS)hxypH zDU3a_cY={#h1>gOk4kDD#w zG29Uup9ZAA{!w_`^^30S>+OF#`WT9hT74}5zs3vg>fv=|iHhrW+1}zljDJ)y*>T&H?5uFk7D6MF|9higsJ-;OuZUf98|{JApaX#b7QU2FBbE?JRk}IOg7q8}kyLO!4hv+|U{JuG~R7ZQ+P#Vs~f>Phy`f85maS zoL?K)F?mh8dxh_H?Qf=EqQ#7ux9(}%PI;fG5uEi$;ZNOyT{){%ymve)TO!Z)K0W1P z1g|kvjD1e(!qmmCx3yZk-|f5RxFjJ%y}h{0wtCzL!%fP)bSpzK`RpZ0r@$ri+2^L= zeog%^z@vlZaYrjA8p<(x3&(uxt986p?(1`IM|>MgF0aZg!ZM@dZo`wMyxX5m zO5SemeU|tjP+=*1duE&W*`WLotrU}2t9t{IEs_^~t1f+7$g{ds6#wJlg=8_cuNDd2 z=I}eB*K&HSg&?=Rmkyradn|j)UNdD?-G=?n$+|3;CHMChOt+FqKN5andOB8J;AwBK zd8jgdrQ-IS>_pX`#4AN+8t-cY@~uWsQh5=R<>P!(EYS7M-o>w1%CJpMo$L~|C0CD| zifa|I9kWec_HTG~Gin%F=9b3%vOf8t)#%9}8iyMD=JYe3qc4kJ~ zjUDUdW~VkN_K#?ESRV~C7naP=*2J=OQE>%YzRUfx4pkaO+cMz2Vfcxn4@*vmDZ1A6&lM zPpLHcNzUZ0-YQ*>R%KMLdcV)-`sqzkFRQ`#!`d@GU;gm-7IxjUxcFUHem0rRmewj~ zpI6!Sx5bFImg&_CbXxA7kuPl=_0mQ?wz*rmdDOLlMcWUx72K!|2ze;oe6?|A-9!#}Z~7>A z<*6P5R4uRXN)G9mG4+ zW$qPfW+$>Pr>jj8n=YNFzErqm~`o9+ZBG)`0Sr<5%*pMrsXeIef_*tuQcIh3w>#dF-X?CayP1` z-}-ix~ikc1n^ht^$8o0=FntdhW6o?lzCbJBUtq_;EAT(LV|Gj5H#Oj+);>VW>T zq;*QEwUy4J1Jwd9j&XM?iThmk_1xQX<6QB~vgB;?IWL=KDo@1R zmTJg58NO}lq}kb14=o+NBGpF>+y@Y`RC{3E-zwQ>z7$JuKliHUX_xzDDb>Xi={NP& z#;{e=JIV{T>E$%(&tQa?98a!4IBDl!DOoQq84g>YXH8L0O}nC;5Y^c6V4(A`>x0;c z0e7FSPH^YX3dQSfX&sSyaS5NzXP;1f4;~NZCC4t+{@JtUi~_bFOsPJ$78{W;r&;C6 z$7kaoq@?htTO&lW!@60SgQW*r}xdVN4V*X(1bN-)pO`phfbxxJ$p2CQcI+?Z1J2b-i*=L zn-@p*vvoXoe>ix+_-(?9E?@556Y(E9uN?4mmeyFE>d~vcNhN!Q^O`)Rz7HZTc>{5& zJTQ6ZQOsC>ucb6veZzm-c5(~ zXvv-3&7VGwvYmS88S{AGmWSu|7LLy{k>Gf2?+w=eLvznk?ZOB!-J0@u^^BVI_H_BA zx`O$~-mxNFce3`WNwZ})vDdAX?P2dsPm%OqzL0YvGiRO6`ttbgYqM~toZ^BzFC&g# z_L)@l*k!wNLix6~O4$LOLKCI!E6j)6>Dn@Ad(^xW7tTnxQZ_sBv-f553&+~eOm@I( zztO#>rcb3l#svInfA&LR?|vm;^QRXV)F~fb6V#IJS?Ty@TYvjN_ui~4-#OyWZf&(Y zzc1uSWx`uj;`ByjZvxJmqUQVac{S&J)vCfHe%pCo*{KdKFqMz4;YuPO! zBS$R$>y3qcN{PK!&f#>K(!!8PnKOl#r`G)$n*OH9rPh%*=kXrZ5SiPRkFN|AWd%J9 z$X2u7Gj?lrfYbd*#jb*DyB2PAXPU}5pWN_$@02ll>%W*jpta?Pl?|)nI*WlvgfzaN zeXv_YZT#}qJ?=^?eWL4+`-G|%sDU0={va>CX;;7h^v@=z-=>~+vF*#SDm(>Kkg<-RP~VkwphX35gbH>>4K7BI4ERx#w{pW&WaofniM zZq$3#R6UaVq^hRB-Y|<@$-E&KpVi+P`>b%juF|Ap)@P+xIR_lPhj%XW`#jFON#;gp zH;<>7%R6>WD_k?*TWj&slLq(e)xhI*CbLr0%G)!`qs3H$v`e~pef#C2D%={*m50&m zaKl^n?t6-kcAW4jy5zgZ-Fxzq3x=)_CrpUqxut_km4uOYwTTN_GQuAQib}i2WV9R~ zi1|}J^nFsCDytscpxrtu?Z_zhx~U<4ZQh&I2eud=DKu4WTj9zshVs*{*ty_otnM0C~f;O9U?L}JU^hh9Wlmu@p7MaTI z{eXK<9;oa@C+e#)i;3v0AEeQ)h zHLG5?x@eqo-xE36^DlIR6680CN(Z*2_4emfwcKh}>}oVU@xcA>se3P`CbU#f%)~lj z?7MARBxW-CL3jU_XXWz~{WG3aEIc>g`*2)t`q9i0?IV3Ix%ZefeTa&(*?FwLdti#` zv4x2;?+WMVirH(r_vHOJnCb2?=g%FhOka3Aq=p>p9fU}<$$H@76-~FE zi-%7nPJRrBy#$~vAr}_6Bk?(WWOAhzM$Di+hZftM7>qpDU{^p#TzE7&(Z5)rS zUaInU%OS>cP4^}7+sdRhul^|}M-$m`;_#-|9N)AWQr+tsm zJN`Cb_uW?8gv`AK+|)AnhAEmVcRsnOvp6bdEb#g=%T9LAg^%93wQV<=btbQPp?V$o(Us)ca}4PNRX<=T|k{RLt7l6@Nve zrlL1Eb*sz)gURvf69%pr9v^!r?v$0Z*mT3~P0k+g8r;?PZELeCwi4Goe(w5{M<&f} z;)}|jht&^{*c_`5f_caRX6=C()9PY{ zXqM=GkJMQ#k&obBi_Gs%s;diF{7Z)v(RwTedA3=-l@% z#fLL(gUr3)GoNBxW9CVzsf=IKeCwO$9#g+r`5L#CR_%@8r9PkEJ#1X0!9h`%qv9DV z;vIdQX2}^J7AYCFz3nWy)01o37We&3<23$x8vgv|UVh|_q((t(icI#g2X0xDPqoIJ zt~qk4rZ0S-f5w@L#)4tjE;Y5Rv+&Jo>#A^P+ux4zi8Finrk!nBRG_k^?BZT7OZ(I& zzXsNsq+YGAB!gZC29G6Y(ry8*`YN1nJ>2(ou2C3@ah74 z|NS~CZ<*SgJNG^)?QO_-0FsURDV9IM^B{F_;1!vxRTblWG{F>Vv)hoP*wJp`zYj4r69~pQ&d!JhFybmv~CkOMyay%9K2JA$4 z=l6b@)9Sv-alx6Hn#Iqvbia!G=og1e#?G?%xMFA709XV`X8^6~*_ht1^0i zMD$rVrWH!oPWN2@{8ruRCGU(HXB;iwAU@T5{cX$rh7V*<%ZI-Dye!*mfBJ_f8%$$; zG}~Bip?Oy0j$YU`bNuLRmzsb`g`mL2w+z3{zyIBMZ0=jn<&QphevaEe(*ZlRf^($d zRMNq1Hdh1B;jA~6yXkN8q>XkgJC<~_l8FY3F=?;BV38E6Iq#Efd!50v7i-x*Dr%Q6 zXdU)IC2pmX;L4ZLe+aHU&>jSSxZ?a~y8jmM!HJTqTf0@UjlcnR9T8Le)A*Ksf6IRFyT&ggvo1cL=1G$q<^Irg zoI~Bcp5`1Uv;6BK_d}}`oy&urgYz@n0+-tCSlAV;P+aJ-e}mL@tyQ=8sUFV>cUf~_ zX2F-kMc194m&XpYgq}3~ATQOv_I*cP*{JV(3dHYk4~p2RS+aCmY7z79;<{SR?4%E^ zAHo*OeT!S$e5*U5+F_)}wLA9l@7kua&&K_o5)liP9?{h;1rgty%ANyMxv^S9)JN@5K}i*Bld#Q#snF zPQ8iwv~+B-nzORcw0oD5=gtUnEKeyv{KVuL^U$6P+jHmlmqY~DM6*YnT~ceJa^!4) z%-ce&WNGFRp4%SpCy@geoF%@rJutZ-8I}$bAE#ueAYS~*PT#_LxnA=Qre{BsN*hrq zF9F`)=X&{Blz2;)lgRicdaG#K3vUUf8BT>;YAb4*z%%-+R}*Ar>dwvkHtDB=Slmi+ z_vIOdGAI8$Qqik#JH}4MGgR!t$a=X#3)ycyC-IXttE(DVkNj$$Ud=JEVCr{mdzG28 z?R(AXmYn+7w+9-T1@_@qQ#-oit8?$yHt%=5X|Oz+I_;N)%3<7Z9T z6Mo~2%H)UhMu3dtlTNd{gX8-Bb8#+Cl|an*Q?S=p4c_d zp*FYuple%Umqf3xSOh1$$DuH3pv3roaZ+**_)=(@*3XxXyeEyfCylklLGpImyUq05 z-UFi2OUnGb$JAnLe##iwU*6MGvZTML`MmnART-N~C-~LBh--Mga>i)3s(y7U_>WdH z%#D@4_2|4MVCQrBEPHuzs48Ktxv&E<0 zK3;7nntwPeELeF;RCVn2Mx|_4R_zpVSNE}F=fdYF=MJ-!0(S__N*8Ny4nJ+s*mUTv zmfECDjnnEoQwqJe?s%xC_Pu0kNMYRJuv8DZdDzYg_RAM)MwpFDF6{ZtE8&E%_`ZAd z-jF&m1LN7sJ{|74ypMX7irpO#7EGDW`mpO&c_ib;!2Fw>trG0?T&bs`eHZs_^6<8F zbmgXR3M}l)e$;fes_bI&+q%)><4%n@niq2F(#5ii#zs44mM+pPIydXt+A1aRpWMy( zxypmuF%uPcKt zac+2%+N7ot{!76_%H>;c9G7_&?!)d@xO^$Tg8gPM2;+sPzW1`Xzj(T_gsJ(acC&lb z$s|#^0h6;utfKu=&qo#~UompMVBLAIPiN=zl+`Dyx@$f^?3EVDQ!NTFDXCKvUvE-W z@6&U4+_C$4$CrV-?T!vt#rA+_YfR^R)Q;>k;@GvE|GO{x`fZ+XpAvX!$gZ-3yZ599 z_Jm)`J(#QcU7aY9zeeYwdaL;RJ%x>Qe&dyA?3O}}MN8%QV&8IAED%f>uh7v8#!`?}+ z{^OJ9_VsY3dATwxvhT(t#KGYm-M_PkyEiFoM31;W=FD zeWm@$7rdt>RbozWE^#X#HXl4Z@?JwG(Aa6-_a@KQhDC2%_w-Enq*ZpGe7a>?^@}XY zR_PzC{Vw`bwfgqXt~LeF8EuZ=vH4nx@|_!rGM9A~efp`%p);F&+2vxVpLdDh zZc@AdbyW7A7iY8C%NIt6fgx<)b_+AdU7|Sn?;OClrekl3pMS7hwL&2z@BX3~xfLIM zCXA}6S#m7P{%}C7)WeL@-nA`%)qJsg?d{N|(B|Ar@JaQL`a;y{E)u=qv0(N&&=uPp3IDFlc~7&EbpFJc*?;Cnn$%qy{UBA zlW%EVaWhEi#32XST(QDUk1i+=zwgw@jxc$)>0oJVAO98_qo+O2`;)L;dhqLWEX6%P zz43VH-^0&!WOSdYSt4oJ%DV2t7_Q3AxOE%6lc;u8Tlc%&N@xAcS7dg7?reRU&dR9H z1#e8LNxUk0>AKu03#Yd9GPQ|r>^;hEqn8f6Q)1AN0(PR+99vE|Wa70c>u1qFdET2CvF#Tlx~QLW%9!?mF!GUPYM1 zx%pq>B{eSw`-yE>m5tT*k3G6GP%n1&{VQrwmlkG!e6%R;wwH;q!(`Dk`~E55JyDqd zH2xd%b~U}2^0fO=$%j2HojDy_%`M7}UYfdpifQ$(*kzf~3SPW)p|M~qty5m{Qt#!= z`l)|f&-gJ=h?i=lV>%y7BqKA^S);m5$49lY9iw9^JN0HOUs|J~=h|7{p6xK@a#U^i z&uixGeKlQqI7@_OfOw~Y=L2q;EWab0pBZI-{l<3s!9C})`u#g5cTPI(_Bi`w-o;7a zI$Rmd6{N`dg4vwx$)lH6OLcauk7!kDfBn3Byv=?cx2(VBe7tP$oAK!HeJAZ%&sM}; z?CI@wJz?>|_gZL5$C?JMm?ATS`G-W)vf-OPz`Hoz{qKD7J+gFmUCB=UYSo-FNt1}} z`39}2$n8awnZs( z`VwB2CLD^BI=U=qU{pn}YgzI+@REmoLw{OJWv@)5X<}K?C24hUw!uC4TABk3b>C@R z`7tN8==Jo1g#9P|7SDH%7bM1S_7^ib=6cnaU3}%00Z5UWZSL$* zn3P`m()&^*%l2U3{g?+A&CgVNc*Kpq*&bP1X2Sbg;?|YHYVIl$+Y&A77k{z4%2jR4!#-M$ocs^zVOkoNl8_RAvMM|*v1zj35pW>OkpsIvgI zuwCu#!9=yu3A@_s(^W4khtxD~Iksf8ra1GAcX8D93{lWanB~)WS#*B_ruLnF!>4}Y z^1bO*VXJaG-S;b>y&gGbCU|lIOIubN31&#ZV(^oDGxcS_x49j+wm)ShT~kcywh)~J z_q^*fOM*d&Sa3!7H9WDb6GK6O*pNUcMf@!TZ{XlB8u0mg{^E)5@+3C$(T%di-~M2R zfUI*<^j`sfTm0KZq)~vE-~MYw{Koj#(MYTKH&noJ11&?^1)mFl+<7KMyREPZML-O`Jb2jexhlfJJ*g%woZ(Y5edq2E2g}F8>|^!~SiH z{}sE~g$=`=$HhuM-F6Gvk{!dzI@iiSQ4f>&G7(r*DK-lHTQ5Sxuu(TfEWuApO!4pW zV2>pDdkk1*!P*7#>s*oHm*yOA;MzLc+PI01juEbHpl_gSU||A# zm&b&0qD{eBy4%s+m@c>t&4JEz!d=*Q-cqoJ4L2}^gL79di;P~*2@LIsFr5H=M{kaf z&KzBBU2tGSQv>~wQ5a^Ug}c)nm@cC+j81c7*fL$JXaLQXZ6(+Q@7AzpxYAwhSaiEb zPT&OSOh<;38_mUzIReA%9BH-;Ckaf%4s*m<7!z~B+%N`)V=CBrTooIQiMV1k%mK3o zyU4;}#RZWkqwt zLu0~02ylu7jn51Wjl@^sD?&L-xI6%66&e=CUCu=aIvE?JXtQduAa7u ziN3a>fi~_)vv#qw#u=`FmAO0`bV@LX2l{3#cId|X=Dd^cz9^h9BJN6CoMIcVP{~YZOk$@ z0KwUv8`2gS69zj7U`FuJS;=6SEr%Bw78=0;<$;mm0WrMgp#foBW#JJSTCQ3+&E1-& zg@ z+gfxc?&JV^nidus4xJ7jXKM}Y9d4G)Os;>i7ZoCtgwXgW?9 za5X(#j{($NxWSw-)DBBxf@7dNO&xR!1Rc%6is`^~V*tki?dK300E|Kd5AWdt=LHS8 zB6Kk~a!Cv~IFbi!hdg~ZJ)AzD=Jq>aZDWR>k&TtXZ};mO>6zFV(lmufq=j=|YW0nS0iT3p_;XxxDf9;2PD*~jk>LUc13RZdJr3-T+)J3f8qRhwa5vO;@Gt<@E!gK^2V)Jx)fHHsWbJ6+;-&5FU;vmr8ID#=wu>$J6SRO0CpsJi zEsh2r!i^4y1VcX>{~}_RM~6bo7!*Wp=yo7}(j07ze*sX^W1!mzfs zbHhRJ;52JjkeebMkhq41FN*;=TIAsDj75W8Wc4NBYR=}_r4$#>ct{(qYbN*ME zjVpKA^1-RiFMja5hSR3$+vpiv8~tuSU7uz}H`HbP&X4qUtqgRm4F7}RU}dGNM>E#Z z7h2|jFhpzXndlfA=;;bSG&miD$u}j__z#Y#qoZd;qiNd+k2rv>23M@xu7a5Z4E%Rx z*Dx#({5}PKD~o{4qXfe$z;Daz7lt2VCe$RA5*#5K_K_VOiiR4(=K~3Yg!B z@z^!V%8}qO7&r{J5CdYEgeVSv5=#jGcUuF76}Dm6+5rrkHCzN-hl_&i?J?lzVr(4x zdjk6GctF*c^>?jf0Lx+#Dpy10>gZ=8Dp?2WkjpGlfP)QS>%pE|!@&hK1}B*|pxPVJ zPvt?iXAk;YWzgTMgZ@5=O$TST7_9IgXj>5t_=~cLU~F*gQP^74`rwW2;*dcUZHs{4 zIWofSVPLM}&@|!paBMinRS<5AftLF;=<_47k=VnD#C}W^T#tfo zU+O_C8ma+|?UyoW;X~^IBmAWdw$;#j;9};NGT4Gc>w)O^OBo3Epp423zoi@cS!i`b z%b*nwEd!zVmohK_|564b{x4-9Qvzkwa{*E1mwI3<`K1h8@BLB+Lgz1KVC?v%3|uW! z%6=l>$1unO{!dL&XcHn}izpYxWRcGhTf@NY8MMJ080?whU~3BaHx=0T4R91_Ml#@^ z2>LAa8d=~e7=H_X7TP8JtjI5)g+~>B7J8m61}zrqih`p|M$rg~Tf@N?v`=D73~WKmB(_F?EohU()=01gnTG-Yg%q#{ zFw8%y*%I!DGmZ)%>JhbvgL72CpiL6{hJh_;jj(NDtWe;Wi(!fwv`FHZBQPb*O#JDo zWj{@B5+x%+iBzubo7Fu@maqi22M$&Yv*hcF0o`dBv|y+t26Vye^8{PNz!tPxs4E6^ zr}1lvfp|#j!dNu}d$Sto&^(Z?7--2ESP)$9ZAZG$A_!hmcLd^v76WxhAYN$q#1`a* z77ukHF~JMZLC6dDKuZM6vMoq=1khE&Zg;Bqrd&)$y0AxaSVO=&67fRMfl4D0FZ3B= z3-Us5fx06Fyd#0Gkar~F1(l#KUj_1_7MO4pl|b7ecrkuE!67zi#i_fHF7$DNcO74M4B{pI2<9*f*et&87{sg0*Bv9^ z9Yf&-cOwJ3lrm_(g#V5~yvTop2wfOWaIk{7!Pk{Uyt6?GZKbk<{h54|McLL(o1eGE01Oe{^3hxBOONB6xT2dj`*8u5~GLmKhw zfc;Qc8u99aEvPFk;FYHEN+aH?O1QL^LS1Rpzef&(Wl9**h2t#2>(6gzm}3dYBN@a? z+OrJe)#K~R2zX^EyfVW6<&O(8h!>6fhJ-F0(Q&Y17^w?$LE(5Li+D+0nCnf$^g(UN zD=XlId7NUIO2u51Ho&_=bemr$#DVZpu%`H8S#?w3g(zZyqZkm zh54frhI;dyHtOHWLf!)rNEg~I4pt1i8A15b6vRu~)fB`FYry`Uf_M$U7UZ2G;GII@ zoq~Av;LEEe{*O0F}Rlqxy!aEi5f=U1; z4C%rwT?s=LzMO}2p)caF2DXmRD~EW=@kkEwlH-va;xz)bA+MZ(SB}Cfhj`IJu?a{Q zX67?6kOy?Cd|ZcgAs4|*>dGTtvYq7-FAi!!dU?ca47Q-Iynt7p!Yhw>(dg4pw6i?o zMY?0wBVA~z@Tfvw1;k70Dp2}Y0r8rE+E7{e!b2 zI5?M~SIE=;p$x`_p;zWGn;BXLbH|}&Fv}ZS26MfkWzZ*vmO;-LS_bpAp=B^O4K0It z&d@TLgA6T$`N7aOfMfU2dT_KGS`UtSL+ioOX=oW7qlT8j@nh)mU~C^+2IJq*&%)R= zv<$|Lp=Hpghn7Kq8(Ic^VQ3k&)1hVa{-Mkal~HF|%>SXD#gH;M662tZ@LNlMe8ee! zi&Oj-M}7;>0s0Rv@LQbXxAJ6N+ZlWn7?+XXG6`MiM^s)oZz{|)rc-#QBVO2+PfLEEqtBiP&u4O#ZRYtrhf8}jL?F?fl4pt1i$=97t;hl|mVGYPT8}ZV? z7UZ2R;GIq3osD>-(7-zq>CQ&H;A7ywizBX2V08b2I57!_+Dhsa7@Nw1&q|yM7(4> zYa(7)1NOEi;X{m|2s$6zu}wFnZW#o%BxM`)u!-jQ+TxnyxJ6AZ6Pmz{#RSb`}qLUg*g%q zRt)p!+m#ODCGAQF@h$-SVLR&}UI(xRd36N5Iuu?V#EYV_p6X3F4(j~E3-d67m(LFgz zkMt0)Bd85|^#r_n6ka`H|6)YGp@(>pg@LXsVo%InfsjH88o%p)? z0$zOzufDK<`MUau7wO6qx-e(O!3uuzbqy%I1{7Wc#LEP=A+Lde*MP!nFqjv$vw@KJ zA<@n-FQ)PuQg{t1yoQLE#n&|y@ETHh4F~ZO*JFmi@WR|12dj|R2=S8f$_Vko8ql7N z5U(@Xg1kloULy*x5#mL&a%?8zH4^gjJqzadR9<7mOX?aUUQ*W>@w)JJjRm~M6kcP* z%a4;t*BJ4lj4NmxvMYEE0gnn6e*c;vUNX-yLA+$1V}f{HL1oBmBH%Tl@R}fA!hfeA zT@%EM<`B2qHB1pN zDw#}tF9@%NaIj)vs)x{>i+D*tnu~ZzKbnhp*`PM$oh#s-OW~c1c+o-mdr&Fr0zV#OX|);yzYG6c>>;f6yAA=cd+g}#EW#l61rw!H;$^L@p;V< zFF6jHAzpGEG()@|pg!a^6Y!c*c+G^o{P}J(A+PxkWLNN7hu|f3%_+R*6kc=03w<8y znhSW%DZJ)FUj96tIpRg072}I^;k6D9Rt)p!^I9NYa$K-LyyUoGfq2Pr!9u`mLE*JP zyr;p}WKU-h^Pv`q7iG3!7*TH8H~?E%V6vrS_b3V&@vcvhMpgc2Se*YUmaQo{b*>J=RcHr{X?1eKa~0W zLz(YCl==Nb*+NuCo!eP7q#n$)EI}FJx0ZbWv84E|CB<(ok>5fl=(m;vzqO?Jt)hWwVyBWcKQ$vl!K@LL+iZ)wPHiSLWX zpmB(X{1)lXCcYnoenjw+x>ksHIw*%DycObwJw$B5>t5J5kk?AUYenI;LcD1F>o7*T zR)`nfH^Cuvp|9d#1q+{-j(ExIZ93wGJq~s0h!^%X)TIk}=@eeNkT-4#(xoF_^nLG_ zm8hNJ@d@6WONsH%8u60jt~KH%$6agGzwjCz>RJnUttq_Lh!^DzqfY*k?YlC=EHuJ(2wX+T4MY@{_T^KvzQNd5Xt}Wsv z^D0}!OXgL!h!^@J)U_4x+ERFJg}nYeq-%?K(RHr~kvGHmOz@Jr^ARs;&+`#4IsVN@ zyyW;dU%)$`!aE=FqWd+f4Uz7A#EWz<5cj9RF#rdvkk=0JlI?7Vcwr6L&UT2`A8bKh zI{~j9h1X8Vi%mtkc8C{wmOgRc1RQ6my!MEfY-fAK3$Ie4u07)A@OAA4y!I4cd&G;f zC`BS(*&|-0>qO|nNQ8q`$h!dXlDZ2JFR8l#@dkj}kavNAcL9ZW!607Z`f~x|MSirB z(1kIN;KlfM<$!p}e9i&!lKGqi;tk~MItX|jD7+4c7gPevDoZ#DI3Ql6dy~+GV=@ja zV5F`i;w9sTBjP3Fh9lx#3@SrjM**)Rh1U`BA{7N1>R(61iy{d|%!k5ppWuDHgosy8 zh!>W@9(6*zWZZBk5v6 zgB2`%URT6R#w%CEi-X@3x30;^^61+D< z343-!yfSWF!0Sfgbwj-10D}8N-4HK|*pkFN4$N6`SOX(<*@%~H zXLv{dG%(Lb@Uju_1h55p*#cfRg_n(Zk^l1NyV-~rv2gYYviWbbG`Z80&w1h0h% zdna|hk-d|;-pJlbU2kOXq^>t<@8F7RFt0b_Mfab5(nq}Bh!=g2h7t2i@X81WtB}_R z@sfV#gLq*L=x08N*B@*_Kl2gr`cQa%g#L{Y`KAxzMfZVa61u+N7zD2+zny&%FRAN` zcyUk*>iQzyrF>mq0k1EG*B9}EN?>`7#z|kqi*#QQy6{>EhZQi4uj_|+$#KIE@rHrj zP}dLfhJ!80>nGs#qwxA6UKB^|%~3o133*==y6{?v;3ahzB3?3%!e`zTz+HEQ?n1;H z!Pi|V;9W@JT`1(`&mS&CyeNLx?nL$suZ3{1f`!k!2=T&mfrRksIN|rCix6)l*avwR z33wM#coz-oUn2ingm{rXCoTTpI*;cM&SmJ_59c4s;5EU}dN5xfS_X6Jq4Q8M&mCG1 z=Ac8%V4gPkvoOQ=2W8ZL^+)|mwy!_xS2Dl#NBz1Cd{tFgk3Z^H z)V^1_s9#|l;9$kD*C9my!$JK@=C>TwuVjA9LH$bRw;Vygawz@ELH$a^3nK602>Vq$ z80o@xCU|f1bpt580TkW<#2W=Z19<}kya5#6fI+;3Jp~|M@cyL56yC*%m&ey# zEZ|*C;axnKm#+fvlSR7sLQx5{NgS*~UM}J#`&;8~F`(7Qyi(O<@c-{dkd3_eE; z>x-k$fl~wLMAV1DPjr# z2FHfyG9JC1OcbyX%P1^IL=>wA8xy|35Dj9}O6AHtJ;zYm);A4mIQOV(u1^d4(m4qa)L!}@E`lhe|7$%byao8wB*L`JqXTUqdOO#KMW3VAzPQ&`v3Rm F{{W--|3Cl$ literal 0 HcmV?d00001