diff --git a/.env_template b/.env_template new file mode 100644 index 0000000..635b15d --- /dev/null +++ b/.env_template @@ -0,0 +1,10 @@ +FIREBASE_API_KEY= +FIREBASE_AUTH_DOMAIN= +FIREBASE_PROJECT_ID= +FIREBASE_STORAGE_BUCKET= +FIREBASE_MESSAGING_SENDER_ID= +FIREBASE_APP_ID= +FIREBASE_MEASUREMENT_ID= +CLIENT_ID= +EXTENSION_KEY= +BACKEND_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22df66f --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +npm-debug.log +coverage/ +node_modules/ +package-lock.json +/vendor/ +/*.zip +dist/ +.env + +server/.env +server/firebaseServiceAccountKey.json +server/.env +server/data +**/__pycache__/** +server/.venv +server/model/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..edf146b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ + +# Change Log \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edbeb99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 Ian Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index edf5490..dfe19eb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -# Manga Reader Browser Extension +# Veebee Browser Extension -**Install: [Chrome](https://chrome.google.com/webstore/detail/manga-reader/eabnmbpmoencafnpbobahdeamaljhoef)** +Uses Google Vision, DeepL, GPT, and [manga-ocr](https://github.com/kha-white/manga-ocr) to help you with translating and/or learning new languages while browing the internet in a Chrome Browser. One great use case is for reading manga but you can use it on most content on the web. + +![](assets/example.png) -# Origins +**Install: [Chrome](https://chrome.google.com/webstore/detail/manga-reader/eabnmbpmoencafnpbobahdeamaljhoef)** -Note that the extension won't work on certain origins: +Note that the extension won't work on certain origins, such as: - chrome origins like: `chrome://` and `chrome-extension://` - the official chrome web store: `https://chrome.google.com/webstore/category/extensions` @@ -21,17 +23,30 @@ Note that the extension won't work on certain origins: ## Build Backend Locally 1. Fill in the `server/.env_template` and rename it to `server/.env` -2. Set up GCP project with the correct (Optional if you comment out the GCP related stuff) +2. Set up GCP project (Optional if you comment out the GCP related stuff) 3. Create your `server/firebaseServiceAccountKey.json` (Optional if you comment out the authentication stuff) 4. Set up Firestore (Optional if you comment out the Firestore related code) 5. `pip install -r requirements.txt` 6. `python3 main.py` +# Contact + +Please send any feedback or inquiries to lyrian1029@gmail.com + +This is an early Proof of Concept. If users like it, I am willing to invest more time to add various features, such as translating an entire page at a time, adding side panel to allow more space for translation details, and chat mode with GPT. Another obvious one is to add more languages. Any other feedback is very welcome! + + +# Acknowledgments + +Learned a lot about Chrome extensions from +https://github.com/RasikaWarade/chrome-extension-mv3-firebase-boilerplate +https://github.com/simov/screenshot-capture + # License The MIT License (MIT) -Copyright (c) 2023-present Ian Lee (https://github.com/ianbbqzy/manga-reader) +Copyright (c) 2023-present Ian Lee (https://github.com/ianbbqzy/veebee) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/assets/example.png b/assets/example.png new file mode 100644 index 0000000..c67c324 Binary files /dev/null and b/assets/example.png differ diff --git a/background/background.js b/background/background.js new file mode 100644 index 0000000..e0f0f2a --- /dev/null +++ b/background/background.js @@ -0,0 +1,156 @@ +// for stuff that doesn't involve the DOM of the web page the user is viewing + +// Set default config values +chrome.storage.sync.get((config) => { + if (!config.api) { + chrome.storage.sync.set({api: 'deepl'}) + } + + if (!config.source_lang) { + chrome.storage.sync.set({source_lang: 'Japanese'}) + } + + if (!config.target_lang) { + chrome.storage.sync.set({target_lang: 'English'}) + } + + if (config.icon === undefined) { + config.icon = false + chrome.storage.sync.set({icon: false}) + } + + chrome.action.setIcon({ + path: [16, 19, 38, 48, 128].reduce((all, size) => ( + color = config.icon ? 'light' : 'dark', + all[size] = `/icons/${color}/${size}x${size}.png`, + all + ), {}) + }) +}) + +// This is triggered when extension icon is clicked. This is the main entry point +// for screenshot capture. +// It injects the content script into the active tab. +chrome.action.onClicked.addListener((tab) => { + pingContentScript(tab, 'initCrop'); +}) + +// take-screenshot is received when keyboard shortcut is triggered, as defined in manifest.json +// This is another entry point for screenshot capture. +chrome.commands.onCommand.addListener((command) => { + if (command === 'take-screenshot') { + chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { + pingContentScript(tabs[0], 'initCrop'); + }) + } +}) + +// capture request is received when the user cropping by the user is done. +// active rquest is received when +chrome.runtime.onMessage.addListener((req, sender, res) => { + if (req.message === 'capture') { + chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { + chrome.tabs.captureVisibleTab(tabs[0].windowId, (image) => { + res({message: 'image', image: image}) + }) + }) + } + else if (req.message === 'active') { + // Change the extension icon and title based on whether the user is cropping + if (req.active) { + chrome.storage.sync.get(() => { + chrome.action.setTitle({tabId: sender.tab.id, title: 'Crop'}) + chrome.action.setBadgeText({tabId: sender.tab.id, text: '◩'}) + }) + } + else { + chrome.action.setTitle({tabId: sender.tab.id, title: 'Crop Initialized'}) + chrome.action.setBadgeText({tabId: sender.tab.id, text: ''}) + } + } + return true +}) + +// Create context menu option +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: 'translate-menu', + // chrome replaces %s with whatever is highlighted + title: 'Translate %s', + // selects what's hightlighted by the cursor? + contexts: ['selection'] + }); +}); + +// Handle when context menu is clicked +chrome.contextMenus.onClicked.addListener((info, tab) => { + console.log(info.menuItemId) + if (info.menuItemId === "translate-menu") { + // Let content script know that a text translation is initialized. + // This has to be done in background script in case the content + // script has not been initialized. + pingContentScript(tab, 'initTextTranslation'); + + // continue with the translation process. + chrome.storage.sync.get((config) => { + if (!config.idToken) { + pingContentScript(tab, "Please login first. Right click on the extension icon and click on options.") + } else { + callTranslateWithText(info.selectionText, config.source_lang, config.target_lang, config.api, config.idToken) + .then(response => { + pingContentScript(tab, response) + }) + .catch(error => { + console.error(`error: ${error.message}`); + pingContentScript(tab, `error: ${error.message}`) + }); + } + }) + } +}); + +async function callTranslateWithText(text, source_lang, target_lang, api, idToken) { + const url = "__BACKEND_URL__"; + const headers = new Headers(); + headers.append('Authorization', `Bearer ${idToken}`); + headers.append('Content-Type', `application/json`); + + try { + const resp = await fetch(url + '/translate-text?api=' + api + '&source_lang=' + source_lang + '&target_lang=' + target_lang, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + 'text': text + }) + }).then(res => res.json()) + if (resp.error) { + return `Translation: ${resp.error}`; + } + return `Translation: ${resp.translation}`; + } catch (err) { + return `Translation: ${err.message}`; + } +} + +// Sends a message to the content script +// If it doesn't receive a response within a specific timeout, it +// determines that content script is not initialized and it will initialize it. +// After another small timeout, it'll try to send the message again. +function pingContentScript(tab, message) { + chrome.tabs.sendMessage(tab.id, {message: message}, (res) => { + if (res) { + // if response is received before the timeout is triggered + // clears the timeout call + clearTimeout(timeout) + } + }) + + var timeout = setTimeout(() => { + chrome.scripting.insertCSS({files: ['css/content.css'], target: {tabId: tab.id}}) + chrome.scripting.executeScript({files: ['content.js'], target: {tabId: tab.id}}) + + setTimeout(() => { + chrome.tabs.sendMessage(tab.id, {message: message}) + }, 100) + }, 100) +} diff --git a/content/content.js b/content/content.js new file mode 100644 index 0000000..8af8599 --- /dev/null +++ b/content/content.js @@ -0,0 +1,334 @@ +import $ from 'jquery'; +import Jcrop from 'jquery-jcrop'; +import 'jquery-jcrop/css/jquery.Jcrop.min.css'; +import 'dotenv/config' + +let jcrop, selection; + +// Handles messages +// currently we only expect messages from the background script. +chrome.runtime.onMessage.addListener((req, sender, res) => { + // Sends a quick response to background script, which will + // use the response to prevent re-injection of the content script. + res({}) + + console.log(req.message); + if (req.message === 'initCrop') { + // If jcrop is not initialized, initialize it. + // TODO: maybe we can initialize this on page load? + if (!jcrop) { + console.log("jcrop not initialized") + // create fake image, then init jcrop, then call overlay() and capture() + image(() => init(() => { + jcropOverlay(true) + capture() + })) + } + else { + // jcrop already initialized. In this case, if there is already a cropping + // session, ends it. If not, starts one. so we call overlay() to toggle + // the active state. + jcropOverlay() + capture() + } + } else if (req.message === "initTextTranslation") { + // translation requested + showTextTranslationDialog("translating") + } else { + // If neither of the above, the message is the result + // of the translation request. It could also be an error message. + showTextTranslationDialog(req.message) + } + return true +}) + +// uses a closure to maintain its state (active). It toggles the +// visibility of the cropping area (jcrop-holder) and sends a message to +// the background script about whether the cropping is active or not. +// +// By passing false and invoking the outer function immediately, the code +// is setting an initial value for active which is retained and can be +// accessed/modified every time you call overlay. This provides a way to +// maintain state (active) between calls to overlay without exposing this +// state to the external world, thus encapsulating the behavior and state. +// +// If call with no argument, it toggles the active state. +// If call with true or false, it sets the active state. +// if call with NULL, it does not change the active state. +const jcropOverlay = ((active) => (state) => { + active = typeof state === 'boolean' ? state : state === null ? active : !active; + $('.jcrop-holder')[active ? 'show' : 'hide'](); + chrome.runtime.sendMessage({message: 'active', active}); +})(false); + +// creates an "invisible" image (pixel.png) for Jcrop to bind to when +// initializing the cropping tool. a workaround to get Jcrop to +// initialize without needing a real image. +const image = (done) => { + const img = new Image(); + img.id = 'fake-image'; + img.src = chrome.runtime.getURL('/icons/pixel.png'); + img.onload = () => { + $('body').append(img); + done(); + }; +}; + +// only invoked after image() has been called +// initializes Jcrop on the "invisible" image, with various event +// handlers for user interactions. +const init = (done) => { + console.log("initing jcrop"); + // Jcrop responsible for setting selection + $('#fake-image').Jcrop({ + bgColor: 'none', + onSelect: (e) => { + selection = e; + capture(); + }, + }, function ready() { + jcrop = this; + + // import jcropGif from 'jquery-jcrop/css/Jcrop.gif' doesn't work. + $('.jcrop-hline, .jcrop-vline').css({ + backgroundImage: `url(${chrome.runtime.getURL('/icons/Jcrop.gif')})` + }); + + done && done(); + }); +}; + +// Process crop area after Jcrop has set the selection. +var capture = () => { + chrome.storage.sync.get((config) => { + // selection is set by Jcrop + if (selection) { + // save the coordinates so that selection can be cleared + const coordinates = {...selection} + jcrop.release() + selection = null + jcropOverlay(false) + + // Send message to background script to capture a screenshot of the + // entire page and responds with the image. + chrome.runtime.sendMessage({message: 'capture'}, (res) => { + if (!config.idToken) { + showTranslationDialog("Please login first. Right click on the extension icon and click on options.", coordinates, "") + } else { + // With the screenshot from the background script, crops the screenshot + // using selection. Then get the translation of the text in the image. + crop(res.image, coordinates, (image) => { + getTranslation(image, coordinates, config.api, config.idToken, config.source_lang, config.target_lang) + }) + showTranslationDialog("translating", coordinates, "") + } + + }) + } + }) +} + +async function callTranslateWithScreenshot(image, source_lang, target_lang, api, idToken) { + const url = process.env.BACKEND_URL; + const headers = new Headers(); + headers.append('Authorization', `Bearer ${idToken}`); + headers.append('Content-Type', `application/json`); + + try { + const resp = await fetch(url + '/translate-img?api=' + api + '&source_lang=' + source_lang + '&target_lang=' + target_lang, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + 'imageDataUrl': image + }) + }).then(res => res.json()) + + if (resp.error) { + return `Translation: ${resp.error}`; + } + return {"translation": `Translation:\n${resp.translation}`, "original": resp.original}; + } catch (err) { + return `Translation: ${err.message}`; + } +} + +var getTranslation = async (image, coordinates, api, idToken, source_lang, target_lang) => { + // Call the function asynchronously without awaiting it + callTranslateWithScreenshot(image, source_lang, target_lang, api, idToken) + .then(response => { + if (response.translation) { + showTranslationDialog(response.translation, coordinates, response.original) + } else { + showTranslationDialog(response, coordinates, "") + } + }) + .catch(error => { + console.error(`Error: ${error.message}`); + showTranslationDialog(`Error: ${error.message}`, coordinates, "") + }); +} + +// if window is resized while cropping, re init jcrop. +window.addEventListener('resize', ((timeout) => () => { + jcrop.destroy() + init(() => jcropOverlay(null)) +})()) + +/* + * Display dialog box with translation when selecting text + * handled differently when selecting a text from an existing overlay + */ +function showTextTranslationDialog(translation) { + // Get selection to know where to position the dialog + const selection = window.getSelection(); + if (!selection) { + console.log("Nothing was selected"); + return; + } + const rect = selection.getRangeAt(0).getBoundingClientRect(); + + // Check if the selection is within an existing overlay + const existingOverlay = document.querySelector("#overlay") || document.querySelector("#testTranslationOverlay"); + if (existingOverlay && isSelectionInsideElement(existingOverlay)) { + const overlayRect = existingOverlay.getBoundingClientRect(); + + console.log("Selection is inside an existing overlay") + showTranslationDialog(translation,{ + x: overlayRect.left, + y: overlayRect.top, + x2: overlayRect.right, + y2: overlayRect.bottom, + }, selection.toString(), "testTranslationOverlay"); + } else { + console.log("Selection is not inside an existing overlay") + // use the same approach as image translation to determine where to spawn the overlay + showTranslationDialog(translation, { + x: rect.left, + y: rect.top, + x2: rect.right, + y2: rect.bottom, + }, selection.toString(), "testTranslationOverlay"); + } +} + +function showTranslationDialog(translation, coordinates, original, overlayID = 'overlay') { + const viewportWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); + const viewportCenterX = (viewportWidth / 2) + window.scrollX; + const rectCenterX = (coordinates.x + coordinates.x2) / 2; + + // Determine the spawn position of the overlay based on rectangle position + const spawnX = rectCenterX <= viewportCenterX + ? coordinates.x2 + window.scrollX + : coordinates.x - 300 + window.scrollX; // Assuming the overlay width is 300px + + // Remove existing overlay if present + const existingOverlay = document.querySelector("#" + overlayID); + if (existingOverlay) existingOverlay.remove(); + + // Create and append the overlay + const overlay = document.createElement('div'); + overlay.id = overlayID; + overlay.style.cssText = ` + background-color: white; + font-size: 16px; + border: 1px solid #cccccc; + width: 300px; + white-space: pre-wrap; + position: absolute; + top: ${coordinates.y + window.scrollY}px; + left: ${spawnX}px; + z-index: 999; + `; + + overlay.innerHTML = ` +

${translation}

+ +
+ + + +
+ `; + document.body.appendChild(overlay); + attachEventListeners(overlayID); +} + +function minimizeOverlay(overlayID) { + const overlay = document.querySelector("#" + overlayID); + overlay.dataset.initialHtml = overlay.innerHTML; + overlay.style.width = "30px"; + overlay.style.height = "30px"; + overlay.innerHTML = ``; + document.querySelector("#overlay-restore-button" + overlayID).addEventListener("click", () => restoreOverlay(overlayID)); +} + +function restoreOverlay(overlayID) { + const overlay = document.querySelector("#" + overlayID); + overlay.style.width = "300px"; + overlay.style.height = "auto"; + overlay.innerHTML = overlay.dataset.initialHtml; + attachEventListeners(overlayID); +} + +function attachEventListeners(overlayID) { + const overlay = document.querySelector("#" + overlayID); + + document.querySelector("#overlay-minimize-button" + overlayID).addEventListener("click", () => minimizeOverlay(overlayID)); + document.querySelector("#overlay-close-button" + overlayID).addEventListener("click", () => overlay.remove()); + + const toggleButton = document.getElementById("toggleButton" + overlayID); + toggleButton.addEventListener("click", function() { + const translationElement = document.getElementById("translation" + overlayID); + const originalElement = document.getElementById("original" + overlayID); + + const isTranslationVisible = translationElement.style.display !== "none"; + translationElement.style.display = isTranslationVisible ? "none" : "block"; + originalElement.style.display = isTranslationVisible ? "block" : "none"; + }); +} + +function crop (image, area, done) { + const dpr = devicePixelRatio + console.log("area") + console.log(area) + var top = area.y * dpr + var left = area.x * dpr + var width = area.w * dpr + var height = area.h * dpr + var w = (dpr !== 1) ? width : area.w + var h = (dpr !== 1) ? height : area.h + + var canvas = null + var template = null + if (!canvas) { + template = document.createElement('template') + canvas = document.createElement('canvas') + document.body.appendChild(template) + template.appendChild(canvas) + } + canvas.width = w + canvas.height = h + + var img = new Image() + img.onload = () => { + var context = canvas.getContext('2d') + context.drawImage(img, + left, top, + width, height, + 0, 0, + w, h + ) + + var cropped = canvas.toDataURL(`image/png`) + done(cropped) + } + img.src = image +} + +function isSelectionInsideElement(element) { + const selection = window.getSelection(); + if (selection.rangeCount === 0) return false; + + const node = selection.anchorNode; + return element.contains(node); +} diff --git a/css/button.css b/css/button.css new file mode 100644 index 0000000..a9946e2 --- /dev/null +++ b/css/button.css @@ -0,0 +1,13 @@ +button { + height: 30px; + width: 30px; + outline: none; + margin: 10px; + border: none; + border-radius: 2px; + } + + button.current { + box-shadow: 0 0 0 2px white, + 0 0 0 4px black; + } \ No newline at end of file diff --git a/css/content.css b/css/content.css new file mode 100644 index 0000000..7e9c3d3 --- /dev/null +++ b/css/content.css @@ -0,0 +1,18 @@ + +img#fake-image, +.jcrop-holder, +.jcrop-holder img, +.jcrop-tracker { + width: 100% !important; height: 100% !important; + max-width: 100% !important; max-height: 100% !important; + min-width: 100% !important; min-height: 100% !important; +} + +img#fake-image { + position: fixed; top: 0; left: 0; z-index: 1; +} + +.jcrop-holder { + position: fixed !important; top: 0 !important; left: 0 !important; + z-index: 2147483647 !important; +} diff --git a/css/icons.css b/css/icons.css new file mode 100644 index 0000000..aaed067 --- /dev/null +++ b/css/icons.css @@ -0,0 +1,55 @@ +@font-face { + font-family: 'fontello'; + src: url('icons.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} +/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */ +/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */ +/* +@media screen and (-webkit-min-device-pixel-ratio:0) { + @font-face { + font-family: 'fontello'; + src: url('../font/fontello.svg?19777476#fontello') format('svg'); + } +} +*/ + + [class^="icon-"]:before, [class*=" icon-"]:before { + font-family: "fontello"; + font-style: normal; + font-weight: normal; + speak: none; + + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-right: .2em; + text-align: center; + /* opacity: .8; */ + + /* For safety - reset parent styles, that can break glyph codes*/ + font-variant: normal; + text-transform: none; + + /* fix buttons height, for twitter bootstrap */ + line-height: 1em; + + /* Animation center compensation - margins should be symmetric */ + /* remove if not needed */ + margin-left: .2em; + + /* you can be more comfortable with increased icons size */ + /* font-size: 120%; */ + + /* Font smoothing. That was taken from TWBS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Uncomment for 3D effect */ + /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ +} + +.icon-github:before { content: '\f09b'; } /* '' */ +.icon-menu:before { content: '\f0c9'; } /* '' */ +.icon-chrome:before { content: '\f268'; } /* '' */ diff --git a/css/icons.ttf b/css/icons.ttf new file mode 100644 index 0000000..2cfd09d Binary files /dev/null and b/css/icons.ttf differ diff --git a/css/main.css b/css/main.css new file mode 100644 index 0000000..e7fdbcb --- /dev/null +++ b/css/main.css @@ -0,0 +1,72 @@ +@import "https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css"; +body { + display: block; + flex-direction: column; + justify-content: center; + background: linear-gradient(#fff, #3c76b8); + font-family: "Lato", sans-serif; + width: 300px; + height: 230px; +} + +h1 { + color: #3c76b8; + text-align: center; + font-size: 40px; + font-weight: 100; + margin: 0 0 30px; + padding-left: 10px; + padding-top: 5px; +} + +.btn { + display: block; + border: 1px solid transparent; + height: 35px; + width: 100%; + border-radius: 5px; + transition: all 0.5s ease; + cursor: pointer; + outline: none; + font-family: "Lato", sans-serif; + font-weight: 600; + margin: 0 0 30px; +} +.btn__color{ + display: flex; + border: 0px transparent; + height: 50px; + border-radius: 5px; + cursor: pointer; + outline: none; +} + +.btn__sign_out { + background-color: #4285F4; + color: #fff; + font-size: 18px; +} + +.btn__sign_out:hover { + background-color: #fff; + color: #4285F4; + border: 1px solid #4285F4; +} + +#sign_out{ + width: 100%; +} + +@keyframes choosing { + 100% { + transform: rotate(0deg) scale(1.5); + box-shadow: 0px 0px 20px 2px white; ; + } +} + +#sign_out:hover { + animation-name: choosing; + animation-direction: normal; + animation-fill-mode: forwards; + animation-duration: 0.4s; +} \ No newline at end of file diff --git a/css/options.css b/css/options.css new file mode 100644 index 0000000..24e9ef8 --- /dev/null +++ b/css/options.css @@ -0,0 +1,160 @@ + +/*sticky footer*/ +html, body { + height: 100%; + padding: 0; + margin: 0; +} +#wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -52px; +} +footer, #footer-push { + height: 20px; + text-align: center; +} + + +/*center content on large screens*/ +.mdc-toolbar__row, +main { + max-width: 1200px; + margin: 0 auto; +} + +/*content*/ +body { letter-spacing: 0.2px; } +main { + padding: 90px 0 30px 0; +} + + +/*header*/ +header .mdc-toolbar__title { margin: 0; } +header nav { float: right; margin: 0 !important; } +header nav a { color: #fff !important; min-width: auto !important; } +/*mdc-tab-bar*/ +header nav a { + font-size: .875rem; + line-height: 36px; + font-weight: 500; + letter-spacing: .08929em; + text-transform: uppercase; + text-decoration: none; + padding: 15px 24px; +} +header nav a:hover { + background: #5c7886; +} + + +/*footer*/ +footer { + font-size: 14px; + line-height: 20px; + color: #9e9e9e; + text-align: center; + background: #424242; + padding: 16px 40px; +} +footer a { + color: #9e9e9e; + text-decoration: none; +} +footer .icon-chrome { margin-right: 10px; } +footer .icon-chrome:before { font-size: 17px; } +footer .icon-github:before { + font-size: 20px; position: relative; top: 1px; +} + + +/*custom scrollbars in WebKit*/ +::-webkit-scrollbar, +::-webkit-scrollbar-corner { + height: 10px; + width: 10px; +} +::-webkit-scrollbar-track-piece { + background: #ececec; + border-radius: 2px; +} +::-webkit-scrollbar-thumb { + background: #afbdc3; + border-radius: 6px; +} +::-webkit-scrollbar-thumb:hover { + background-color: #607d8b; +} + + +/*bootstrap callout*/ +.bs-callout { + border: 1px solid #eee; + border-left-width: 5px; + border-left-color: #607d8b; + border-radius: 3px; + background: #fcfcfc; + padding: 20px; + margin: 0 0 20px 0; +} + + +/*button*/ +.s-button { + background-color: #ececec !important; + color: #000 !important; +} +.s-button:before { + background-color: rgba(96, 125, 139, 0.56) !important; +} +.s-button:after { + background-color: rgba(96, 125, 139, 0.56) !important; +} + + +/*misc*/ +h4 { margin: 0 0 10px 0; } + +p { + font-size: 16px; + line-height: 24px; + letter-spacing: 0.2px; +} +p:last-child { + margin-bottom: 0; +} +code { background: #ececec; padding: 5px 8px; border-radius: 3px; } + +.s-label { + font-size: 16px; + letter-spacing: 0.2px; + display: block; + cursor: pointer; + max-width: 400px; + height: 40px; + padding: 5px; + margin: 0 0 8px 0; + border-radius: 3px; +} +.s-label:last-child { + margin-bottom: 0; +} +.s-label.active { + background: #eee; +} +.s-label .mdc-radio { + display: inline-block; +} +.s-label span { + position: relative; + top: -15px; +} + +.s-label em { + font-style: normal; + position: relative; + top: -1px; + padding: 0 10px 0 0; +} diff --git a/css/signin.css b/css/signin.css new file mode 100644 index 0000000..3f97c41 --- /dev/null +++ b/css/signin.css @@ -0,0 +1,90 @@ +@import "https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css"; + +.login__form { + background-color: #263238; + padding: 50px; + border-radius: 10px; +} + +label { + cursor: pointer; + color: #666; +} + +body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #64a4ec; + /* background-color: #263238; */ + font-family: "Lato", sans-serif; + width: 300px; + height: 230px; +} + +h1 { + color: #fff; + text-align: center; + font-size: 40px; + font-weight: 100; + margin: 0 0 30px; + padding: 0; + color: #fff; +} + +.btn { + display: block; + border: 1px solid transparent; + height: 45px; + width: 100%; + border-radius: 5px; + transition: all 0.5s ease; + cursor: pointer; + outline: none; + font-family: "Lato", sans-serif; + font-weight: 600; +} + +.social__login { + display: flex; +} + +.social__login a { + flex: 1; + text-align: center; + text-decoration: none; + line-height: 45px; + margin: 5px; + padding-left: 10px; + padding-right: 10px; + margin-bottom: 20px +} + +.social__login i { + margin-right: 10px; +} + +.btn__google { + background-color: #4285F4; + color: #fff; + font-size: 18px; +} + +.btn__google:hover { + background-color: #fff; + color: #4285F4; + border: 1px solid #4285F4; +} + +.btn__github { + background-color: #333; + color: #fff; + font-size: 18px; +} + +.btn__github:hover { + background-color: #fff; + color: #333; + border: 1px solid #333; +} diff --git a/icons/Jcrop.gif b/icons/Jcrop.gif new file mode 100644 index 0000000..72ea7cc Binary files /dev/null and b/icons/Jcrop.gif differ diff --git a/icons/dark/128x128.png b/icons/dark/128x128.png new file mode 100644 index 0000000..93e48cc Binary files /dev/null and b/icons/dark/128x128.png differ diff --git a/icons/dark/16x16.png b/icons/dark/16x16.png new file mode 100644 index 0000000..04279f2 Binary files /dev/null and b/icons/dark/16x16.png differ diff --git a/icons/dark/19x19.png b/icons/dark/19x19.png new file mode 100644 index 0000000..4301ede Binary files /dev/null and b/icons/dark/19x19.png differ diff --git a/icons/dark/38x38.png b/icons/dark/38x38.png new file mode 100644 index 0000000..8955e83 Binary files /dev/null and b/icons/dark/38x38.png differ diff --git a/icons/dark/48x48.png b/icons/dark/48x48.png new file mode 100644 index 0000000..0c00056 Binary files /dev/null and b/icons/dark/48x48.png differ diff --git a/icons/light/128x128.png b/icons/light/128x128.png new file mode 100644 index 0000000..916772d Binary files /dev/null and b/icons/light/128x128.png differ diff --git a/icons/light/16x16.png b/icons/light/16x16.png new file mode 100644 index 0000000..c3869e4 Binary files /dev/null and b/icons/light/16x16.png differ diff --git a/icons/light/19x19.png b/icons/light/19x19.png new file mode 100644 index 0000000..6c422fb Binary files /dev/null and b/icons/light/19x19.png differ diff --git a/icons/light/38x38.png b/icons/light/38x38.png new file mode 100644 index 0000000..bc7ea82 Binary files /dev/null and b/icons/light/38x38.png differ diff --git a/icons/light/48x48.png b/icons/light/48x48.png new file mode 100644 index 0000000..33e03df Binary files /dev/null and b/icons/light/48x48.png differ diff --git a/icons/pixel.png b/icons/pixel.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/icons/pixel.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ebdb21f --- /dev/null +++ b/manifest.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 3, + "name" : "Manga Reader", + "version" : "1.2", + "description" : "Manga Reader", + + "icons": { + "16" : "/icons/dark/16x16.png", + "19" : "/icons/dark/19x19.png", + "38" : "/icons/dark/38x38.png", + "48" : "/icons/dark/48x48.png", + "128": "/icons/dark/128x128.png" + }, + + "action": { + "default_icon": { + "16" : "/icons/dark/16x16.png", + "19" : "/icons/dark/19x19.png", + "38" : "/icons/dark/38x38.png", + "48" : "/icons/dark/48x48.png", + "128" : "/icons/dark/128x128.png" + }, + "default_title": "Manga Reader" + }, + + "background" : { + "service_worker": "background.js" + }, + + "options_page": "options.html", + + "web_accessible_resources": [ + { + "matches": [ + "" + ], + "resources": [ + "/icons/Jcrop.gif", + "/icons/pixel.png" + ] + } + ], + + "commands": { + "take-screenshot": { + "description": "Take Screenshot", + "suggested_key": { + "default": "Alt+S" + } + } + }, + + "permissions": [ + "storage", + "scripting", + "activeTab", + "identity", + "contextMenus" + ], + + "oauth2": { + "client_id": "__CLIENT_ID__", + "scopes": [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" + ] + }, + "key": "__EXTENSION_KEY__" +} diff --git a/options/firebase_config.js b/options/firebase_config.js new file mode 100644 index 0000000..298512c --- /dev/null +++ b/options/firebase_config.js @@ -0,0 +1,29 @@ +// Import the functions you need from the SDKs you need +import { initializeApp } from "firebase/app"; +import { getAnalytics } from "firebase/analytics"; +import 'dotenv/config' + +// TODO: Add SDKs for Firebase products that you want to use +// https://firebase.google.com/docs/web/setup#available-libraries + +// Your web app's Firebase configuration +// For Firebase JS SDK v7.20.0 and later, measurementId is optional +const firebaseConfig = { + apiKey: process.env.FIREBASE_API_KEY, + authDomain: process.env.FIREBASE_AUTH_DOMAIN, + projectId: process.env.FIREBASE_PROJECT_ID, + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.FIREBASE_APP_ID, + measurementId: process.env.FIREBASE_MEASUREMENT_ID +}; + +// Initialize Firebase +const firebaseApp = initializeApp(firebaseConfig); + +// Trigger index.esm2017.js:87 Refused to load the script 'https://www.googletagmanager.com/gtag/js?l=dataLayer&id=G-28RB8VJQFZ' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. +// const analytics = getAnalytics(firebaseApp); + +export{ + firebaseApp +} \ No newline at end of file diff --git a/options/options.html b/options/options.html new file mode 100644 index 0000000..e2cb75a --- /dev/null +++ b/options/options.html @@ -0,0 +1,31 @@ + + + + Manga Reader + + + + + + +
+
+
+
+

Manga Reader

+
+
+ +
+
+
+
+
+ +
+ +
+ + diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..81f9a63 --- /dev/null +++ b/options/options.js @@ -0,0 +1,226 @@ +import { firebaseApp } from './firebase_config' +import { + getAuth, + onAuthStateChanged +} from 'firebase/auth'; +import m from 'mithril'; +import 'material-components-web/dist/material-components-web.min.css'; + +// Auth instance for the current firebaseApp +const auth = getAuth(firebaseApp); + +onAuthStateChanged(auth, user => { + if (user != null) { + console.log('User is logged in!'); + chrome.storage.sync.set({idToken: user.accessToken}) + fetchUserLimit(); + } else { + console.log('User is logged out!'); + chrome.storage.sync.set({idToken: ""}) + window.location.replace('./signin.html'); + } +}); + +document.querySelector('#sign_out').addEventListener('click', () => { + auth.signOut(); +}); + +// Done authentication stuff + +// State used by the options page to determine which options are selected +// and display the selected state in the UI. +var state = { + shortcut: {}, + api: [ + {id: 'gpt', title: 'GPT (For in-depth translation)'}, + {id: 'deepl', title: 'DeepL (For quick translation)'} + ], + source_lang: [ + {id: 'Japanese', title: 'Japanese'}, + {id: 'Korean', title: 'Korean'}, + {id: 'Chinese', title: 'Chinese'} + ], + target_lang: [ + {id: 'English', title: 'English'}, + ], + icon: [ + {id: false, title: 'Dark Icon'}, + {id: true, title: 'Light Icon'} + ], + userLimit: { + requestCount: 0, + limit: 0 + } +} + +// Set the state of the options page based on the current config +chrome.storage.sync.get((config) => { + state.api.forEach((item) => item.checked = item.id === config.api) + state.source_lang.forEach((item) => item.checked = item.id === config.source_lang) + state.target_lang.forEach((item) => item.checked = item.id === config.target_lang) + state.icon.forEach((item) => item.checked = item.id === config.icon) + fetchUserLimit(); + + m.redraw() +}) + +// Get the current keyboard shortcut from the manifest and display it +chrome.commands.getAll((commands) => { + var command = commands.find((command) => command.name === 'take-screenshot') + state.shortcut = command.shortcut + m.redraw() +}) + +// Event handlers for the options page +// These are called when the user interacts with the UI +// If keyboard shortcut is reset, call button function +// If other options are changed (API, icon), call option function +var events = { + option: (name, item) => () => { + state[name].forEach((item) => item.checked = false) + item.checked = true + + chrome.storage.sync.set({[name]: item.id}) + if (name === 'icon') { + const color = item.id ? 'light' : 'dark' + chrome.action.setIcon({ + path: [16, 19, 38, 48, 128].reduce((all, size) => ( + all[size] = `/icons/${color}/${size}x${size}.png`, + all + ), {}) + }) + } + }, + button: (action) => () => { + chrome.tabs.create({url: { + shortcut: 'chrome://extensions/shortcuts', + location: 'chrome://settings/downloads', + }[action]}) + } +} + +var onupdate = (item) => (vnode) => { + if (vnode.dom.classList.contains('active') !== item.checked) { + vnode.dom.classList.toggle('active') + } +} + +m.mount(document.querySelector('main'), { + view: () => [ + m('.bs-callout', + m('h4.mdc-typography--headline5', 'Requests made this month / Your monthly request limit'), + m('p', `${state.userLimit.requestCount} / ${state.userLimit.limit}`) + ), + m('.bs-callout', + m('h4.mdc-typography--headline5', 'Keyboard Shortcut'), + state.shortcut && + m('p', 'Current keyboard shortcut. Refresh after updating to see the updated shorcut.', m('code', state.shortcut)), + !state.shortcut && + m('p', 'No keyboard shortcut set'), + m('button.mdc-button mdc-button--raised s-button', { + onclick: events.button('shortcut') + }, + 'Update' + ) + ), + + m('.bs-callout', + m('h4.mdc-typography--headline5', 'API'), + state.api.map((item) => + m('label.s-label', {onupdate: onupdate(item)}, + m('.mdc-radio', + m('input.mdc-radio__native-control', { + type: 'radio', name: 'api', + checked: item.checked && 'checked', + onchange: events.option('api', item) + }), + m('.mdc-radio__background', + m('.mdc-radio__outer-circle'), + m('.mdc-radio__inner-circle'), + ), + ), + m('span', item.title) + ) + ) + ), + + m('.bs-callout', + m('h4.mdc-typography--headline5', 'Translate From'), + state.source_lang.map((item) => + m('label.s-label', {onupdate: onupdate(item)}, + m('.mdc-radio', + m('input.mdc-radio__native-control', { + type: 'radio', name: 'source_lang', + checked: item.checked && 'checked', + onchange: events.option('source_lang', item) + }), + m('.mdc-radio__background', + m('.mdc-radio__outer-circle'), + m('.mdc-radio__inner-circle'), + ), + ), + m('span', item.title) + ) + ) + ), + + m('.bs-callout', + m('h4.mdc-typography--headline5', 'Translate To'), + state.target_lang.map((item) => + m('label.s-label', {onupdate: onupdate(item)}, + m('.mdc-radio', + m('input.mdc-radio__native-control', { + type: 'radio', name: 'target_lang', + checked: item.checked && 'checked', + onchange: events.option('target_lang', item) + }), + m('.mdc-radio__background', + m('.mdc-radio__outer-circle'), + m('.mdc-radio__inner-circle'), + ), + ), + m('span', item.title) + ) + ) + ), + + m('.bs-callout', + m('h4.mdc-typography--headline5', 'Extension Icon'), + state.icon.map((item) => + m('label.s-label', {onupdate: onupdate(item)}, + m('.mdc-radio', + m('input.mdc-radio__native-control', { + type: 'radio', name: 'icon', + checked: item.checked && 'checked', + onchange: events.option('icon', item) + }), + m('.mdc-radio__background', + m('.mdc-radio__outer-circle'), + m('.mdc-radio__inner-circle'), + ), + ), + m('span', item.title) + ) + ) + ), + ] +}) + +function fetchUserLimit() { + chrome.storage.sync.get(['idToken'], function(result) { + const idToken = result.idToken; + fetch('http://localhost:3000/get-user-limit', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${idToken}` + } + }) + .then(response => response.json()) + .then(data => { + state.userLimit.requestCount = data.request_count; + state.userLimit.limit = data.limit; + m.redraw(); + }) + .catch(error => console.error('Error:', error)); + }); +} diff --git a/options/signin.html b/options/signin.html new file mode 100644 index 0000000..2c5ccab --- /dev/null +++ b/options/signin.html @@ -0,0 +1,24 @@ + + + + + + + Login Page + + + + + + +
+

Manga Reader

+
+ +
+ + + + \ No newline at end of file diff --git a/options/signin.js b/options/signin.js new file mode 100644 index 0000000..6656499 --- /dev/null +++ b/options/signin.js @@ -0,0 +1,96 @@ +import { firebaseApp } from './firebase_config' +import { + getAuth, + onAuthStateChanged, + signInWithCredential, + GoogleAuthProvider, + setPersistence, + browserLocalPersistence +} from 'firebase/auth'; + +// Auth instance for the current firebaseApp +const auth = getAuth(firebaseApp); +setPersistence(auth, browserLocalPersistence) + +function init() { + // Detect auth state + onAuthStateChanged(auth, user => { + if (user != null) { + console.log('Below User is logged in:') + console.log(user) + window.location.replace('./options.html'); + } else { + console.log('No user logged in!'); + } + }); +} +init(); + +document.querySelector('.btn__google').addEventListener('click', () => { + initFirebaseApp() +}); + +function initFirebaseApp() { + // Detect auth state + onAuthStateChanged(auth, user => { + if (user != null) { + console.log('logged in!'); + console.log("current") + console.log(user) + console.log(user.token) + } else { + console.log('No user'); + startSignIn() + } + }); +} + +/** + * Starts the sign-in process. + */ +function startSignIn() { + console.log("started SignIn") + //https://firebase.google.com/docs/auth/web/manage-users + const user = auth.currentUser; + if (user) { + console.log("current") + console.log(user) + auth.signOut(); + } else { + console.log("proceed") + startAuth(true); + } +} + +/** + * Start the auth flow and authorizes to Firebase. + * @param{boolean} interactive True if the OAuth flow should request with an interactive mode. + */ +function startAuth(interactive) { + console.log("Auth trying") + chrome.identity.getAuthToken({ interactive: true }, function (token) { + //Token: This requests an OAuth token from the Chrome Identity API. + if (chrome.runtime.lastError && !interactive) { + console.log('It was not possible to get a token programmatically.'); + } else if (chrome.runtime.lastError) { + console.error(chrome.runtime.lastError); + } else if (token) { + // Follows: https://firebase.google.com/docs/auth/web/google-signin + // Authorize Firebase with the OAuth Access Token. + // console.log("TOKEN:") + // console.log(token) + // Builds Firebase credential with the Google ID token. + const credential = GoogleAuthProvider.credential(null, token); + signInWithCredential(auth, credential).then((result) => { + console.log("Success!!!") + console.log(result) + }).catch((error) => { + // You can handle errors here + console.log(error) + }); + } else { + console.error('The OAuth token was null'); + } + }); +} + diff --git a/package.json b/package.json new file mode 100644 index 0000000..16b5d87 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "scripts": { + "build": "webpack --watch --config webpack.development.js", + "release": "webpack --config webpack.production.js" + }, + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "copy-webpack-plugin": "^10.2.3", + "html-webpack-plugin": "^5.5.0", + "webpack": "^5.88.2", + "webpack-cli": "^4.10.0" + }, + "dependencies": { + "@material/button": "^14.0.0", + "@material/elevation": "^14.0.0", + "@material/radio": "^14.0.0", + "@material/theme": "^14.0.0", + "@material/toolbar": "^2.3.0", + "@material/typography": "^14.0.0", + "babel-loader": "^9.1.3", + "css-loader": "^6.8.1", + "dotenv": "^16.3.1", + "dotenv-webpack": "^8.0.1", + "file-loader": "^6.2.0", + "firebase": "^9.6.5", + "firebaseui": "^6.0.0", + "jquery": "^3.7.0", + "jquery-jcrop": "^0.9.15", + "material-components-web": "^14.0.0", + "mithril": "^2.2.2", + "node-polyfill-webpack-plugin": "^2.0.1", + "style-loader": "^3.3.3" + } +} diff --git a/server/.env_template b/server/.env_template new file mode 100644 index 0000000..9479949 --- /dev/null +++ b/server/.env_template @@ -0,0 +1,3 @@ +OPENAI_API_KEY= +DEEPL_API_KEY= +PINECONE_KEY= \ No newline at end of file diff --git a/server/.gcloudignore b/server/.gcloudignore new file mode 100644 index 0000000..52535c3 --- /dev/null +++ b/server/.gcloudignore @@ -0,0 +1,25 @@ +# This file specifies files that are *not* uploaded to Google Cloud +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore +.venv +node_modules + +# Python pycache: +__pycache__/ +# Ignored by the build system +/setup.cfg + +# not yet +populate_vectorstore.py +query_test.py \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app.yaml b/server/app.yaml new file mode 100644 index 0000000..9d782c9 --- /dev/null +++ b/server/app.yaml @@ -0,0 +1,9 @@ +runtime: python39 +instance_class: F4_1G +entrypoint: gunicorn -b :$PORT main:app +automatic_scaling: + min_instances: 1 + +handlers: +- url: /.* + script: auto diff --git a/server/download_model.py b/server/download_model.py new file mode 100644 index 0000000..df9c410 --- /dev/null +++ b/server/download_model.py @@ -0,0 +1,14 @@ +from transformers import AutoFeatureExtractor, AutoTokenizer, VisionEncoderDecoderModel + +pretrained_model_name_or_path='kha-white/manga-ocr-base' + +# Download components +feature_extractor = AutoFeatureExtractor.from_pretrained(pretrained_model_name_or_path) +tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path) +model = VisionEncoderDecoderModel.from_pretrained(pretrained_model_name_or_path) + +# Save components to local directory +save_directory = "./model" +feature_extractor.save_pretrained(save_directory) +tokenizer.save_pretrained(save_directory) +model.save_pretrained(save_directory) \ No newline at end of file diff --git a/server/imageDataUrl b/server/imageDataUrl new file mode 100644 index 0000000..db89acd --- /dev/null +++ b/server/imageDataUrl @@ -0,0 +1 @@ +// const imageDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ4AAAEYCAYAAACtG08KAAAAAXNSR0IArs4c6QAAIABJREFUeF7snQeYXVXVhvfNpPdeID2BUBJ670WkiQKKYMFC+S2oYC8oKIINBTsqoogUQZCOVOk9QAKkkt5I7z2Zuf/zfmuvc/a9SYCEmTMROT6YZOaWc/Zee5VvfWutUu3TvymXQzlwlUIpNGlS0t+LuMrlciiV7PvWr68NL46bEc685Lowdsrs0Lpl87Dnjn3ChWcdH7bt1jHU1dXpdXV15VBXLoca3Sf3G0LZbl8Xn8k/m5RK+uza2rrAVzThhbyf3/O9vKZJE72Wv+t98b1Na5qEtetqA5/UrKYm1DRpEmrr6vQfa1RqUgrlOrt3W6+y7svvjy/k/vhZfDz7jDKvqwt8/rp1taGmpondS/wc/wx7Evtc7nHuwmXhxgdf0Ot4De/xfVq/vi5Men1+WL1mfRg6sFe2nnyvrwuv5+I96c987fVtJX5nr/N7sj/9d5US4ffAW+y7bCX5u+4x7oF/nn8Hz8R3lWqf+U3Zv9A3owihSwWFG1lfWxdeHDc9nHnxdWFMFLy9d+wbLjjruNCvZ6ewZu36bFF9g3g8BMw3qq7MptZkAsDmr6+ri0IQQk1N3LgQQm1tbXbQWCQEo1p4ERIJRdwU3xzfPBc6E5IQauv8QNhm+fu5XxcyX2vkVEcnbqwLqW+kCxkvmrdoWfjnQy9l9+fP7+v22vS5+t2O/XuayMbN573ce/56+6WEhkNZZ3/6EvLdmSzwOhPD0KRkByQVxPxQvbmi0v3oO5PPqXvmN2V+yLfkJ7g40fPThWbaQPB26he+d8axoV+vzmHN2nUVpxJNwH2nwsFiIAy+6Xba7KT7yTNtWRPfFzVhuRzW1dZKaF1L8h7XrvZ22yATPpbL1swu00z+HSY8pg15PjQli877eU6/R/u33bMfIO2FH6X4+7mJ4KVCwveimSfOnB/atmoR+vbslByeDffQtaBbjPyrXO9XCh6fYAKTr5+viwm4aWXXnC64lZq00iL5GpUwtSwMF4tnJqy4a2OCN3bKnNCqZfOw7879wvlnHB369kDw1sdF8E2NNjZqFl8kHppn4H9oO534aJpdMP2Q8XM0bdOmZlqbNjHhwBSiDVJTnW0Swldlmny12BRMX9OmNWaWS6Wwbt360LxZUwlxfijMXWCpfbPQuC5yLiD8yf0tWLw83PTQS6aN9P1RS5VDWLB0RZgxd3EYtG3X0K5NS73GhSXVYC4org1Nluxg2IEybeQHKdO4Ei3X6UFuRp3cFzvgqclNXadMwKLQovFxp9zcl9Y99Wt9W7yPCpNThPi9kcbbY0if8IP/Oz7036ZzWLFqrTaQa+3a9aF586amPSRYthAIT7OmNVoMNqxVi2Zh9dr1MnO8Vr7V+tooCHV6aDZ8LcLRtGkmqAhN86Y19nnNzHTz/S2aNZWmY+P5s1XzZmHNuvXmN8m81ek1CC3fJaGuaRJWrF4bWjZvpvtau742tGnZXPfBxXdxz6vXrNN98R72qkWzZmHdenMvZi9YKlObayrTQutq68L0uYtCTalJ6NOjo362vrase/bPd18z9XtZL/mcUYDcjKJ4XKPiCnAwzKTmGi91DUzoeE5fd9P+qQZ0sbV1Z2+ie7H2yV+VJb1R+jn9RV5vJHj7DxsQzv/UMaF3jw5h3fo6LSY3zgOwQRKOuDnSYk0smGCzWZDVa9dJ+PgZm8YztmjeNKxcs1bvdUe9pobPM59Ph7CJmcRmTU24cyEjIDB/tFlNk7By9brQsoUJlG8YmtlNNtYX4UUYeY98xVI5rFxl9+U+mB8G30DuAgGVFiyHMG/xsnD9fcN1z7gE/JzvmzV/aZgwfV7YZfC2oVO7VlFL271zD9w37+f1vM99eN7Ld2aaPWoeaeWoyVzbmgsRf44VqTWt5a6HHa6aTJu50HEIFbxFLa17iM+kr0PjmQ0vy9/wE16U8G1M8EbH4GKfnfqGC886LvTt2Vmb7KeN1XT1byYqjyjd9zPBaWInFoFc7xrOo01zntEW2iBWIL6WP21TbPG4WDTWScJDFF5rJ9gFhBU0jWS+p5t4hEVmO4uoK7WCmSzTLHIX6hB0ExaPFOcsXBpuevBF/d7N9crVa8OrE2fp33sM6Wufb/bNTKA0c2bL9LfclBJo2fP5YcvMuOx8bl7twEZ3NkEh9Mnx3uWn1kTfNx7CPOgzSTI/l/uKrhAaL96vFqxlczNnRV/VwUWrFs3DXjv2DReebVEtcIE769X3lkIB7kOkMIl8tfjg7LJDM5xaBCyNPuU8R+3IZqDh5C/WYhpzv8Z+F4Uzagn34/Ko13w49/dyJ92DFPOtU1Pm780DmXKYt2h5uPHBFzOYg/egBSfOmB926N8jdO3QVt8RY0Q5AoZx5MLmQYF/V+pHmsDnJtLW18xojiCYUKeQjN37hua4ArZx1yQJmnR41j/1a/3I7bz7UUUIXuqMsrEvjjUcDzgFP2ivHfuFiz5zfNi2a4ewet16Of++wAgEJw2hQQB80129p6dbi80+eMAgiCU/8ekm+3OnwuyOeCokjt25E+5RLAdIG5kENClel8Ep6UbHIAMLljr1vrHzF6+Q4HkYjV+JiUXb7rVDX32XvjcGHrnpzwPFasc/399K/K3yuf39eXhREZwk0EyKD/p9ZFqWgI8IPT6z3Bo0XvamUJY/UtRVIXgRQD5LgjcntGhWE/bZuX/43pnHhH49u5gPFh+UjVy5aq2iUY/wgCvc//CNJlj3h3PBYOEIJvDfMujCdP4bPrYHKGk05/eTfUcTApz1Ap09Imax8SsFwMSvSO9JmjODhqILESNrDxDmL14ebv7PiKgcQuDfoyfPDoP7dA39e3VRxC8tEx/DtZlvvJvEHOzdEDbhPQqK4r2k700j14qfeyCRgcipOOf+okNI9t6ggEw+nmk7w6a2BsGTj9eiedhn537h+/LxOikyxCy6WUCzCQaKOJ0LgZuSDCJKEHnBHTjDTczxzk1Mbu60AbVudnJh9OhN5jKaawOWcxDYAxwPVBBsBQTra+Vn8j+PKCs0sn5u35W7EwZV8FPheNHH4zXTZi8K+H27bt9bloFn8mjS7yeFVEwezVfLNZpDKZV/5veQW0E7yG6ATaDc/8wPkfSzGelEwnOTXglUS+Nl552F2io03mxFffsNGyAAuU8PMhfrkvSS+WXmt+QpHntIx9vt7yn4CVblnrJvsJ9mPsd/lprJVA26028Caz6Vmx4HqWUqo0Num4zZjThp/LdHs3arMYWUCbx9o/uZfC6Cd8P9wwVCr1qzLoyfNldadPu+3XUYU/Ot7U/gD/93qmVTreV/r/SN7Z6yFF7E2vIMTg4K6z49s5MEHIlLV+Gb2v2EUFrzxK/K7lCzSluH4M0JrVs2k8ZTVNvDNJ7BPhF7W59jdhUpIW1bFodV/J3AEVzLhM1OsQUWttn2M/7tmtUERhiYoBqLdt0fdhMm7M3zlBH/ciHNApdoVfT9CFbMYKSv8/szwTErhOYmuLCUWTnMXbg8jJ8+Nwzq3TX06tKhKoNiz5H6adUaLI2ibd/zA5phlFXRa5q98IOYuhn+ORZRp4JvQuapxFRLZoLn5haIoKhr0z7ebOFje+7QN/zwMwDIXeTT+cMiaICudtpy01hbhzCajwpKjonwE+0ovgud9jYCyPgc+GW8xrE3EzB7jQGznvONGQ1AZuFzho9Z5FyW30kQ4xvOB/Aa9+P4Xj57zVruFc1tOWRAccfMeCjXLnyS52r5/OmY2UXLwrBBvZQm42cOS7jAptqqWghFcIhRu2tb3W6VsBnUlLsAucBtmAJz8KVS00fbrGcx4XPrI+Fc/fgvy2loXaTGS1U+G/uSSALXh9FTXg9tWrYI+w/rL423Tdf2QukBY7nQGsArbVo1z0HTBCsTRBIjPQSYNBbvwdcCp2STSWuxKRZoRD8sZiR4D6YdIfacbMsWTcOyFWv0WnYaoUZgcQkQchYWQSLSNIjGTjvf6RkUFzrMJZkM17JoRQ68BUx28B1URuMR1d704As6CE+/Mlmft/v2vS1Sj1rbI3sEHEG3NchVSOaClIMOBwEJn6MAJqYZDSy3+yZsI+XI83qGI8UbXXPyO2QGsN4JGnxrmmURiB2zRkbGKJvgmTmxk1xkcKEbjDlMHuAFCd61YcxkfLzmYe+d+4UfnHV8GLhtl7B05WrTcnVlCZKlzCyTIVwolEItZjKutfs5/vkGJKMJLTcLoEzGQmk0NFIEknmfRce57ySWS1xghMz8NtwSNsUYKYJ54qEQqAwpoElJUSLZlZQEIHchptSMIJBjiZ7hSE0WphYAeeHSFSJSDO7dTVbAfVhPs1kWFyA5J33ITUjYKhkywHdmoKGZfov8DVBnvQSiJ2k1F+OUKCAXBPZNBNbdiqVEBGPC2EnM9gcfz6InOx5+Eosyty4YokWNnR7OuPjaMHbqnNC6RbOw79AB4fxPQxLoJGHQJst85j6QP2ge8m/cWXft4v6ZnWwDk505kmJ95izlbBITDgNQPbvgmQ43tRa15ukzIyqY9rFAxKJaS7nZIXJfrgmvcSJB1DbmjwYJHMHF9DmLFFjsPqRP6NqhTczr5pGkBzgV+dUQ4ZqEG5jCOlkgkuSgtbqJm5G6RFnAkQQxKXxTKTfxQ6KPnIHRrNFqBC8yKXiTYU7FXangvTDWNB6CB0yw9079woVnHqdcLZvlFCLTlDlDQjISSZ+udVgs+RWRclS9eL5JnlXwFJX/O/V5+ExP+nu+0+/Bv883O9PgkfCZ5WhjBIyAKUfsdjDuMmaK+7VnMY2qw1IqhQVLVoRr7nlWBxMg/YBhA7N8dSWfzjY6hWrswOVpwtRc+rr5bueQVDThMTr316XasxK+qiQGuCBWR9KpVJVWPna5fu90qMYUPBb2zEuuFYCM/7H/0AHhgjMNTsGHUPI7CypMbXPzbFL6DM6scLMhgU2iLdd0vhC2yeYERzptlsdFEE3LmiBLCwG9RLJAvIsNIkkzW8aYcTPrZkk+VEIulSBGzWRuD1kWO1h898IlK8Pvb3lM/h1rsfPAXnpRat7dbakUiCr9kwQQFThici8pHqjPzNjaMfObwiwb6KdqZoq9wAm7fjilBEzwciwGynmRV7WPdxbBxeTXpXn33rGfcrXb9ekWVq5aI/8D+ji+ndOPpL5jdGoug2lDfCtej/9mUSmRpRFARRWK5iSnqOcAp1N4PEDwTSV4SNM+ONUWjdqmOBzi6Svujedg8+oi4dPvEaEx/l98b2QpG4PDBM7ZHIuXrwo/veaB8Myrk8M+O/ULA7bpaqQFEfpCxrJ2QXGCgpt+80ErAWQxSmI2KE+D2RrEIFdprvUxGt2Y9nL4RdQuXAcdYJelKHQVpIXcLZDgSSojU7Zd65ZFyl1FcOE1FwgephYczwFkFtpBW6f5mPAZkJyG6ym84NCC4UmWo81wPM6itBgCiq9XyTBxqhgL4pGgU5I8k+HZFPebUvPOdyqizrA/syzOmHF9wGsM5jDvVcFSAsouWLoyfO+Pd4ZxU+eGI/baPnRuj39Xm2mjDKyN0EVOVjBzbRYgp7LnLn6ukdJn9cCmwldMMisuIA5R5Tiw/caBePNpTbbSAy7TvfzRy8ruxPKCojWea9uMnXKJaTwJHtT3M48VA5nI0LFG9yEQBndYM/ZJAqAaxGFOvIGgDhLbhqTmxkFS10DuF6WvSWsUXKDzFFdlxsC0oPED3TdyofRgxDfQ7z3D37xWomzFRrPmLwnf/t3tYeGyleHQ3beTf5c6+dWaInp6eQrLHlYvS3E9U7ZJyiy+zqGSfI3y1+SEqfxb/GeGD+YpRBfMDb+3FEorHr0s7oIRKUVQLPDalOCRq917p77h+2cfp0Q42FcKILsD7kU6GQySpIuEQcVozoUGqEMk0KTSLD/BeaWZ4WFoSKOFyw/LNs8AUT6fhTYsDK0S6yqiL8h3OOvZC11cIOUzRkaJMWWctVwrLYwvy/fxHVNeXxi+/utbZbb3HdpfWtQ2M6ckpe6SqPcIfDRz1a8zqMPA9TQiTQMwFxYP4qp9Ql+z6s/2n7v74cIrrmfiqkjw3FfgRYCnRV6b1ngtwn5DzdRu272jAE9P/DsGhjaQrxQjReFzYhvbggIOA0Q7zsV34duIyRx9EV6PkK9et05ClOF1keGMT4mQOFWcD8bHNK6agadGKjAUzZm+rKEi4WjePbrlS/h+p8Kz9vIpxS42vw/BAJxG6/OpU15fEL5y+S2hQ7tWglLkFkVmNAKdX6aFfE0cb3Qzl70uUsQcwnEAGYwyw/ki19CoaHk5pX+mR9NO77cAKXcV3PfOshVxbdzVKS1/5LKs2IfTtfVoPEuZ/eDs48PA3l0DjFtPOxkybwLkTGH3+9xhZ/HxAcnxZqB4yWonnNGBsPA7NhkBcr6eMgstmhqrJKavUtPL3zkI1EUYvdyiagQGrWRFLXYIlJFQpqKpMYAcM40sX9dq1Nk6aC2zHwOH5s1rwuhJs8N3fn9H2KZbhzBs0Dai7puvaULmNSAcHNfEHqTkeWunTZnz78GLf0/mhzk3sy5IQ3mq0KEeuQ1JzbBnSqTRE5Pu6T/HA8UIjwGNajvw8RzI5AHatm5RpMLLzEHq442Z/Lp8zTS4QHs5z58bdJ/MFsTOGkIn1D2a0dSk+Ht4xpi1kYaRZY5CgBD7ZtpG5DQs3yjVc8S8LLlhLWIEtM03igCz0kQexZlv5ZCObZDlhd3HctPujr0HQvz7tenzwvlX3BkGbNNFcIqvhVmLlAG8IY5nNK6c7RI9Ee2xuR92D3ZFzmCspHPQ282l2+VU8Mx/zZ/TIuIcRE9paylgXlr2yC/KzhPjC4qOajfp4ym4iAXdPTrLxzNHPu6ai1tMw1gEVsk3yzfFFldRpmoxLB9qfD4nX1pi3gQpFkJL85hfblFxSrPKozdP2zkW5yxkvsM1MYcA6AVNxD0sXbFaf4rEKTa1aS4yGxy69m1a6r285pUJs4Tj9evZOfTp2VkMGtd4Mo0RUnJz52WTLkxmPu1f7htvLIpNMxqVbOZKUDrfhzzUcG1V4SfGQ+2/c6HXei57+BeqMvP0EA9c1JXepGu8sy65PoyKUS2dBMDxKPah/M81ubC46HzL/Ma64Li22flNyZAOYroTz2dlJzVA2GyqTVmzrlZgNd9HaSRkAYTRo/0ObVtldSks4Jp16+TsY5ZYRzC3BYtXhOWr1ki4ECyueUuWh/mLluv3fDa/h+ggt0GVfbEIplQKbVs1z1g23DdUKCJ9rFGXDm3kHsBMgTrWqV2b0LpVM8ME4ylxQN01lUfqKcaWBiMZlpjAHiakmyKJ5lhdGnT4e6rlx9yLfL31uqUP/1waz1VkYwkeG/DS+BkBwYMkwEbDwKCuFvPipY1mDnJowAqRY4ARJc8T21aXYRkA14g4+zjyluloInPI36GTj3htZhg/dU6YOW+xhA6/Ur5c86bSRmw8jBj+o9wSAaCYGjMMVZ/PWbZydZizYFlYsWatNJxXqq1as1bai/vAVKYRp6hNaNOI93EgZNph40RTih/qP8fc44vjMxEMdmjTUsXcaMSO7VpVsG089ZYe8uro1U1jddbD/VZXXGnlWLUFSIU8jZT9dY5PZpoXwfNQn0XiRBd1pQuQCd7F14dRk17XgkKLuuDM41TQjZZw9N1NGwvl6D5/IiBsKqZAuc8YhHhqiU1nwxRMNLXaXNi9z4+eGu564tUwYca8sGr1OkWiaLEM/4uBI5+LsKYbgAA6gu+mnnuQ35cB0jEdYBY/q2FG0FIfi+8jGOF7cAe8IwKR8pLlqzMA3CEOPgvh45laNG8mLdizc7vQrVPb0LlDG7vP+Br82TT/nOOWlfy6FDJy2yxAOqYAjfRq2tDNtpcAuPBVW55Me0Yigv5NVKvwPOaHOD1FXRsVPADkKHgAyPDxwPHYTEES0c9z86o8anzSLBqMdCs3rzIl7qhFStKSZavCg8+PC0+MnBhGvjZD+dCsgi2CvxKS2CFA/hclf9E6KE0Uwench7HUnEil0cnOqtmS7k1oLA4GkTObhsA4abVrx7aKsDu3bx26d2qrqHzp8tUiTiCIvI+1QCPzJ0KpAxJxQLQynwcERZetFGxOLQV/T321dImyTEh8MP0uYlQVn5EJUmVQ48FHano3wAiX/OfnVuYTaTtbh6lF41nm4rtnHBP69+psbcPiYllxi115At65Y/Zz14p+sj2NxMYtWLI83PH4K+FfD4+MZnVdFJacQuSbUgGcivfn9CYLhXUAYlAo0xT9zTw6rjzGCA+ChcZFSyEYrHnrli0kiF07thGuiNBQU4G/ibl/6uXJGQCL0ONC8Cc+6arVawNptSXLV0nwgYtwVRA+Dq3DGGnWwjE3OfxZes6j28jPjFmNdC2q/b5N+4GV4HTKTpZALnrwUjGQufhlp3ati1J4FUUgqamldA8/iuDiu2ceG3p375CdaglbbI7jJYeuLfDX8Ivw+xxM9pPMBrNIi5auDFff9Wy44/GXw7zFy7P8qK2BMzBy4U2dcL4HLYdTz8Zi/l2z8bkCc0tBgoT5IxDgP7Rejy7tJFCtWjYTybV96xahfRsLVHAreD2fz58EOmiudhHaWrxsZbjuvuF5IVPEDLn5VWvXhWUr1wQ0OEzl2QuXCgGQD9i0RhDMkL7d9UC4Kn4QN9bvxLE6X2NrhbFhS5OKHC7geoSXcgG29cuFOs9RZxoTjef/4AM7tW98wXt1EjieA8jHhX69uujkpxqOBcHceI0D2gZNiNbgOTBBTttm44k6l65YE66682nRyDFfDkh7TxIHi/EX8d2MfGBYn1sEoke+E1NI9qFNqxZhm64d1EsFVnCPzu2krbp3bidTiSbz4EM+IlhhMB/OGDRGn3KfKYq+gbzRraBpz5W3P2maPB4P2zOLOnluBJAGjmQ5+NOBcw7ITgN7ht7dO0WSBcKX8wvNYpipJnp3ooSCL7GkPUuT52H5m30/QhebE3m7kOhXuvvj7o57Oi60JQTP8S4WHB+jyMs1Sl5zcV2grhZzsd9QyhuPEWLPvXneFc3iaSFD1kkz5ZVe2sxSE0EWnHz+vWjZKtHH/3b3M3LUERRy0/5ZvvhoLDQO2gYWCP91at9KGgiN5doJX5gIslsnEzSa5nRo2zI0b4bGqjHBihCLC5PEJkpxDnG8+WpDEvjTrSZ41ZcrDcQAk0vNLa9Hm3sXLN6zy3bbqnbFGNLWTg38sAL/TFjKxp42ClmOAeam2AMLAe2xbMCxUj8UflBMYcROAvEBKkwtG8tJLvLaAEBOOoLSSeCCM48Jvbt3lAbyRXIQ2TMFqTnMI16jJOFPYYrufXq0hG7SzPmKfqXNYo5VxIFmTaWhdt2ut7ovDenXPXTv1C5069hWQsjpz6LaGN26s48Z5/ee6ajv9XszwUtxuGUrVotUMHvBEvl9mEFIA5h0notnkUn1gGEDxkouXBIcV1UZhOX4Xi5Wbg02BtkIyora0bWg9g0fz8NC7qexBe+MKHhOi6IVLYJn1Pe8BtZ8iFhtn3U3ypvVuN/Hw454bUb4/c2PBaj1fqEhtQFlo/sftOvA8L6Dh4V9duwnp5zvx5zXNG0ik2ibEP2W+N2pgFVHbfUpfG8keOn3iFywdp366c2ct0T1uPh7CBuHq1+PTmHAtl2j0s1NtdbRH9A1UtZxqjJrYUCwVj++0o1/5RNvHCv01Fo5lBY/dKmEmv/jI7YmwaNbFAAygrdqDdhYZF+ULeFsV+6j5Cky86Wa1lCvsFL1Cv944IWwfOUanX5vOYYGBSN8/8G7hJMO2yUM3LarAgPMr+VtKzGuNOWTLrPjd/UpbOlnvVXBU5S7dn1YsWqNzO3kWQvC8lVr5dvhi6KZt+/TXf6n9jvCUNUwi2vQag2Wipg/s8uO32/1e11jCpHwBWTXFjzwszJbZ3WZdaFjgVGtq3JBD7EHMhpPRNBWzbOai15dqZgPiiLd3HmNLO9zhgR+lft8PA+n/Zb/jAh/vfOpMG/JitAcRgvVanVlYV3v3XeH8Inj9g27bd9bwLk0nPfbS3on+30aJFDZGT81+40teOwf4DiBBTW6oANE2wRh7dq00Mbjk+40oGdFDz+HiLj/FFjOo9e8x3NaY6vXxw5Qm9Jw9nOv6EsyTgiemS3DxIrMXGxM8Cq6vqv59jGhT89OWWNFj+R4IGdwKM8Z02N8pqKx9XXhsZcmhJ9f91CYNmdh1ukS27pNt47hg4fvFk4/dh/1DsbUZqyKhGliG+GmZcOEeHq63fQ3hPC9VY3HIaC2GEiJPPGseYvVmFs1F+VyaNcKvLCZItzOHVprjeRvidGTuxHSYtGk5v5z9fPnhT1pTtyf3zSdFzVV4oJaq/n3/1QkAX0BprZDm4ZYu01+ZmVwMUPjBlzjqU3ZGRZcePoqBSLdPUhVuH8R9ae//eej4bERExydU4TLZyF0Hzl6L4Gr3svEaxJkuDNHunouRKVjnQqeBzUNsXhvVfB0kGOxExHu7AXLwthpcySEWIN2rcg1G7kAt0KVarGhkAXcVYFFkuJKA7jUrHq6MBW41AynyYlU0ZTm3vvjclo131hwSspOQfAsVztAmYs+Ci4qqeKeIbB6BXP+rV9ekGN91R1PhTufeNWwvpomoWPbVsLbjtx7SDjjhP1D7x4dC++asKVCuVmCFw+NU6/IP788YaZSbe3btFCUDkIA44convXzrE7GIo58PCvnzIfAuM/rQpjmar0GJBfePBvGczuc4ta1tOCBn2bBBf4TuFWRlz+0Ogl4C4tYV+tNe9QDOfYVccAUNgiRml9oLAKHpStWhRsfeDHccN9wRXcIMIcJocO3Oe+0w8PgPt2yBtabg6cVuS7pd22O4LlWQf4A3RcuXSkSxLTZC4U7AhG5O7LrdttmXfIdz9yA8xg1oVmXtCtBPmog5Sma1o09oJN6YS/nzOrYMbV2s/YGTkSRVyp43klgHC0sAJDReJ8BNgSQAAAgAElEQVQ+Wj4ZdHJnWKSYmaKleCoR3ruffFUpsUmz5suvY7ExK2i8Tx63b/jAobtkeFtakFLkM2/ud22O4MlX9RkbtXVhyfKVYdKM+eGFcTOk+UEtOITLVq0OO/Wn41Rz1c4aQpJP/RGdSaSMmDJz4DnWkGQMcIK7BAFI3RQ/BHy4AsBYSiC/0oMLp9qQWyzy2rjgzVVOU0RQRkr1Soig0YfJ2MKRLMYCMVrpVzc+Eh59cYJ8N7QdfVfIOhy1zw7hrA8ckJ34hoZA6nMNN0vwEmoJwgFxYObcJWHkhJka1NK+bUuVi+LnYd2gUJlfWGlWLcOR9M5LAg6eLdNcGcs4B1vcVRTPMJJL0+yH1n7Ov39cVilddCSLxvFc8PJuUQzRs8aM9E6hhQWtaOknZ5018wSUIvGYXwQ6oU/wVXc8HeYvXiZi5MBtukpzQpD84qmHqlDGKeMOmr/TTK3hczlDGIY0QPK4aXPDqImvK5jo1LZ12K5vN2k/hG9TrelyelOK4OWQiEe/GZSclRGkdRiVvl4WmMy59ydl5+izkUUHF6ngqZMAUe0UIwlo3ICIoMbHkwMbawzERIH82ayZsCegk0uvfTBMfX2BaONMuunZpb3yrl/48KHh8D23zyjrOslVVOz61FD1/Vmbo/Ey8xZvAvcDdjUBF7jejLmL5NfhfkC7at+6payLl4iaxd1YAXfe8tc+ulIY7T3Vr8lXIiUL6GDMvfcnglPQJrwRdkWRV6Xg2fRGolqbZdY/fPfTx8jUohG9PlY06jjaE9bJ1NmLwo/+ep+a2nCIgAxg2bDA/3figeFT79tPRUxVaEGRj/m2vmtzBc+FT8B8XV2AVjVhxvzw+vwlanPGz0gUMKqgS4fWCsoA1D0ydZa0EzH85tOUYZ7C9WEyURzTnshxwdN6F9eO8vGyqvMmpUYNLrxb1Ngpc5W5oOaCORce1ZoTm9PdMQVkM255eET49U2PqEZCVKWWzQP1qMcfMDRccNaxQusrEaq3JQeFv3lzBa8i1RUzPqTPZs5bFCZMn6/Be8xhYzgLo0YB0L2uxIMD13weRFRrUi1CpPF7Fy/eW42zWuG3Fbp7n0Dd3+x7fiQvMhJRtUlFXr5I3piRNmU0pwHsdXaK1ZIaxdvCfVPz5B4hP/7h1sfDjQ9Yq1bjvjUNh+w+WP4hjIz/Vk3n+/C2BE+jVcth/pLlKkyaPmdxGDt1tqjskCE43CQN8POcAuWuCDLh/ZFdEA27Mxgk9Y9NQKM0bkyADJmPvymF0px7fxwLuq3Q2XGeooQvzVxYK1qb7IPgYWqJavHXXPCcdcJDw8R47KWJ4fe3PBomzVwg+AQQedC23cI3P3FUeP/BwySc/w0BxBut99sVPPZ7+arVGkFFcRMF4ouXrQod27cKu2/XW64Mgpe1m4jk0JyBUpnNqSZPbODxJZG1P1f1HsjHszpMs9UwF4q4MtZC0gMZANlpUZAu99yhT7joM++T4BG1pg4wUMnI12aGP9zyRHj0xfEidULbB6H/6NF7hXNOOVT+atHzdxti7TZX8FIfz/8OeQCSKMHXjHlL1NaWw00/ZYI4YBYvRHeN5j1q0m5irvmiRyfL44VPMrVVfVbs9RtOlCzNuvuSskZMyj6H0LNLcYKnoKZK8NB4VFRhLg/cZWD41iffG7bt1kGHz7u+I3RQnPDt/nzbk2HJitUiNxBM0C0TKhUn2UfBZ9y8hpCKAj6zPgQPoXp9wdIYXJTDKxNmKrPRrWO7cMAuA0JfCqrWMh83t4gWFFg31LR9SKbFotCpBiaOY/Do1l8jpRabTvpUI7VKx9RaGGySuTVEtZha8DfD8Y6T4Fn5pbXBB7uDcXLJX+4NT748SYFIj87tdXi+dOphYp0QZJhvl3f6LEBGGuQrNkfw0mR+tQ8GRYouDbBXmJ3x2ox5CsR2HrRNGDqwlxqeV7JUEs5eRFBdMD3Tkfp8mclNWCmpAPrwd2UuZt3zI/HxHIOhvqHIq5KdsqGPBwMZwePkqCVWkyYqbPnH/S+EP932pPw8QNDWrZqHA4cNVJd4fJYcKd+QdVHk89XHd22O4L3R9xH1A6dAlQKGIogj392tY5swdOA2avnr3Us5tV7I7Z+ZspQ3YLJU+XWVhFBTAGm0LVNrH2zceJm1Aq+NCR6mlhoBxg0QmVKiR+GOF0c//fJkA4tnLwgd2rRSlqJX1/YKRA7cdWDWj9dbrv635GQ3tez1JXgc3OlzFobnRk0V1klBkAPKZHcO2GWgGNhiqUQTqNHGMh2WG0tB4hwU3jiN3oFoq9YzMfOOWaUZd+Hj5bWTRZvajWu8OTKf+w3tL1rUNl2hRRkThTmtaLqHXxivYAN2Mov4ieP20X/UrHrJnveKK/AcNchX1ZfgoVgAk0ECFi1bqVkZHHLo8lTIMR+YbIb53tZR1JjpSduxpMhbpjWbRh7DjQouYw65uB/owUZpZtR4fDhapuiU2aY0HhHXHjv0CReeeazSO1TMY1bpAPCbmx5V16Xe3SwtBhB64VnHqszQAUxpcLqFxpZkDSIRBX1ofQket0tA8crEWeGJERPVDGnOwmWao8HeQxuDikbw5gKlrvnqQmUhhs/7cByPQ84QGa+wyzViZeotG2/lmnTmXRdHBrLNbujZuX1By5mfkOqaC3WLamVzLmi+7d2i8E9+9Y+Hw+MjJgp3QjvDKP78hw4RZmfQSSQRxFSFjwAo9KHq+cvqS/AcuSB3e98zo4USkLEAWOZ3MJMI6GgZwvJRV+vIQ7q2ebszg0/U0SFp8ujZDycZVPP4pPVm3HWxjK9Ty7H1RV7VGs9xPHKH5BIvPPtYUdTxR/750IvhmnueU0E20Rh9Rk44eFj4wimHyjfdWC7RszJFPlN9f1d9CZ5bAzDRF8ZOEwsICwdCADzFYd6uT/ew7879hAp4z2kzj85/yssaI8iix815eHm8YF2knM1iP8+i4ul3ovHyqBbtUuS1McGz3inNwj47WeaCIXqAxT+++n4tmPrUtWwhJjFRLHgfGF4KJeSLVeTTNMx31ZfgeVSKAKLl/jN8vPxhevrB28OkYkUIMmh3ZuaxMqDItZ0/a85SsfU3qpoHKB5g+H5kgjj19ovK+abhrDeuqXUA2XE8mm/T8Abf7tK/PxiolIfGw+8/c9JB4ZwPHaJAxJPRDbP1jfup9SZ4UfsgKgoyXpwQps9dLHOL+UUoZGl26ht2GtDLupXGru/VmCAdCpSRiDN6167N5wPnfnbu5+V9k6MrNO2OHyplxj8RQKLEIq+NBRf0TsHZpXfKJZ87QWqfOQ9PjJyUDcVjFMEvv/KhMKRfj3e00LEX9S14CjLWrAsvT5gVnh09JbRt2SIsXr5SaUmZ277dwr479dcBt0aMZjLRYuoEn42nin66GVEzuSIU5wkJp87jT/LeDF9F43lNKZFJY5taJwkQWR20y8Bw7mmHq0Txd/98TM4wh4NI9tufPEolivz7nazt6lPw3BdDe6HN6LHyyAvjw8rV1m4NPw/NhL980K6DxFDWnA8v+ImhrZRF7BUYPbfMH3TMtJoUmpZBivo+7c4flhnwJjCwSRPVKBR5bQrH07iBnfqqOOdvdz+r00kXJmAWfkY+tgc8u6RZYpH3XeR31ZfGS4MA1p2mPnREnThjnkysdaKvVd8+mEFgej5ob0PCSeLbJZ1UUmZyBTcvbUOL4E294yKVfPtwEeobirw2iuNNmaOWC+BzO/Tvqf7E+HawZgdu00Wg8pH7DMma6fy3057ebL3rS/Cqgy9qjqm5ffbVKbIclBeQIcLc7tCvh6r8mjJMJfIfs3AiSqGXQqb1tZWZjZSDlzylNN4dP9RkH4MiSsJwirw2FtWOFR+vudos4Nyy8AQQpMeOO3Bo+M6n3hu6dGirpjx5r4//Zo7xG694fQleampZd/ad6Pah58cpk6GWbitWS39hbqlToTwUID421srm7HqE7M0rfR6bE4qtG38OpeR9WOxZS5Nvu6iMj+SYDXnRIq9NaTw6eyrrEAcCA24S3X71o0equxOOanq9k7VefQpeumbIBQXw+NA0PAemYuQWRVTUvICj0tDReXrKZvh4Uw2Mtu4OmqIZGxpl0FxVIzPvNOpBSGnCLd9XK1pviQpYW+S1McGjFA8+nnf5hAQAT/CQ3QaH8z5yuAKgSrZskXdc/Hc1lODxJJhX8rVoPZvDVheWrFgl0wtJFEwPupT3T66eO+uRbDasZmNmOYLP6Zzc0uRbL5Kphe+HumzszIXjeD7dsFmzmtC1Q1up/q987Mjw3n12EJ3d2RKVpXjFC0UR39iQgoemmrNwqTqmElzg4lCfgTbr3rl9OGLP7ZTdSHus+LwR8eqyjgOWrnRFInOakkqT6ETvm/Sv7xucotYHZWUDirw2RRJA8DhFLVowPql5OOmw3VRHQYjPjecnjOITGvdYNdM78WpIwWO9yFw8OXJSeHXiLHEbwfSAWDC9B+82OAzu3TVOiTTAGFnxLluaf5FU2VqpbD6G3gXQ8/GZonjtlgul8WigjHZpbI1HrtaDCw0iaVqjgOey804OBwwbmLdViP1SPF/4DpU5bWpDCx4+Hf1qHnhurDJCgMZkNjj88Bs9i5FqOot0jV+nLvKKfiunntvvrfVUOtdWwojgeW84QttBvbcCjTdljvwNHg41//kPHhy++OFDs1m6rtmsrZYVKb1TtV0RgofVIap95MXXwuvzlwovhZQBrjto2y7y88D5wPRSPM9LTV2LOTJSGcBYTYVVbiSE0Qn/+n6MjE2CcSiLvDYFpxBcoO2O2Gv78JNzPqADkVaM8b6UjVLfgpdDAZV+S/rz6nXy35l5yc1+9XvS123stdV+a0NrPAsyasP46XPDM69Olt+1cOkKBRpdOrYRIRdLaNWIFWJlfl0CDsvsVtXc+s/yFmghlMbffKGNho/pkMbSeJwm651ybaCTAAEEtRPf/uR7w4eP3ENRlrR2ATbV/F3jovlVearz+6gW0MrTnrd3rf6caqHNWL9RaA0Ps+9vaMHTIQ5BhNBnXpms6UCkz2jiDQtotyG9A730uKphk7TTQPpMKePJOw6kzJbS2JsuEJxCKaCp1q6FKDw/FXmbMhc88/Ggs1N297MvnBh2HNAzK7MrQvD8hKanNy8eskMqQawSUBPOam6adFrsqp+7BfnrzBSlhTXVB6yhBc+fE3by2Klzw6hJsxRcEO3S9GhQ765KocGB9Koy/emlqXG6Y3oI/ffZOkWKPGumuouxN31Pbcr4ELQOvTSKuDYteGi8OSo6OWSPweGn55yo6idvgl2E4CEU2aSgmAt20576NdqwDNgxVCAVGjE6VFdq89ekBSJ64GOkrHoO+pFpdNs8iwy9FqahBU/fGYkDjKNibjClkPh73BukDNoCU4FIXt/GbCUTzxnRmg0PjL32IivZbQYWy2fuipyLqfV+whR2I91FXGneUGyJ9bXqWompheIOLerwvYYomkULv5Fv1RD3mx2MSAdy/zLXfJUt9F0EXTBzX4i5ajZ/wicxepMbCA+kApnZ4Rtp2sek2SGjQgQvLiLg8SsTXlfHePrSLFy2Qq3Mdh/SW0rJlZT5pvamtP/xpvxc144ZA3nMTd9TVMvFohUVXGxc8KaHM35ogte2dctw6O6Dwy/OPUnFPkUDxa7hIidIHDPvcAk0wEFRARKj4ePUIY+yGVWFBoDfBixBQQ1Tv/kTwiVzKABqAcXZTMajDurTNZtz65tUmOB5QBCLgTC3DONDg1N7y8PTvhbSANpv3fp8ELSPZ0uFr9LPrRxLkDUGQvA0hA4sr6bJViN4+HiH77ld+PmXTlZdbdG1sZiYuQuXi71BWy8Wlik5VLrh/6xcvSYsW7FGzXAQQJ8fi7LitWBjkC2XrlytSZGwPtLBxrSGaN6sSejUro16xHzsmL2FmYGjuXlyEmUhGi+qIrTzlFkLhOsBIFOGQANvxpyStx2qrqrmk7qrwUDCdORoWgyUZ5hs8KHXYZTG/dOCCxaayYP4U0VervkyU3uJ+XjgRgjeZed9UIJX9IXQ0B7j8hv+o+5K3CeaTJOB1CSSP8ua2m01qGYxzMTmfhpaI8X2PcXk5YD4cphcNN+pR+0ZTj1qj9ClfRvxDB27wNeilnhjMEV9rov7pHMWLAvPj5kqABkNPWnWAvmblI9SCKTGTjES1ntipVllYJUXBSlrEQMQv9/SK9efX3amB0u0Q79iggu/gVTwBKdccl2gvJHTdtge24XLv9w4gofGotPoX+54Olx777OBzVCqTqNG48z46OeYaTQakJ4ngUIca0yDIjQG5tn9ISuOqVPL3VOO3COc9f795cj72tBG9srbniwMSlqybKUmgqPpoEWNmvS6zCvuD74evaQzXzRmJ9KKtDTo8oRt2tdQ/uGYG7/ntUQytQxZK+LamI/ngod6R7Uf2oiCJ+22fr0wtNsfezlcf9/wMHX2QiXS4Qh6TtI0l2PylbniNNDIE+bWlctznbzD009oUmpePnb0XuFzHzxYjYg8if/HOK82FeqG2Cf2BYIo1XwvjJmmLlKz5i5RZgP6E+ylA4YNCO3btpLm931McTsLlAx2kkmOfzcza3ddGn3jd7NABA4cndKLuDYQvGzACj2QG1/w3IfhJHPySaDTNuOep0bL//ERpvhqDo2kEZ0Ha2nUx98xWarOj9OIsvdklrWsonp8PsYjYN4Q/j/f/lQ22rOhISW08eRZ88VYgZLGWmDuuSBp7Du0v5jglbnZtLu7BRQONmcDVyJnT4I36h8InomhFfQW4+NV43iV0xsbV/CE43nmImJrtPaau2h5GD5manho+LgwfPQ0tfmCseuJcK/Gqj64BpqW5Leh6TYmdGoNwTBozYVoIv7hWR84MJz5/v3DqjVrw59ueyoLOhpa8NgbUmYPPDsuLFy2UtPHYSoTHJFDp9XFHkP6Wj69Dg1uEzRz2nteW+v+rvY7w0RDKI289ttq2qP2VCGIa1/EtTULnrRU9NUcANZ0SCCS1Tamae7iZWH81LmCfqbPXaTewiD9+Ibu17nWVH+R2jqZaaLbfLxpbppTKpGZpxD2HNI3XPTZ44U0pKPhGzLIcCUE5sikpeFjpglCIciggwPPQu01gDI/t2ZKtjY2Q8SkR+5DNKt8Jl36RX+PwiVTa44uGq/mXcHLuIk2zEUJ8DhbwyJNWznWDB8QaIVNQhsAqyB4Die44CFo5D7veWqUJoWvXLOuYoy89RYBoojeYvwO+sJQ2ISvi6k1h76y6XV9Kon0s7l3WphRSM/982+0IAqK7quk0PD3ECRcB3VdzeZcyJj6Umk8PWWSolDF+balkdd9O9ZcWL6WBn1FXv6wW5Op1QZXlOxtuOHp77O0WYQWLDVmTps4bKUgUwUk8qdbn5Dz7nCKaQebQGkYYO6wQ5JgbCpkTI9qG1LwqvedgAI/D+AbN4w2FzwZQ212GbyNBkyrjVmM5m3R7LndHUiDj8zn4/evXP8deXhIIhqvqFytP+TWKnhbcvgcB0trC9CMmGCi4r/d82yY+vpCCR0aDk0hHynigJpaFJPp0JGO2W+n8OWPHq4RUAht0dkbOog+O2qKYXrNmqkTq01Taiptt//Q/oK9XH7SPZXOi0QKhNWSFDmnqvTqDeerhQU/xFksysd7pwqe+3e+Ga8vWBKuuPnxcPN/XlLza/ehHMdDkwAYI2hU0nVu11o+1PZ9e8iPokXHwiUrwpVEtQ1oZjd20HAf6Ef9UGzug4wQ6KDl8O8O2mWQhvCtXc9MYGuaJA0eQXMjGMcIl16Feo3ZE5laTqinpOiaXuT1TtF4zjpxrcRm0P3glzc8rAou6ho44LgUbBzDnw/cZVA49oCdpD1IEWJx8KcgDaBJmOfGZgEg//n2XOO5NmnofSJgYCwBlHiCKkojFixeIVlhtAO0NQiixuaJXUOj32FDlg23M86e321s2oOpdT8E7hXV+0Ve7xTBc8cfTcZQYgKJ2x59RWaKMe0gB8xTo1MDPhvF0mQAKFonPZiyczNrEJm9zCD7421PZnnOogSPvYEc+viICUqbUVDPv2nY2Kp507DHDn1FEOV1KZSSmVgHi5OgIguQXvr7t8ulkr0ROzxs8LvBxZYePA4wsMO1/34u/OHWJ2QiyXeSjRjcp6uwL7qcQj0jFZWPRLBvTClXFnQQopTEbPnzHU9nL2hoHC99fljI8POeHz0ttGrRNCNKqO62T/dw4C4DVKMhkkAWWFhUm1+mBlNXQaaW9BB2F3+jsU0tdbWjtoLMxZYIH4tP5Hfv06PC/c+OVT0wQgbJoXf3TgKFaYhDbvyNajJyjWfh9az5i8OVtxXv43Ef1GIwgJqxrARDHAS6D9TU1IQBvTpLe9POTIFV9O0QubSvSq4Nc/Z26cVrvln2yTe8wLn1W7LwW/KealN75sXXawhIY+dqt+RZxCZeX6s5YVCpqFeg+RBRoIbUMX1yCz64oWlRb3RLPBOa++lXJysix9zOmLdIB6Jj+9aaLTKwN/OEvQLNBNCyFLmW03iqOBpee07mQhFWCCqcJooq8nonCV4GpySDhr1J4dvhEzam4CELRLKvTnpdXaUIKjD9CBrcQaY/wlhBfpxv526DoCFR/ksC2B2IFolg+NXfKIuWU7ZxA41vav+LNV5SmWYBm6EFcPL+mwXPu0pBkiBnvXLNWpFgESRcCZjiTEU3apTDJwmIHGufKwDk56/+hsYNyMdr2rTRg4v/ZlNr0Z050k4TejsC55anMTWenqdUCkuXr1LNLV1EwXupySiXSxr38J59higy93Z3aRCRZi7yOpQQSi9e8y2xU0jboDppV1Dk9U4ytZaHzFfPeXebqj19q+vc6IIXgirEYGK/OHaaFBTECMwnvVYO32t71Y+oX3Is1fRn8wNorBtbHMnyM1d9rdy8eVPrc1bTRPm3Iq93muBpYSMNSs8mU1tn03G28NoaBA/XYc6CpeG50VOV02fU/NIVa9TCjEnqwwb1yvh5nsHI8rWbMrWYA7hgIOdFCl6qkqHXvDRuRjjrkuvlyBYZ1VYv1BbKR/a2aoLB2/28xhY8z8bAzcPcAr+BVwJsk1lh5OieO/UVnlddbeYzb32NhfBROfDsX7+uKjP/BdOai7o2ELzxM8JZFxcveBluljghRYK0b7bejSl4ZhotBQGf8KlXJisTA1lgMiWQ5bKGsjBpMx1HlglatLWYWW9xJsF7+qqvKqrF8PLn/5rgbcwRfjNBKPr3W4vgkcUYPnaaaPC0s6DJD4XqxAbgv8QH1sk1Fj0lDm9ec2KrV3rqz1/VSCnMLZqP6X1FXVuDxktNQHa6k8aPRa3FG31PYwqe7xF/+uTHka/N0shRip80A625EYh3376PyA1Gh89JAhudawucQmDBC8Fl/tc0XkZPF5PCTMrWZGa5n8YUPD+MrAnmdeLMeeHhF16TeaXPCm0uqD6DpQIrWV3iI6cwfW/GPI4k7tKTV35FDGQbCR7CPjv3K+yQN7bGM9wtVt/F4cBePrg1CV9jC54LhAPJ9zw5KrRr3ULZC/iGrBUNNPcY0lt9DO0w56RPL/h24Yum9is66Oo33Lxp2H1In0IFzzUMGNBL46cXGlykZpb2FLRuILpmqAtZnGr6dkMvzKb8zcYUvOp7otLu7idfFfSGzGBucdNat2huLS4GbmPzziKmZyYklg4kuVtpPA+XmW1A7q2oK2OjUgzidbWXXK/K9SLgFBc8inSY8wDnbdHSFeGME/YP7913xwwMjsmIig4B9blGlVCDg6x5ymlrEjzID9RhWKlE0zBp5vzIbGoWtu/TTYOWacmRCp4si/dDjgtXevSP55VhvPJBrVsVK3jcg7V/SASvIHaKMYbJLdL1fG149KXx4Tu/vzPMmrckHLXvDuHnXzpJ6SCv8s+q4JP2YfUpfFtrcJHeF2tG37yHho9VrhaGNECycTlLoU/PTmJVU4WGP1g5KDmfwiQl+PAV55YpPwNKoVdbkQByYwpe6vguW7km3PHYy+Hbv7tDMx66dWoXvvWJo8LZHzhQKD0LC5Ud4mZDXxsLcBpT41ULHn1V4OZNfn2hshYwklVTW2NDlg/ZfbDGffk0IF9nBRwRthOO99gfzzMfr0lJvP8iTW3jC56xfEmAX3vvc+H7V96jnnYsEF2RqGml4IbcpIxFXKf6Djw8x7spn3JrEDwJCWSBFavC0y9PFmcSYgCNHMl6wb1j5Nchuw1SoOGmNu2pkgqxcDyveKJPxv+S4LngUz96zT3Phh9eda+S4Vxo/yP2GqIJ4MAEpBNTR7s+NV+q5aqFkO/ZWgSPg0EnhOdGTVGXAdoFI3im3UrKXNDjr3undpGpYn6qxxDp+snHEy+2FNSlkiaBRV6N5eP5M7IYJLv/9ciI8N0/3Cn2sPcxJlLDdHz82L3DUXvvIIp3fWu7NLWkvnuRpUtFV1EdQd/KfrvQuOBBFqB4iUaVshKlkkodD9ptUOjVpUPsqFCJiXoiQ8rziT99uUxUx4KCOu9dYK628U2tnUiaMD760oTwnd/fIYJCi2Y12UwHIjRqJpi3Af2HyjBMCQlxOcmRbWy+TN4XeGMCmuKG6ftIuoOH/fvp0YqufZoRVVx8H+aNmout4aLImxZmNK3E5wV+As/D1FJTwjAWfD2Vcnq7CtjJccqjHbRSKD38+3PLoiTHzAXDNIq8GlPjsQhsOv+jnuDS6x4M1987XNzEFs2bKQ/pDXc6tG2paI3JkTTRGbitF/C0D106tFZLWaI81cU2rUnqK6yome/yLlE4i7g3bOLcRcvCs6OmqkfJ8NFTpT242FQ6b9IV9bA9txPt3Ium61vrbs5+c38SvJGTlKNljegDTbqVarpD9hikzgfeJABLKuilaY01o4z1Z6VH/nCuUma8kcX+XxK8nMIT5Ls8PHx8uPiv94UR42cIBGWjWRuCDaJaq56id3GNBIP/0JLX3qsAACAASURBVIiAzW1at5Bf2Cr+zIWvpglCyEglw+dUEFQLfXydgpr5S1aEeYuWSwDlkMcKLZ8bhgNPDxU2decBPbVHjXllGm/kpNAy4nUQQpEfupgevNsg1WXwLGkhN/9mTdzKlR654tyydfIpiXEAAFjk1dgaL0+CM9lmebjtsZfD5dc/HF6bwczcZiYIcvqC/JbUT3FgVLUVceqQWnXFPnBZ906fSBSBVI69CWCdlQTG1J1vjAmoRYqOI+LIk0eHBcKBaKxLgjduenjq5UnKVliXLLMMPbq0Dwfvam0tgFjyvscbMhRLD/72i2UWigXo0Kbl/5TgmV9mqLrnF0l63/XEq+E3Nz2iAcJssgkV3Zysv5vDT06uyDo/WelK3mnKu3zG79iwxNlYkTYEMAfTK9NU1kEKoe/VrYMa+eBDNZa5dVOL4OF64B+Dg1ozycqo1teXP73O1p4tBAmenyo+iJkLRV6NrfE0zrJpDpWwyYCiTDL8zY2PhOfHTJMk+ThNr6JKAwvr5F7Zt65aM/rrvbAqNfNoBp+ObWtvjrmPgUAxOO+NKHuPAvPp1bKAxqNZI0zktq1aZr0BOby0zT1o14FqVysfL3ZCMAxQIUX2caWHfvfF2DuFRG8zRSVFXo0reKbx8o7kcdvLQfgUDvQV/3o8PDlikhaYrpbMdEBY1WqsBqqQ+WXVREfXew7NpNX0G5Aiky6aXgQtocPcxm6iaDz8SZo0NpbgIVwig46ZGp4bNVW+HNpu9Zq18n1797BGRLSuzYKLKppZhlne/5svlL3zOKf6oF0HFSl3GSi7tTVmRPPBWGH0wb3PjA4PPDs2TJw5X/RvK1y2ptumAY3BnWpB/T3pIOqL6kJXLXwpUcBf6z/zaJCGP0fts0Po1rFto5ha7geN9/zoqYrEifQRPA4FbBXGJdAbBsqUT0FylyDNYAhUvveX55Qh8nH5ROYiJc/9ma1N8Lgva0lRp3YUE6bPU+en516dqnQR7bvwbxAug0mcf2aBhlsVNXJIsL58bc23dE3o7k72exLvNZY/pxVG29bk0fuIcOnD9YrapzSzwjPTxOfJkRMFqNOKlwAD2aHTGNqYaNddCTez+LHOx5OvfO+vzlFdLRLJAI0DCza1W6/geftZ+5NTTYaDWa4UNY+aOEvjpqbNXiQ4ZMHSlRozhXby6MKfzbsJxJ6EcSpGFJtSUC4Y2ITItVP71mrQiBmjVpXeefh/YGed2reRqS86sEi18dr1teHVia+HR14Yr/tgXbAA3j97rx37Zd3gFTDFvsh8hs8EkcbD1LoTC0YEDlPktTUKHlrI8LvYgiJ24uTEssgsNiYHs8tAPChCdIGnuh4gmrI/zT1bt14tHzA7vA/8Dg2IS4MfhJARCZJiAgPj30BaCBrC2KJ5TWjdsoWa5tDGNu2b3Bh7xHfi6458bWZ49MXXYtNFa9wOtW5I/x5isLds1rSSCBqCcFEiYoeCSvf/+gtlGu3h0xDVvuvj2ZZ6sj4lq7rJ9D/TwXGeOrIO8LUSstzc+PQei3wRIB/xgAaDUEl1FtG1QYbmO7opnr1gaUXzbfclixK+VONxmF5+baaifm/Sg5Yn8Nll8LZh1+17Zzlmy9ZY5ISMMRHJnqkcSndf/jmrqw1lnTb6nRV5bZUaLyOJGr6WBg1pPjYPAvw1+Wv5m8OmKS6XBg7pZ/nfU2qU5zXVmPH2pwo3sakc+DNwsEZNfD089MK4sD7m+HlOfFEAbpq3V/bDs5VQtJ58YOmeyz+v8kYunEIG1xV5VQve1tKY0f0xDxpSv8q1YKp5Us1YvX4bE7y3ssauaaBFNTZJwJ+BlmX0dn7khdcirGS56I5tW4u7SPDjE7zTQ4bbkgUXaDyZWuXV6uTcNrbG+2/uFvVWhGlLXrM18fHwaWncAwuZw+ldKGjeQ1va3j06VY4V8AmXUd1ZlFsKpTt/8VlpPE40KbMDGxnHe1fwNhTNrUHw/K4gUzzz6hQxaRi6SJAFjkmB9/67DAh9uneKOe28Tpn3qq4mjpRSVIvgudRCPgSgLPLa0NT+9zZmbKh1a2zB8z3iTwDjJ0ZODKMnv66giDEEBKbUqcA+ZoS8M9o9CJOZ9VI97xJ6+88/o8wFH0oYf/R+OzXU+m30c98VvDdf7sYWvPQOaS5OZ1CyOETmK1atVeQKdxBEpLuYKXkYocg2m1yeeX2hdNdlnzMfr858vCP3HvLmK1GPr3hX8N58MbcmwWNy5QPPjglAPGRtNDa1CWPjO0jwnCCgWDYbqlfZHkR7fstPz9ZoeMJdotr3vCt4by4JBb9iaxE8BAbi6n1Pj9boVKJZ64XcJAzu0y3sN3SAOHrpmHjMrTfwqYhq8fEABWkQ3aFdq3d9vChUabWX9/a12VzGRFE/kHxOkt7VUKmsxha8zMcLIcxbtEx8RUwscy/olUeelqbtVCg6xczXwzsIKK0W6e+iS/3rp2fHXG1J6PMx+7/r42EigJe4iOJmzl2iDgPt27YUM4S8KgwMkUSj8Dky0BDKsLEFz8ymMaXJVd/5+KviB5K3pcqMFODeO/YLOw008Dh9vbfg5dDCdPeRoqVbf/Z/Ejz+o8qMniFFXlujjyfBIydbWxteGDM9/OamR8OoSbPUaLpn1/YCSXcc0DPstl1vLTZ5yvro7r6pdd8aBI97I39Nkx7mtCFQK5WPXhfat22lWh2KoETljz2PTa7y8aFKVLjfh+CJ8VpbJ0bEEe/6eNnAYhZx9OTZ4Wd/fzDc/uhIMS0o7IFG1rFd67DH9r3D2SceKHImZvedaGrTrAv0J8oBHnxurOIBSKHkpWHTAKWA4RnzOB3MZjUrKeNaON7NPzlLypE3tGvV4l1TG9WOR2TkJkkRMbaTYcdcnHZOLxVlCN1XPnqEOp9X+3z1ZTkaU+Olgge75KVx0wUgU8q5dOVqKSzGTMFqovQzL2v0ghPjJhovMa89Lt3ww09rlhmmgk4Cxx3wro+X+ijOOaPQ+3t/uCv8Z/i4jNqDKWbRjz9w5/DNT7w3bN+3W4NovcYUvPTw0Hb28ZETwqsTZymgwP/lgAKhcAAdPM7QusjzRNuB+VnjHmPJlm685IysPx443rvBhTvHORGUnyxevipcc89z4aKr/h1WrgJCaKpFhJELePqJ4/YJX/zwYQJQ/aqmUaXMkzQKTrXKxv5OYMNo+Ma+4B8y9Hn8tDmq74UwgDD17NwhvGef7UP71i0zBkoKnaTrgdytXrs+lP5x8adFP8YOE60xMbrIyxe6MedcbOp5HVLh95iZ+58dE770i5vFOMbPI9WoWtsQVG9w3mmHh0+fsJ8+LqupTUx3Re+QyAiSSVey3fAuq3/BL8rNEoLno+FTgS1qn3yPFi9bGe5/bqw6p7rGQ24IKg7efZAKtp3KlT6ru3K0w+NZgWRK1/7gk2phwWkETnnfQUOLeh59T1plBpcfWlTRA1be7IFZTE7pfc+MCedddnOgHSugqZlkm1qIwJAk//VXT9EETKWKKjolGbXTtKAJmtOe2AwfGe8/80aQvIcswZ9uNY2XatE3u+/6+r0zShYsXi4Mb86iZSKuovGoN9mxfw+Bx47h2estqHBfGegJMui69evD2ClzDMfDgWbhCIuPO2Dn+rrft/Q5W6vgpSaPB8HM3PLwiPDt392uRLn3MXEtVVuuC906tlOgce6ph+VF3VmxeN7Qx5D8fKiNH0B8Rm+Engqt+3jV7Vzf0gLX04uAl2bNXxpue3RkWLZytZ4fbc/Vq2t7tXTDVXNBiyTqjEhLWpa6DDqKjkHwrrvoU2KnhFJZ/P4TGknjYWpfHIfGuzaMnjynkB7Ib7QnKSMDLUPdw5V3PBUu+ct9orUT2XprCw4tC8vCf/HDh4ZvffK9WRtW13BeCO6miH9z6OhcwBBifCfAWVJO/Xp1UpkghTOA1rT1v+qOp81/ivUf9SRPb/ljaJ82adb8cOujL6uVmgPDAO30xaO5EJ1AOTzcqJna3H3AqvK72fOXqn1t6bqLPlkO5VKgQg8WaVE+nm+sazweBsE745Jrw5itQPBsj3OWBTnKX97wcPjFdQ+p7NC7nnv9KOaWniE/OPv48Inj9q0AlNPP8fYXaItpcxaFGx94MfzjgeFh5pzFxuooGVZIwHL0vjuGjx69l4qA/nrnMw0SMb9VyQPDoyn63U+Nsuk9sTcKzwaMcvie22f1tBv9zFJQtoM6ZRU9Xf/DT2Usb6KSony8ao2Cxnhx3HT5eIC2RXR9f7NFTwWGgAIg+dc3PhLgLaq2NQYGfA7dpAZu0zX84VuniaVRXauRgsts2pyFy8L19w+X70aNLoKL+bJGPfb3zh1ah/cdODR88IjdRDVvDP/O14jgasT46RquQkPGVWvXCjxG6w/c1oYlp/W05o/au13LE5xgZmGylK75/idkah3H+8Ahw95sP+r199UpszOi4JELpf7j8i9/UI0Ri76EtCcd3nHwf/iXeyUo+Cruo0kYYuZn36H9w98u+ETo06OjzIxSRuo2ldx9qaTE+uMjJobv//luTazku5zBgcApwo1NDelHguDxezSNb2RDZUk2ts48B4IHcEwXgU7tW6lzKpoLWImG7XSSzQ2E2VqIJ1zOxyPdxiGjGk0AMg/BiaO8ETC0yGtrFjw2m/tDyGbMXRx+dPV94ao7nhJh1gQvT55jfk8+fLfwx2+dJlOcBgfmTli3Ad7DbIjv//mecOsjI7V5vbt3Cu8/ZJg25b6nx5hwxdfyXTsN6KkZcx3bttLvihY6vo/gijqLMVNmiygxZ+FSBRckHRC8YYO3MZA4FbYEVvIuU9QL77ljn1C6+oLTJZaoRfKP7z9469B4jW1qXegcEpg1f7EyF9fdN1yax82eayCE7T377BB+89VTRAO33G3SsiymJTHZ1977vISY6Hi7Pt3CVz5yhNrcAlVc/Jd7w4rVa0QhwuejhQY9SmiFu31fg2mKFjyeEebxf4aPD5RaIif8CQQEkEzPPp+Bl9USx5JGabxSKcxbvFyF4Dxv356dQ+kv3/t4WTnGMi0sWujUFnltSuM1puApx83wldjzhHucMH1++Oqv/hUeeG5sZvJc+yBgaC425OTDdgunHrWn5nqhrSQkMcoj5QSzg+zH+Glz1R39rA8cEM455VAJ2g0PDA8X/vHusGrtugwHhHoEWEv7OMoHixS63D+jaeUK4Zh00SLypmczrdmop6Ur/tBBvWKtRSo9pulZm3FT5wqP3Gen/qF9mxaJ4AVrvn3yYbsWKXdbcbco808wEURztz82UppqzoJlKnIRQJxx8Uq26HXl0LVT23DI7oPCl087QprAC12I2oePmR4u+su/1fIWoaRPzQVnHSuhWrJ8dbj6rmfDD//yb5kwA5iNvIHGYE4Y7PDGEDzug1a5jJLiIPDsTOjmgiqGL75t945y19xf9cwNz884B3o4Ywk4kDqw1170yTLqHLVOE+kPHLLLViN4NJ2+7Lw8uCjSzBD6vzZ9Xrjj8ZfD/c+M0d+he5Mqc5qPC5471USriGvfHp2E5X3oiN2VDSJK5f2/v/nxcMP9L4Tlq1ZrXNUXTjk0fOr4/XTg8ZmuvvOZ8NO/Pyghxl9ScBL9SHwoz6MXuQ4IAwIFloipJXrH34PyzvP36tJeBAGgJLI7PkZK1KfI1KbJEcQCov0uHdvIRJeu+u7HygrjhR81C6ccufv/tOCx6ZxmEPp/PfKyCKCcWO/YbuQKizq9L0gKDbFJ8BoBkj9/yiGhbcsWMlM33D88/OrGR+Qb4SMef+BQaTvynHzOjHmL1YH01zc9qpxnmusl0IB0SiFW0ULH9/GsmFaCC4QLWMQnIPXv1Vm+rfVFsYPiPQORKfA/hrHQSevQ3bcLzSIGWPrbBaerFS15tM74KFuRj1et8bb0RHiu0eskHJ/zQhQ2GUyK1BSle/hhz7wyRQ41gkhAmjditLvwLua+MWYCrYUsGu/bn3pv+PB79lBw8NDwcQoaOPX4PORyL/ncCeGIPbfPOqFPeX1B+NHV94e/3f2sukS5oGtOWJMm6k1Nm9eiTa09a63GwTPbgpQXfp4HEWg8DgRTofKC7axPpdaULvqDe3cNOw/cJjPHpT+f/1HBKawb3LKtSfBQ4fWB42VAcEQ0CRzSDaSl2LOvTg63PfqyhITFWr1mvXV0iqRP7/PmbWcRDK/LyLowRnSAvOWFZx2rRoUvjp0eLvnrfVl3JThr55xySPi/Ew8UG4iLTXxtxrxwwR/v0j2AE2pPQimsr6sVtR4Cwr479W8kwatTCzZa8+Lr0cZCfloohd7doUTtUNmMMbJtcFcIorAg+w8bKPKo+gfyXkytNEFNE+FEJ70DgwtppUg5qsbB8Fduf+zl8Pd7nhMrBojDGyl6oxkJhwbQgFPlqbQ0aW+VaGX1uvvqx48Mpx21Z5gxd1H4+bUPangKnTPJ5eLKfOP0o1S3kVVg1daFsVNnh6/9+lZlKJwtZP5VrYKZQ3YbXOgQ69S6ICxgjLQmg9LkrXEJgAZt21XpMg6p0oeqtzDMkkaWNK8kGUAFmrOTdagQPO/GTU7wxEO3nuCiPjRedZ405YlhSolUf/vPR8PrC5ZmMyc8K8Ei+iwKz+44k9YnOaYgMoJ69H47hp9+4UQJCyMLSO7TX4Tf7b59n/Djz79fhTE08pa/FlNkr0yYFc659CalDdFwjhN67zlqYYYO7LWl3sbbeh9R9uRZ88ODz40zfzdG3CirnQf0kgtguGdOc+fZps1ZGMZPnSuopW+PzmpUmaX9rjz/o4pq2RAozKccucfbusnNfXND43h5NXuO+KPhFy5dIZrTz697KMycuzjLNjg3zuGSbbp1DIfvsZ0WXGawKRVlJjS+ATZMpC4M6NUl/OK8kyVgaFE+e9rshTKdPTq3D5/74MHh7A8cENq3aZUREKjGB2p5dtSU8Nmf/CNMnGGtIdz/rK2tVfkg5ozec41xESCQsXjw+XGxq6dlKBC8Hfr1UKEPwYUTJjSGq65Oc9kIQvbaqa8O07o4uUhW54pvnlr2QmVSZpiIIq+GFjzFWAnLhA2lLO+xERPke9FQm1+bGfXBbyH06NRO2NmHjtxdmuamB18Kl9/wnwrw2E0xXDwCs0+9b7/w+Q8doqk3sFhGjJ+piJUm1arLOP29YUj/7tm8Bxde6PMwe7/4838K4Ue4HcMDegCkRfDY5KIv7pFIFpLuEyMmGMYYU2M8P8QIcMuWzSs7CDD4j0wF6bWdBvaKKICxq6PgnaY2ZSw6UwmLhlN8IcVOGTs9nHlJ/bJTDAvzNJMl7oE3yLmikSjRk/9WUyPWBWORhvTrLrYFXdaJxsZPnxu+9bvbNdsBE0qAIW0XmcNotCP3GqLByhTA/ORv94cnRk5SsTOs3GEDtwnnn3G0oj/8txyGsVQlfuZ19w4P37niDpkjFzwftscAa8BjouGiL9aLYAIA2A6pSY7VywYROHCJqLjzaY24KJNnLggTZswLu22/rbR9Wt6o9175nY+WoSMTo5DCgQlR1JViUpgbZmSddcn19UqLqja1CBmQAL7dT695ICxfuVoJeOas9uvZJWzbvUPo072jptRA84G+9Kt/PKxWsDZtx+pn7bAaYMyIp/M/fYyGi5B5AOHHr+P56NT+pQ8fqvpbgjdOPNrVwWEEGIedovGfXfugPt/YzbYLmHCiX9rH0Z+k6AuBQQs/PmKC0l4MlVGwFXHM/tt0VnChgXrrLWJFKwIdwWxn9ClCyc+gk2UC+Nuvf7hMA2j8FkxtkRqvWvDEQI4aL6VFAVL6tbk4lncF4H2evgKzmzFnUXj61SmqhD9w2ED1jcGXwr9CK8GiWLx8pYial/79QUEsPl9CGQvys+vB2EqhT89O4dxTDxeD5fe3PCZTzrOhHU84eGj40efeLwaKzUKL92FVz6HUpImgCsz+lbc/aQSEZPQSwk0O+D37DAkDtulatNwpuMJPJWtBANa0xg6elyxi/g/bc/usGSNrhP9MBLx9n+7quKB1qrG0os9tK/3uG6eWyTGymOTSPtSIGg/BO+uS68KoybMVgqd8vBT03dzVl3aJb/KUDr7T6nXrpbWg/Hsxtld9wZljeDJakVHo+DUIJM6+BRd5+SMmGm3EBlGX4JszdOA24SfnfEDa1Lu5W0Bi4Z8dvKBZGV//zW3KlpDDzeZkaLJjUDoKdgqsjqIvAotJsxZI8MhYpKgALsau2/W2+Xea52ajBwhEGEhz8O6DQ5f2rY1lo7Ghtm7y8dB4+kuppBfBrCjqco3Hn5rsM35GOPuS6+u1ysyFzmeE+bPlohij3SwAKak2gJb6l177YLj3mTE6zUzt9hwqWB4XwmpNCI2F4dMcMUYIyZdOPSx87Oi9Aj6aVdPb5Yl/vxciWSJaAh4ne5pWsS9iVi01DUTYRV8CgafPDY+8OD7Lz7qbgOAxyYfaEAOGzR+ELMr9H7TLQPm47pIYS8dWofTrr54iwZPG69hWaZ6irjTabFDBi46wO8RJkGvSIAp73hKfASmXXf+fcN29zytY8E5Q1W0Y/OCozWo8yZggEuGgA2QoenfrFN/vGjKlI9tKQ/U//fvXKC+sQnHPsMQbpabh0D0Gy0lP3ZMi9olgZ+yU2TKdNh7U7t+QgBqNrt9vWP/MAlhqbaKgn6GDtomv9RxufscSPA/rab7ykffuVcTzZDfkX4YTDQ2c4IIMQn35eLZI+bASN7U6dZFv5+YDAYKehMCh7Uj1cCBTv8yFkIUnhxtpewZ/xNFKxx6ws8ocoXhXzx1Lhd6JomjXU8//izoxeecpd8K5Txz4Q3bfToqhcMGjQAfBe+G1sHINjbZt3bg/fNjdtttW03zQ/PyMZ6C/ivVD7qg8rx94z+Vq7fHx+CUah5MFlaeoqyK4qK0VVnTWxRsKXn3VXERgPXs897W89JBIlAX+9u/vUApLE7PLNpWGxea0kw7yNlx8kCfx0Qxc0NR/es6JwrbYGBb7jYbe8Xva95/23b+EmfOWWAVX1ZxXgiuYHSiGzQ2u3u5eEplSoMPsMtJ+BAlualkfOIcQQfkZgjdt7qIwcvwMja3gfpGtzKykTaQu//IHs2If6DyU0xV1vZHgNTQD2VnGfnoRIAifaLrbH3slq/bnhAOtgHG+NH561vXIhcPa6JtTzcH92seOlNWAh2fOTBwtv4lFxX/EJ/ro967WDDQFF7GzkhccueBxD0VfgNuvTJwlOEU9T5LWGy54zDgmCOGC2s/zQJAY1LubFJpfDkNJAyJ4/gMk9GNH713YszWm4PGQbKz+VxfCrAVLwtV3PaPmOBRv25itENh0/DVMCNifBiR7+/yoQhE8iprPOGH/8PkPHayUmEexZtI3XaDDhj39ymT5eGCGlpLLWz9Um9rCNid+EW0qyEBQFQfe66wZnpnDteeOfZUi9L7HWA34dwRjVofhPqE5wVlwcdl5J2dRbdcObcPHj/3fETyHRAggqCf42d8fkNYzswqu2TJ8/Nh9wrH77yRNeP+zYwUJpKWNnOhWLZuHDx+5e/jyR48Qkm/wlPXQI+h9o755UMnRJp/4/jXKqDhBwLNJbBbTcg7bfbCyKkVfPsnnqZcnZ8IlBrYmQbUSlLJ93+4ZXEV0C3hM+SN+Hhgx7kTK1tZB/PE5HygjnSw2/HnabRV1NbbG4/tJXMO8+O4Vd6mQR7TsCDbDIjn/00erkv9TP/i7GjSmJAES/AjZgG27iHVCG18W1f1As7SWWtuU8KHxHh85MZx+4d/CwiUr5Re6z+n1Cz27tFNvErh8Rft4HEqm+DwzakrMOtihUrP2tq0CtcTb9ekuSrzf22vT5opGRSaHPDPCyLqsW2+Ufq3LL7/yoTJ+Bg/Zr2enRsHxuBG1KUuCi4b28fhONB61DtRCXHHL42LWunMPU+dzJx8cTj9uH42G/9qvbhVLGezKi6290eC+O/cLl37xJDnalgQ3+MBhkTcyte7jfeyCv2W0+DwlZ3NuO3doo1wtHL7GEDxy1M+OmmzPFAFwnh0LScESPnA+5rQs8id1FqTSMMcEZ7XKXlhzRgnepV88yUgC5bJytZ88ft+iFF5ODYJGU1sn592j2oYQvFTDKvm9am2468lXw/lX3ClqFGCnMXXKqpXAf6ET0gtjpoXRU2ZndCWvtQU+IRjAuf7ZF08Ue8QhGodN3qwpN5/xysTXFdVOnD5fn5f6eAgeBFLYKZAuixQ81oh2s8+8OllrYAcib6/GkGeb5tMulggYoD5pxjw15qE9LW5IDqPkuIIEj4XmFIOQF2tqTTvwMAKQ6Z1yyfXys+pT8KoZFQgFJm74mGkqN3zsxQk6jT63V5tbLtvg4liPkTNrLVhw2hKmlwVG8LaEPcK9jZs6J6DxwC8tc5Gn1BA8csiYWvrQpQ56Q2sICd4KEzz2hvVJZ3707tFRlHaCUnNR7I4mzVwQXps+V+sCwcGL4z3QkAW49EsnKVfLP7p3bl+o4HlkKUgia9pTv0P0HHNCgtz8YdY5kb+7+TG1l0UIETxPq6WOvUSgqsmgm0IKmil35NRf+sUTlRDf3It7gj5Ejprolk5RplUM+GajqdaHpoUpL1TwQtDIKHLVuEH4Z+B6XPix/Xp1Fpsa1o3PL2Mv8e9QHuB7huXlkIq5OOVQ+sW5J5XXracmoU62ukiNV4zg5cl8L9hZsmxVuPnhEaI7wbhA8Jw1YZXvlne0FFnMLSadjyxYsNexGfg5l37pxLDTgC2jpnMIzvnZjaKWN1eFmTWo9mIivou01H47F9tJINd4U4TlEaEieAaK12iaz75D+8UoHtYNHbQohVwq7iLM497dOpqPV0vToZrcx/vpF06M3p6xbov08QoRPOF1BvB6Un/ijHlinfzjgRcsguW/GtO6FpHmCf0NWDHSnNbIx+k+nPpfnHtylpvcHK3HZ02fuzh8/de3ilrvm+P35REhBd3v3ceG3xTl53FvLR35hgAAIABJREFUNB2HBIpWNkG03DXR966Dtw177NDHZtFGNIA1XhIb/PTt2Um53BSkd3y09OPPv19VZiw84TpNBYu89N0NaGpdtaf5wlcnzQoXXflvNcnx/ibmt3lbMSdqVoKe1XlSBJp7Z8bFZeeeLKr85l58Jj2VOQhX/Otx+ZReWGTcA2Pu0HngxEN2Ca1jSeTmfs+WvN4F7/lRUwN1v2COkGhxL8haABwDEvM6N6e4LGQ7MM1ce+3QN2OopAemdMnnTih742fIiu80U+ta1RfeU2MX/unu8O+nR6t7Qjpd0HxBC7YMNgmhRg0TTdN4YOFsFv5NI5rLzjspDBu8+YLHfeHA3/Tgi6LXQ8VHm3hKjt/jk9IO7LA9BkurFqnx6HcMeWPctLm6N/K10nhNa3TQ8ONgj2d8xybUrtTp9ZR3MuMMHNRJD354Sxf93/Fl791GcEEVVFFXJYBsUS1wyqjJ9R/VOrzBhuL4UjyN4LVp2Vz8O6/cF6hM37eWzcOQvt3V14SFZ9EZhY6vkl0IZZMm4RBFtScpP7klF5w3qsy+fPktgm3YVAke0X6sAOQesUh7DOkrej7F92gXHSwKctas030DvTjkkUIfWyKsBjmtCaMmzQ6vTn5dqURvuM09cgjocCCrEoMGB8op/IZ1s13f7iptRFgr2Ck/+twJNii5XBa1ukiNV525eHFsTn1vCDhFG1lXJ3Ys7SJufOAFmUocZS5+x8LSjuHU9+yhUs+ly1eFH1z1b1GDgDo8SsYvRBgASKkgu+gz7xPAu7mXa1cAV+o1KPqhhYVcg1BSio57UlPrEEQqRehgJQNyo7HBI+ltwj0dsvtgAc5p85zNvSd/PcLEMBnGwI94bWZg3IAJmHHxaMgIJWrNmvX6d9pTUN0ZRk3WvVDTwuHySj75qT/8zPsEIPNhAIL0ayvqqhC82Hy7vqvM3MdLTz+pKXrR0dOYtmMtWjTV5nKKgUQgStCujVoH6gd43Z9ue0JRm7W1wJk2qAMN8+kT9hcrBehgcy8PXnDib3/05fDDq+5V7Yb5nhEvVNmlKQfviOC1ITwXigNzR8PuEw4epvuo5h1u7n356+nVRwoMSAUSgwX5JbkDdAfAx+OeXJsZu9oKftCUrB8AO5bFiASR8o+pVdShzEXb8NmTD97Se9zs9xUleCZ8ZpIQGuATWL9/vPUJBRjQfXj2A3cdpN4xOMS0bOM9JMmpGqPmddHSFaFFi2aCD9x0AEF94/T3hI8fs49M8eZevgZsFC1qqWYj2maTvZUF3+W0I984/x5P34Gl7b5Db9279+97u6aW7yCgAAXwvilOfJCPN3gbBVZi+UTXIKX1kwOnPdvuQ/rITaBexTVm6QdnH18mSiHAoNjnsycftLlrt8WvL1LwnNvmXjAV7hNmzBVoS683RkKp1ULPzjJfYsTHKBeB+MZvbwv3PDkqq4tF4yAsmJuLP3uCTNybpcc2tlB2IAxgJRfMRlH0c8N9w8PEmfPNj4tAcuqnGeOZKrUQurRvo6KbHQf0EGPEc8Sptt/STeIZoYQ9+tJrYc6CpRm4zfPDSmGiD9rZKshMG7oWh5tHGpQqvF0H95aMOX1KgqfEbSlo3uiZ799/S+9xs99XuOBVFdoQLaLR0CZtWrYQeNu0iTv2eZIfOjwtKc697GYVaaOJbFJN03DCQcPCRZ85Xry9zXXgOf0q6GEsVblOlCiED8f8hbHTwq2PvKxah9kLluRdliK0zSaz4X16dA47D+ypA0N6amM9+9JgY3M3yRr2LNSYAVrJOkkA/xOcjgOH35lS9b2wCd8TWAV5JH1mzxshqvM/fUwZxgU33LVjm3ekqfUTmAq6RbledmfBxcY1kvlVY6fOVdQJNR76NwuI+fjmJ47S4DxvyL25Gyv/qBYsM6/OZ4eg0qMxqPCitPBfD49UhMkzUDwN8ZSNp5icCNx4fPbtlZrx7TXrRktRU8w9TJ+7yFrk2tLpHqwIqZ3u15nTjiBwsKGSzV24NBy59w5KB3oAUvremcdGfICF7FioqU1NQUO1sHgjQbAF3PjGuJC60NJZ6ub/jNCAFTaCMgE6Q33t40eK8u7jBTZX8NI10HepL55d/BsNiI/1x9ueVACBwsDM4Q6g8d6onmNL7qX6PT4MBs0r0x/Z0dwjxFTKLuFxqnVZrBPOqfvkoecKviL67d+zi6JzuQjf/tR7VXPBKQEdp2FgEVe6sRXslIutoLs+4ZQteR7XiFE25Zvg8D85cqIIBjSbPmDYQHEYhYO+SW3Flt2D6QRynwx2yTMrG5ZIbsnnv5X3ICRwFiluB4ZyP5Y7gM1E11Z8TIIQvzwa50/W7LnRU0XpIn2WNWb8zqeOLquEr2SNlGmlVcS1ccGzsaH1DSBvyfN41sLNhjFoa4VHATG0bNYstG7VPOPvvR2N92b354OSq1N2b/a++vi99U5ZJh8PwfPeMXw2pp75HPjHBiznY1Hd7FOLy0Qg4BQfl6DnwNSKY0Xv3p6dG83UNhQtaksX32lJ0ehZojuWHfKz/FRbRfjmBhZv5b5c0BpzNDzPTC0Ivi2TuYmy/b5IsRI02LjQmGaMrounGCkWen7MtLBuXa36v2Ra8ZufOCpr2tOza3vRvYu8/CF8QveZDZAy25Ln8YhTCyjP3/vsxVPt9RRSifaz+ha+rUHwuAcED1NLRwHvjcfPyZzQsIfuWmvW2fwLj3pZFvzPVWvXC3wmH41PDBKgqebnf/posVNYNJzkInE8d6Ddx9OE7kuuC69OrN9c7ZYIHk61YIj45o15VT7CPfdt6tf3ygRv3uLwx1ufrHfBfivrgjWko8KjL74mjDGl5RNgUbiNyUWGaqNfko21Z0DN2nVh+JipIhfQas0Ft4TG85MKH++8jxz+Vu6n3l6T0qJU7HPJdapBaPzgwkROgabaWGzY+8TTV1uCkznCXwnxVNKwfJEb09QieMA6CB6cPA8ugB9pMwKOR/NKNJ7Xq6TCwRgtqPPOonYygQTPfBYLLuhwVMSVpVgSPp403sXX1Wu3qCKe5c2+w6NRNdpmbkVs5ZpGf+lnVAtyYwoeBwRWCoJH9RgDUoB1EEjasyF4zKv14MKew+hjXDTxeW70FNUb06RRFCoO85c/cnjZOWlUC9Fspojrf03wXDumlfUIWJpmEwkguj1pHW5jC96S5SvD4y9NFGULoBxsEZ+cjMXBuw4KQ/r3EC3L+YrIj3dPRVj5jx6BlEOC4ynX/Y3Tj1LTHt6EVJ7zoUOKkLvsOzZgIDdAlVmhD7SJL3Ot5wVH9u90LFXUFTHfmUbKjSl4mEMaaT8xYmKAuQ2MRJ5bdbUd24S9d+wfBvV2jWd5Z++uRcqM9hekJPEF4QRkKbOvf/w9cglxCHt26RC+3Ig+no2Gr38iaGMLnqAG47FnQHNW2JykuBwzjNYqy7s2puBxL0SkdHxH46HlyOKQ5oPRQyeB/r26ZARRt2QoMpoQUSSEQqMwCAHMBO8rHzlCwQUqn1nx557WOMHF1gan1KewOiboGg4zBL4F7w5qOa0gmAikEVOxUWRqghtT8FzjPTVyUhgzdY6AYDh2PBM52gOGDVCn0qwyLzp3CCa1tUAxu26/rcirnrWQcH7tY0eKgcyD0lbq8wVlLnxj3dS64J1xcf2OG6hPAXo7n+U5XySLjbvjsZfVDo1K/XatWqh+geZA1M5SwG1WyDz0Rhe85as0uwM4xUeccl8AyPsPo3aWhpFpsVRJzG26+NNMEroZ+0xqNtP0Xzr1ME3oJt8I26Ko4GJDwaMjKKZ207nahkgZ5UFOLDqO5Y4uKMpYqJ3ZG3d9eiOhlD8nO2otHibOnKc6Wuos+GyAVhgmtMCAiPrxY/YWZZzdBCskV/qHfz2RFRw1xDps6v4RFFgxz46aquZGaDxo+igqaimoqyVoSIu2rS3HLFXPUZMBSRUZW09QEb+o9NWPHaGRUiwLs1OZs1rktTnljfW94MKUIlAMHEBvN9pWEOVzOnHK2PjE398iMkCefjMBnj5ncfjR1fdpRClQBZxADUYORjVikOF5px2uv1N2isa78ranNoolNvRecU8IHkHCjDmLBZOA57E+HJR9h/UPLVRvYXeCQOLb0cF/uz7d1ElKee51NhEo8wG/9OHDRBJQsU+vLo0KIL9ZcFHvghedfrQQsybufnKUHOBj9t9Zi5aRKrPG3Pmcis3ZcNd4hs8Z1442D8AMbOhND72gv3szIDp/HnfAzlIC2/ftEeYsNMHjqu+03Js9B/fOwRgzebYIqpAk8PXg/w0b3Evliy5QnJz5S1ZI26EZcR80oTxqOs8CaR/PPfUwMZAJgenzxkkr8mosjefajmdds7ZW82J/es39Mn/QneiocMCuA3WaXWBc8DeX4l5JOLAibZ4bLYs2o00aEySpY0A9YKrguNGJFEAfM0Wn0i3JkLzdveSZqUmBEzhtziINjxk/fY4q8/BHqSn20Q1Ll68OY6fOEcZHO4+O7ZhkZObVcL1E433hlEPLUvMqb+zSaFHtW2Gn1LfGczYsJxUBYPOvvP0paR46XX72gweHo/YZItObVUe9QVvZTW2yaTx+uyFtiM6Zf73rGRVzI9Be4IPwAVN87ePvUXbgmrufzcxUfa/DG/qncUQU5pModdGSlWHCzPm6V1pY0NOFA4HQwZYmRcbQQSrdPJAwfms+t0Pdos477fAyqp+H2aF/z3BuQSmz6uCiaMHzIhufF4s5ufuJUeGCP90Vps9eFNq3tTar3/n00WE/quU1mcb6E2/uZYU5dRbZxeaE/Iw6Huo5rr77mfDN394ugJW5X5AqeS3dNOmrTLDxz4deahSNx7Py7ETiDIJhrhmMYi58vIN3H6QiHiJe0mN0vd+mW4csMPK18sNiDJYmofT5Dx5cNhsdVJAMlbvIa3NMbX3fV+p7sbhMk2ZKNtABpoQkOI7+N08/SgwM67iweZdHx376vU1thOsUXBCx/uDP9yiwwXfSe9Qmomk4Zv8dw+nH7qOaB4bQ8fsiL18jGvEwRI+xUvilPA+sYlpYUEw+9fWFYXCf7qF/LwbKJNzFWBtcE4vp3UctnXPKIeqPx5MO3LZL+OrHihG89ARwM6q5EJxybRg9eU4h7BTbYFsZAM+x0+aEb/zmVmsXFhskQhX76kePDGecsF/SsmzjWi/H6nLRSLMRnjjnu2xeRJAG+eU/Hg4/v/ah0LyZN/bO+7RQZE+R+ez5SzUxCPPbqR3wROwlnHTobKjgg+eiTx41FwuXrtQ+oeXQbADfaEPWidQZVsF9WhOymK6JS5JFted86BB5Hy7BRQletQrmQQAcwfEoti6CFpVidSS+mcdKr2Partq8CRr2lMJR++wYrvjmqWo+80YU9xSvq34++3fu4/l8DDTFRVf9O1yv1hVNZYpVexr9Is92kFwnT0oXTkwcjBAnd6THoCGiXgRpxeo1glFwQ2jnoZH1LZupDS0wHOWdjMNKrYiemOAC1CRGtk6YLX3m5IPKUu8hhH49OoWvn/6eIjV55jC7j1d05sJPIFjVPU+NDuf/4U6NFMXUkvAmGuvfs3O4/MsfCkfvt8ObwhnVWs9LAV2A2ESiWTQdtbMPPT9O3Qzwj2I/RtX2asxmnP+K2YX9wXspRcVxp5AcuIKR7NkGNwAL2j8bQidCRwCBYuBmALkpJB+0bZdsXazIHCsS58NFAB4Zsyk/UZuj8cBmACoJgYsOLnzj8+Ci+CozFovo8m/3PKtZF+RPrXuU90dpJWb2BWcemy3cxk6nJcDNtKRcOwlbba2S7TjmDz43VgUwU2YtCIuWrRTbA22HCVaLCFqklZmFaykm3q9Wr947pRSU+9x7535qWeEwS0OYWj9IgOv0yGOE6Pg4MBncd/9dBgoG4v42xrZxwXXiawYJ/d9JB5Y5YbwJ/4E+IEVeLnio7hFxUDJNqIswtf6cCP3M+UvC5Tf8J/z1jqc1+wKISXTu2Nn9pEN3DX/41kekcd7oyqETMzMLl63UHFvG3j/9Cr2EpwusppsSGkARXrD6BIcdrD+J4V9oDwTSpqhHg20yqCT9sQfsFNv9b8iQro991PMQ2a6vDbMXLgvjps4OI8bPFDCMeaX5ttJhsSOoORRO97J2FupZ4wFH7DFY+sxJB7o1CNv17ibcqMgrF7xaPVDRDGS+n0Wj8zq+FmYP/8v75dmQvFJ430FDw1Xf/XjWHHujGi8KCxqMWli0GxHyiPEzDANbuko9hN0PM2jGaPWu6TyL5CNKrVAa8+xzxHwzjXpOHQOUo9Rfrc/9889FYy9YslJAMt3yIYRSh73fsAHZIXXt5uq+orNBZIV5xF46+wMHxJQZudqu4RunH1Wf9/2mn5WzUxp+zsXGboaFADcjoPjuH+6SoBDRuqlE8DAlJO+v+OZp6vHmRUDGuMhde/wwBPjxkZPC069MCmOnzBEwjf+osUq+E/FGsj4k0TdzrhrfiQb0fn0yvZ4MjQA2Ggenno7zVHs1hJnNNaxNQAL6kfYeNz3LK9MhAAo8fmuKVPjf/TP8WbORA5hanFjUIZXejcVOacg5F5uSfjvNJTnuj4+cEL79uztUTJ7OE1PzxebNlUL7+ZdOMoGMzWecSAAAb12eXpb/xuZQyGzAvNGbsp4hwZoaqqddbNvF/dlwPpt/5igPr6P/HBEsGKNfaBtSagx/gctnRTa2pQ0S1ZLyqq3TASJfi3+KzwfUA8gOvuh5ZjfNfhyl1Rm9ivvS1AOh/2/vTMCtKss9/m3owGGep8M8HEBmBJkcQTMRAYeywSENsyzv9Xa9laam5ZxN6tVrmaXp1dTqMX3UTO2SFqioaTiAIGgKoiDTYdZz9n1+/+971/r2PgfhwGHtnseziucgZ6+11/rW+73j//2/zuVOP2aSNB4HeLzzv3jkLrVUQ36g1AlkXhQQ7YfmveTO++/7lQw1ildj/Wzbstz9x+enuW+fcoTPUQXQAAEAJpVmZ+ZlLFy6QhO9LY9lfhsvA1/OC5+vYKBV0Vr8hJQRT0+/d8YoX60G+2+deoQbU9lLw5s55C818eTX0rZ1wLga9v145BL3jAvx3rqNjoF6pJ8AM4wb2lvCr1lmCejJ+5tJK6TLK/pm7IA2IZEvGk9U8S7nBvXuIvajLI+SCp5gUX56zZ2PLHAX/exBLS7m1ISHnYpmYXLP8YeNKdB4BAm3Pvi0yllg1TSuIFDbWuMO/0ZFwo+fKtO1yMNpnmvPLoKRM0cNtIo0Bzmvau+Qwz931ddnq/ZJDdkS3nGrZZK+CaqyoTWeXZ/7QYszeot8K+sGCACNDHDVYxYLe0ji1JKArUH7yS2YM2ty8ntGImUd1ZZU8JSm8H2j198zVxUEDvH5BiAegoPzfut3T9FIJ0OqsMPpvPr6NXeLuNDMpTZxmOrIYkPNhWbAXO4/pLfmQuzXr7v6FdB2lJ+4BuSPqgXnvG+HVqMScMVZs9zoygr38/vmJZFurBjiukBDC10SLIR67ep1VR7O9dZq3SuaDjpanlHplMAWZWtUcJ8RK5dAAqcePVGmFhVIeHxRyFVlpfVKKXg8Iy/5jZVrBQ6AhgzzaKkUY4A6cuJQRbTAvm2XognhBr727rmiaKDYT8SKmeZF0DhF4hnhGTagh+rg0NZS/cDEst58NxNwTr/0duXImpeVSSvYxCBcH8aRIvC33D8/JWwJL8eCHKNaK3boG+IdWjqEkZ8QNMKURbUFjj6CmlGDerq2rcsF9DTBL46wC5vXQwL5jNlTNDaUk6DXP+9j5OPJp6rJqwQE2+ff/rFMO5kDwUAAMCMw4V999rFeIEPKBE0D2/m76zYqzUALIH4Pv+cc6qnQwiJk/HczDSXxZTiL8Oi+J4r+6lW/kalOhr2ENPTg3l294A3o7n5x3zwJqjHUmzbal8njWOMR+aPt+PPPVevUlQg5+dghveRCaGp5aBEoZiXV7JDQuG/Ree7UGRPzuTCHlB3GYOAsj9qVi+zaGy0xCn/bV668KzF3tjgEDzSyfOe0I92Zxx6UdMenmoWcGsFCymOsKLaJn/AtDeD/X+dB1Eu/6leu+o178533PfdyVPQHBX3V2bM1T8L48faFOd3Z+/ZBheeQobq1bOUagRVWrF6vDQZ3Cu5Dm1YpFa2xv+vRQ/rIgBIWlMnH++KMiYG0xwkFcd6p2US1cc6HG7R0yhmX35kphQU4vEefWuS+9oO73dqqzYIixQ71kL7ewQcoYD6MtF6IznZnGPLOXiyC98TzSyR4oHvB4sWCR7B3dSR4WSoEi6ARODYOaSWSx6BTqCsjRCSwxw3tK9ygtF0ExYmRKcif5TETM3zK9AkeCp9zDtV+4ZeOyuT5TPBiHy9r7hTWaf2mLRq08l/X3edqQgSq5QgdPlNG9XfX/PtxbngYCZqkU9BMMh8G565/Do2X+udnF7uvXv0bB/liscYrteD5qg6F/Zxykq8y3adqiyowuB2UD5lnBj9eSkWb6vc0pZKGQPbec6fNmKTMFBJJ5HXJl4/ORPDsS2qZ2gwpLFgOIrUbfvuku/LWR7SQNr2xpqZagjDrkFHu0q8cozSI8nof+mI/Oxofx/uD/mnqawYJRsD+oW1hVPcQe+Phc0pvlVLj2b3wjqq2blf7KRuEQKNd63L5w0zt8aTaPscoYUsqMeQAyeF5ZIoBjgUP+8KnxqvnAp+EvFHWwYUJXimYBPhuEsDfu/kh96sHnkoweGb6oZo9Y9YUQcX8jDA/ffrm+/6qRbx4zgzl3ljIPaGiJWf4p6dfdWdfc497b22VIsW4MehfRfAQKDYoFkkVmR0funatyiVoRLWkixSgBlCDR9f4CT8cKDWqLaY99c5PPXpC3juEwKK6u/NPy8bHK9Z4qGp2VJY+HguAb8UYAY0QVY3WQ5pYPISNEiKQqPLmzQSXYuwTKRSiuf/55meD4NVf2/H80Fg8PO8Vd/YP73Fr1m9WBPyvJnjcJ0JFNEvZDgVFdYYcHgepnnatmYJkzYveTbEgw0AP+PCGTpbGO3n6Ack5aDzDnGVlb0vr4+WFqj3ziruUSrF5H6wcCwW69oLTj3QnT5+gBafZhRbIxxYsdmfMPtBd9KVPFVBNxOa2OJdV/N9e8D5wD8172X3t6rtVNlN7Y/C3+QEIoNSmFs1FrzEgUDQegQQBWZsW5dp0Q/t2Ffl22lGWlsriurPKZElnvHO5zx6xf56SDgc9F98/85isZE7fUyuBnKGPx3eD/SOB+9LSlaHPwgda/K5Ptw7uojlHuROmjZV5QSvSlINvdsmXZ7jTj5lUsNNZWZgJ4lkVtpjF+TYEkX5VQAVnXnmX+hZIIGtNvNrTyM1SCp6lUxC811e875595U25A+TsSDORdxxQ0Um+aTIqKliM1FdN+y58NsDnSHNEtRRvyddAN4AjneVRysoF5p3KwSmX3Ca4u4dDebwbJoI+48vPmikyHca3n3/j/e6+uS+6ARWd3U/+8wRh4fwCx4Q1CI6Nj/eE3ObzxFT9fAcaDxoLBvRhyuz7ZZ5q8mIzKKXgecVQI9MKPfBLr69UMhxB692tvfxcqjOi6g2wHUs1+UEs6XA9KwMa+DP3uU+OEyaCE4f26eYuzjiqLaXgsZOZ33DyxbeJ3cgqBywSu5JWgCu+NktjL+HxPe37tyuByuC4G799omquJnhmVlJ4kt++5vqYmbESlEZrVtcogUzlgnovtA8GLuCdoAhKKXg+nVKjlsZnF/3TLV+51nVq21LuBeVV1ojNicX0ebrUt7PNGyeRzd9TcIHgsdB8gIXMOp1SSsFD42A+v3TpHYq4KEep+yugTJiGzYA80kzA4mEawBzOPHiE++E5x6m1L23lK7QTscBZzwQvJm0I96kZ+IG/fMVdSs5aZ5sZp1L7eAgIZcD3N2wSBg8+Pxp8ypo2cT268Ox5yQxBg4EE4nKZ91dzrmnOeaaoUMUR3P+EaWOUTiGqpWR22Vc/PqaW1ADJY6JKNl7STB3qqQeOGiAmASLOc6/9vXv7vXVKHcyZNUW9tm1bNa/TK+GF4ICTm1v1fpXquGD+ECwK6/hH/AS5y/RrNB40r0ZfYSmmUgse2hfAJ8+hQcnrqtRkRLRPpxtrRibE0Dx230Heotptqv0ROsH4Tzxif29281ASdNWI8yyPUmq8dRu3iLeEWbTsYusLZWei/ZhMyODoex9/3t3+8AI51TREKeCYOiaMQ/e8eSw6u57Agx6LR59epJ6LNRs2KeGM5uAFUdfk5cFFiJkCXnX7QwtU/zSeQsO2ldrUYmbRdqvXb9asCtg9WzYvc53btxGsiwgXaxA3raey452OtMzotLG3iAl1ncvNPmRkvmV5c9nywX3QeDOzlLvku4xJ4IwMOZDRZNffO9dd/qtHJBSkMwwggKN/0OgBama57cGnBAfi35jHRQAG5NsW1swnhDX0yZLnQ5MhbBrpbr2mUfcVJomJ3vxE8yY9FdHqE9VecdZMN3JQT3fzfX6IXn2rI3vyMu17EBJaMNHWLyx527FRCSxo8oG2uE2rcqWcSKtQzUgGqwRfLw0w/GxdnpPrsU65zx05Pq+x43lAAt0yFbx4Ia1ykWUCmf4BqCN+fNefJXRpgtODQeFLoTRE2yWwICK6GQeOcN870w9Gjg9xryxZIZPM7C5DCRdDUyjJoVHVDohGCMJoMPEkDZFzrrJXV3fZWTMFBGWyT3Gz0J4I1c7OsXehfGPIY+IiMDgPTY4WpwcYS0DaDd4Uwb9Co5F4ZYLq843p1j3nm0jgzaPxietDd5E7fuqYvJV1B/dlh81qyOf5yGvFSVUJHpN9LssOnYLvcvmtj7ibfvektFkBf1sul/TQ4uewmDTYMFb1nM8dJqJswbkD9AfUBlqJFklzH/QynSc2tzwXWtKb1NB7kECHPFFP3DSDcF84Z7qbPKKfgKCW6tmXWi8uYa6r2ioXAN4U+oLRWFRXgO1jYlmz1i2ahQ2LNvY9wsH1sfIoAAAUn0lEQVSj0+ZCa+LnIrQd2/gGJdYud9xho5ORUlQuAB6W4qBh+HmNlLpTvlEWDd3UaS+75WGRHiqVEpZN2seK9aEShGbC2QehPfuQkUmXv9Vo4Sm+5o7HBZ9HeyrHRcPziP5SBGhXJlyDdkaQ+b2P8grReobhI3/WukW5cojTDhisVkkDqe5rk8v1QaMQxeKfYmJpaSTh3aZlMze8f4Xr3b29fGLek69Xe5oNzLKqHR9Wu81btsvnJW1E8zlcyWqIZ499etpYDVjhoOcC7FnWh+WLeDgELyvSHjTe1b9+1F1/71+EsBAyJeqOUj9Z6NpH8CBIpOmH5pviSgSVBxpyLrjxAe8vljV108YPFnIZ4dqweauG0cE9QpfW/H8sE39KAhgtWnQDTRKMEIjgzI8c2DPJNTb0O4qtDxqbujTIbP4OFIoOOtaCgAJgAEEWG5TIvLqm2r27dpNGwG/b/qHm+JIpwRQDJgChzPqaptaGO/bQ0Xn70so+Xdw1/3ZcQz/TTq8X+xVJcJGh4CEI193zF3fFrx4Jjj4E2B5pYiORrMURYTptxiTl9VjsxKAEh5+d/vu5LwjJbH2x/Sto1pnpZhw0whMzhmoFGv2yX/5RozitPmsVDlsT6/32bQk0DZW5oX27u8mj+vt5GA14FGtQFBFomZeWrXItmn9CZNr0DfM5Agqm9BBcQK2x7YMPpI25T8YPkCZCyDy/H9QoaWRr66oeEUwtz8A/ltTHw9Qu9tMbsxoNj5b6xR/mue/c+IAXPECdVC34GVofWRteBLk3HP1Tp09IeiZirYdmgNrhlO/d5t58Z51SB2iEqeMq3Y/OOc4N6NlFfh4VCiBYtz34tOqzaIadgeNN63EeASAahIQtnWrMj9gXvh7ChRAhaGg8Ilj+TjMSMtKlQ2t36NhKaV5cFcAD0JVxX0DhUyCA0e+G2nPoF/bBk/OCZ618nJy1j5c6s0bMmB0/HmWyO//0rDvnx7+TAKTBBcsTao0BFkSi9JYLT1KPQV0Hgkq995KbH3J3/PEZ17QJRNp55ewg0eYPMKi7Hn3O3f/EQvXTWm9GWv2gqdtXkSzIEMSoukY+E39v0byZkrYwcZLI1Z2G+nJDCCLvQ7XZpSvlCkC+SGCBj0pTNv8NUQ//zR/8NnxZWYEArtgdHzQ3+5BRYn2HzYeeC2qTWR52k6Xgx8M8Pjz/ZcGitu7YITyepwoz8+A7zegQm3XISHftuZ/Rrt7ZQX7qgb8udN+6/g8CThJAeCRHK/nPaBKGQG/a6hHMRgwkLQAKvNoPeRGIksbwJjDOe6QMiWZL1HZs19IdNGqgOKu9xtx7pqjkPWgw8mZhIwkaMOvzFy4XfQUbE8HrV9FJAVK3jm1d905tEuS09V0YpjHGFnrf2VO/KaSaPnl4vqyMXtKm6gH9wdnHZil3JSVmxHyxqCddfKsWO9V66YvEzCJsILMZt4VA7EyzIKTMeDj/hj+4/3t2iVIPSiCzsZuK/ylJq/i8V8oSxbndOrVRDwMpDPJmaD2bIVGId2O4SXc3bXylNE0cGOzty2Nz4A7gNlR0buuaNm2qFkw1/cDL17qFonvSKfhzfgN5wINtgsRaJHk9n0LiM9YGmTv20FF5+jv5Bzrms45qS6XxDGsGyeBpl97h/r4Y6i0/0WdH9Yee6wOKMJd3w/r1cL+86CRFc2bail8w10NjEbCQnqF6sW3bBz5YCe2OfIZ1JnAxRC4/EarO7Vq5M4490J14+FiVpm747RMCiapXNxA1+gAErZiTjzd9ynBpoFp9rHsofbwLUibAn2A4gMSJ6Jb2T35HgNW3RyexBzAy1Fo5IX1C6BBa25iFUb9HMeMXWuI8N+PAEQlpD3k8GJGyPEoleGbSaF4hwoTy39iazNczQaFaccsFJ+0ylaGoddsO9/iC1wQYhXmKaxljpkGnvPR6skNeHoNt6O34/JHjXZcOrWTu6WG9/8mF7t7H/q6pP+TD+KwX3BrXumW5ZnBgbhuqosG7IG8HGhvGK3w5ENp0lyFIJIxJHo8b0lvjQ02bGxYvfj4fGPkHTQkmvSshU3zMQSMUXLDIw/p1cz885/gs5a6kppbnhtv3zwsWuytve1S5Kpx7MRoF8mvyUEw7YgyDpUl2Zmq5niLXd9ZKYxFkMHjEM8inh2HuiAbRKowTmHXwSNeJSDW0VqIZ4HSZt3CZmKIeeepVh2X6BNzI1TUKLACighlsKFPLvb+zeoOb+/wSBTPUo5l0ZPzMaDyUE51l0HkYBs+zK3gwl18jn4A3ao3wq2QBpGwkeNCFVufdyEEVSpBmeRRrvI+a3tjQ98UiYbpIkDJHghf8txdfV2nIECpE+ld9fZY7/IAhu0xfmDONdsLX+/WDz7i7H3tOWiQ2h/htUJDByQLTqE049GmVlBmee2Os6FML31ApbunbzJkgGm4iyPnU8YNVpG8owaNaAS7wry8uk/9GtPqX55cKKa1cYvMyL3hDevvNpBIflL2eJasYgxgHPZaY970XOZc7atJ+um/gy9Bn/eQbJzT0+/3I69UWvOwoLGRua/LibSMiJVf15N+XasevWrtRtdmZB490n546Rjg8a1j5qLSFrkf6g5LT6vVCGN/+8DOeWJEhNj06usPGDXZTxw1WBaR757bKz9VFc+ERK3lxlfz07rli5ARmxQEIFWJEYSkj2os9SamYv4uAEVS8+NrbMqkEFk8tXO5LezmqFM3dfv176L6pzJig+aqiT7ybVWANrJwYN/2YMOSmTxkukAAXBuP/cRK8tHLiF47FQgCZYoPWwjcjosWsYRXsJde1kzzKONr5oV6JU019Fl8J6QKD161DG1UAwLYZCWStYCU0SOPT4Ycy/aea8QekXPJO7JpG3O3vPgUs7InmQMjB3j3+7Gtu9foqN2ZQL20c7tsY8LlniBjxK70t9UdxtcU0sCXGvWZLARDy8z41aT+ZWnJGwwZ0L3lwMSdDPJ6vTnjGS1bGGpD9wtXWQTFncV0v15fFUupZ0xQItDU8C94eDU+xZHHdwuzvbOWajUK+FL/ouOS4N0lkrsOmgaPv0WcWqepCrg4+ZxLdfnPkZH6pmmBu/QyOFOyZaLKgHQtyeAlNrl9nuROfnDBU6BR2FgnkH5U4uMhU8NTMko53MnMhExwcZovOYhO2M3PGC4zRuHGUFzf7WCnMEq0701CGRKbByMaGmoZJX3Sa7N4TTWfnkDYBvACmkNFV5CAJkix/SHROu+fEEf2EwbNntQS3Cb7XvFZNSTWiRC5KzOeOmDDU91xgant1cdee++m9uf96n1sqH8/8Gr384Ov5aCxNA8Qmwr/wuFmn9qP6lxGjhNMCebzwdi2jMPuoKJnPwstsg5LjQKKhggrMN0njh+e/IheDHOGWbR/In7TUEq0Bg/t2c/ShFIBWyU1qLkfhesSTjLQusQ1BMI+aPCwPBRVR7ejKnh9LH880XBL+R36LsVnyGdIHaXa+tuBZzZvfeEe7sO/An+FJtn2UG4y8QVGKLum1Z17oEKho48NvCo/u3ZOAIr4W2u6JF5ZK2xG5QjYOs4FVK8grkvphGjfJY8lLiE5FYFlDAcJDytI+C++qKBcK2VEg7fHIbOdyh48fogQyUe2QPt3cdSXWeFnPMit4mXV4dvFOtXddb7VeLFA7xaPUfWUg4xAz7u2ROv0p1wsaDvzj3OdeE/gTFlPEBdSxz2V6QQETeNi4StWdqaZ4ze5/Z6Ovis2u1/IpU71x5MmNwcezGxo+sIf76TdKa2pLKXh7+2L31fkNLXjmaxFQrFi9wT35wlKlbAB5tmzeTNouZnYiWYymI1md+HJB8EyLx/4sPh59tElFJQioBW8S6MMPGJLg8cjdXHfuZ/bV+tV53VKWzDJ90L34soYSPO9bWtbNiSFgwStvKtmN1iIZvW7TFlVbrDyHoPWv6OjG79dXQUWMQPGPlCa8TcPx00xx8tipZ+EdjqnjBiuqpRTD3IUbv/3ZvVii+p/aKHi7XrOGFjzeNyb21eWrVKUAJ9iFCkhN3q3eUJVOkcznXfvWLYRBZPq2Hxjjx1tZlG5VC/l2URLZi6QvpfEZUjJxC2fusP0rvcZrklMe7/pzT9z1SjTgJxoFb9eL2VCCZ9oOH4uk9GMLFrn31m4SQ315eZl7d21VGBvgfTfKYqMqKzRZKA6qkopFcHrjjGdqcr0mJGI2JlArm0njYWqVDc85FawbTe2uBSHrTzSk4CErmFjKgkveXu06tG7hOrRtpZRN1ebtztpjEZZBPTurgb1FebNCkGqIwi2atijdAg7+3UytpaQsaW7mOHeoabycZ32/4ZulNbVZggSyFqA9/b49Ebw4n5hUOCCD3LZDIFNMLE05tB0az0vso/Xo3M5NGTnAde3YxlUHAu7YR0zJh1JGrOT3oTwWA0IljAYMReMdMnZQ3qIPggtGY2Z1xItTCiaBrJ5zb7+nPoIXl9G8IPkXbklyNBv1WAAHABZoR+T6gBrMFINqpjQGaMTOTassaVLcX9tXKmINZzhG03ze9/P+YZJMnjZ+cJ68DB9izn2Webxagpcxk8DeCkRW59dH8OyeVIe2Kd/BNNJw/eKSt93TL78pBDFg0uUr1qjb3yJRmokg/R41qEKBhccA+r4OO9SAFJAztEwYLs9rPP+pYtCrRjloBm+ARR08ZqCnos3nhTq48VvZmdpGwds90a2v4MVaz/wwcnY0sP/txWUaJNO7awcxBQCzNxNJfbZXtw5qVaStMWbySoWusPICWY+HSBXWqYufjECFhqUEOjV1XGXexo/DZJ5lcNEoePtG8OKrmmmjow5M4OJ/viuo14aqreqL9T0c0LJBudFJrZPC+dGMHajXbNxnUvlIAHYe2B5XRKyOawhkO7dac3Yt6HAud/DYgXmDLTMzK8vgolHw9p3gxQEF/huQJ1oWPazfj1mwvBpCR0lsxKAK16uL5zZO/LFQNpOIhQqECZrXpmlRMY5qY5ROComXEfam+MDRAxRc8D9KZo2mdveEIctP1dfUxveGNaPxGlQxWg9Hn2HHm7Ztl8OP7BDB0l9B5cI0VsJnHPzDWEkkfmQAKBQKYtqyaWa+8Nzg4xHV2omo2ZvO+3yWa5rAiIhqX3hthZtz+f+q6TkLtqhMH3Qvvqw+ghebPTQa0CbWk5QJQ/roXqOH2AtYjbrFxg/to1SaBRjWS2Ek5OYD+qAhHUATC1YKlUpLaAa6ictn3J8i5ckj+idoqZEDe7iff+cLe7FE9T81dmCzHqJX/7stzRn1ETy7Q3w3+O2IYtdu2CK/bcWaDe71Fau9dczn03ZF5pE1DzM2whRG3kvZJwKHTB0wdwljAH1aVFsrcRyiW8vnGfxdgjhl1IC0ZNa/h/vZednl8WyHGdN6o+DVLdj1ETyLLsHYwUqFL9ezczvB3jy54jZpO/o1mNZJD4XYCAI20Hwz6zGxuqxhA024TPslEM86zG7iE4Yabqz5cpNH9pfgcUGYBH5+fmlMrXGnzMmQmLE0+qv+31pfwaNXGOowoljwc0Sr+HgIobRUk5zr2bm9ymFd2rdSl13cvJOYU9+jk/Kd2BwLG0ITjYiyRHMikEEQC/J5ETomN2mEFzy+maj25oxNrTmexo9HySwrYsb6i0BpzqiP4LGOsKo/+cLrMqf4bgQTi958z1NMMO60bQs3YVg/16+io9DBxbB1Lyw+WhXvS+iZTXtFfKXCKhRprTacV8B0GlDJgb7DIuncuKF98thyVO+wft3dzRdk6+MVCN6itxRcNApeoYDXR/AAADDkDw4X+A5pacCFIaWCJiNJTOM+75p2RbhSUDpCEQcJ5J1QkRBcPeTeRDIUsiceCuUjYoSOrjSuLxMe0jVJhSLAo7ie503xh3w8dgL0BAQXpYtqa+SDNApeba26O4KHXtm0ZZt7btFbAgEQTPCHsQdM1JZwNW0ixnV6JwAHELVa3CD/K5jh0OoR6rxeWOK8XEGeL4A+TSumAwRDfi+c7LWiL72p7XPiiH7K4/FnWIl9vOcWv+UaTe3uCV6cO+PvaK6Xl60U7RrMBDSOI7BvrVofNJNPncACMHxAhbjvEAMzs5YGtkqHCv+BJKgWmji6xVrgzwCJ9xB5a/b20uZrxyGBPHF4vxBcODe6spe7qYRRLeTbjT0X9Rc8CHbeenetr8Nu3OwG9uyqnonXV6xx23fAewLC3Gs7OgkhVKx9pD6dp54o1HL2+bQiklJWWGXCAov43DR5XNDg6HIHDOubgPApmfzywpMy9aDjPF6j4O1eOkVpuDD4EBMGUfb8l5a75SveF+oEzQa546YtO5ysW97JtJK16Nu9o+jWYtNZF6jANFkSDBRVMDjHmBWKWxoLymiKW6NEoMXIaDxungtxY7d+95RGwct0BXb9ZXX5eGnxf7tqsIz1hEqXOWMI4sYtW0MB37mW5WWiwiWogFoWJ58jZrCygS/200ezhXAoL0MpVW98jsmXuQApULQQRJDAtSYM75s33t8xg3tmnk5p1Hh7Lnj0wcLMDiMVzTvtW7dUTo6BKHagqHp2ae/G79dHwUYiIEWaKIaxG0cfHlnamO6vmGq6mJLCp1fM1PJTeb2QVqmrdJaaWoeP1yh4uxaD7D9Rl8aLuezw6xiyTHVi0+btCcScOwUCNX5YH00VL/bb6kKbxBorYUYIJENesLzljDVfLHBxrdh6cItRLPo8Pp5pHZp9oNTP8rAHSCoXJJDfWCUaBTrXf3zO8WIuih/IHrT4IbO87yy+y9amWPDImb2xaq2b/4/lwtQxNxfNBKGkDWRBOKBXw7zCEMGAFibwpP5YIbGO+iFEEm6gTo+H8pFo6rVZW2Ma/Raa0phOI0alFPuRuf2H9NaViXqG94dk+uQs1rRgx1gbHMEFbFGvvPGORhExQYZJ2LCM+xqkj4y0k0LPZm2fIpPbz+RL7NmgKYMtisQt6QgG0sFc+uob76q436ldSyFO6I9liRAiWAFQJHT/w8NnE7QlGKRKIig63yOB43+hIiH/LbQmmmLiu4mWuUYK+EwnIaXRcKhWRP6gRouGFgsW7/8B53wTI/f8WD8AAAAASUVORK5CYII='; \ No newline at end of file diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..478f571 --- /dev/null +++ b/server/main.py @@ -0,0 +1,124 @@ +from flask import Flask, request, jsonify +from flask_cors import CORS +from functools import wraps +from flask import request, jsonify +import firebase_admin +from firebase_admin import auth, credentials +from requests import exceptions +from dotenv import load_dotenv +from services import UsersService +from services import OCRService +from services import TranslationService +import os + +load_dotenv() +hard_limit = 1000 + +# Initialize Firebase Admin SDK with your service account credentials +cred = credentials.Certificate('firebaseServiceAccountKey.json') +firebase_admin.initialize_app(cred) + +# Decorator function to authenticate API requests +def authenticate(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Get the authorization header from the request + auth_header = request.headers.get('Authorization') + + if not auth_header: + return jsonify({'error': 'Unauthorized'}), 401 + + try: + # Extract the ID token from the authorization header + id_token = auth_header.split(' ')[1] + + # Verify the ID token using Firebase Authentication + decoded_token = auth.verify_id_token(id_token, check_revoked=True) + + # Add the user's UID to the request context + kwargs['user_id'] = decoded_token['uid'] + + return func(*args, **kwargs) + except (auth.InvalidIdTokenError, IndexError, ValueError, exceptions.RequestException) as e: + print(e) + return jsonify({'error': 'Token might have expired. Please sign in again.'}), 401 + + return wrapper + +# Replace with your actual keys + +app = Flask(__name__) +CORS(app) +users_service = UsersService(hard_limit=hard_limit) +ocr_service = OCRService() +translation_serivce = TranslationService(os.getenv("OPENAI_API_KEY"), os.getenv("DEEPL_API_KEY")) + +@app.route("/translate-text", methods=["POST"]) +@authenticate +def translate_text(user_id): + source_lang = request.args.get('source_lang') + target_lang = request.args.get('target_lang', 'English') # Added target_lang argument + request_count, limit = users_service.get_request_count(user_id) + if request_count > limit: + return jsonify({"error": f"You have exceeded your monthly request limit: {str(limit)}"}), 403 + users_service.increment_request_count(user_id) + text = request.json.get('text') + if source_lang not in ["Japanese", "Korean", "Chinese"]: + return jsonify({"error": "Unsupported language"}), 400 + api = request.args.get('api') + if api == "gpt": + try: + translation = translation_serivce.call_gpt(text, source_lang, target_lang) # Pass target_lang to the service + except ValueError as e: + return jsonify({"error": str(e)}), 400 + elif api == "deepl": + try: + translation = translation_serivce.call_deepl(text, source_lang, target_lang) # Pass target_lang to the service + except ValueError as e: + return jsonify({"error": str(e)}), 400 + else: + return jsonify({"error": "Invalid API"}), 400 + users_service.store_request_data(user_id, text, translation, "text", api) + return jsonify({"translation": translation}) + +@app.route("/translate-img", methods=["POST"]) +@authenticate +def translate_img(user_id): + source_lang = request.args.get('source_lang') + target_lang = request.args.get('target_lang', 'English') # Added target_lang argument + request_count, limit = users_service.get_request_count(user_id) + if request_count > limit: + return jsonify({"error": f"You have exceeded your monthly request limit: {str(limit)}"}), 403 + users_service.increment_request_count(user_id) + image_data_url = request.json.get('imageDataUrl') + print(source_lang) + if source_lang not in ["Japanese", "Korean", "Chinese"]: + return jsonify({"error": "Unsupported language"}), 400 + try: + text = ocr_service.annotate_image(image_data_url, source_lang) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + api = request.args.get('api') + if api == "gpt": + try: + translation = translation_serivce.call_gpt(text, source_lang, target_lang) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + elif api == "deepl": + try: + translation = translation_serivce.call_deepl(text, source_lang, target_lang) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + else: + return jsonify({"error": "Invalid API"}), 400 + users_service.store_request_data(user_id, text, translation, "image", api) + return jsonify({"translation": translation, "original": text}) + +@app.route("/get-user-limit", methods=["GET"]) +@authenticate +def get_user_limit(user_id): + request_count, limit = users_service.get_request_count(user_id) + return jsonify({"request_count": request_count, "limit": limit}) + +if __name__ == "__main__": + app.run(port=3000) \ No newline at end of file diff --git a/server/populate_vectorstore.py b/server/populate_vectorstore.py new file mode 100644 index 0000000..17fdccd --- /dev/null +++ b/server/populate_vectorstore.py @@ -0,0 +1,62 @@ +from llama_index import VectorStoreIndex, download_loader, StorageContext +from llama_index.vector_stores import PineconeVectorStore + +"""Simple reader that reads wikipedia.""" +from typing import Any, List + +from llama_index.readers.base import BaseReader +from llama_index.schema import Document + +from dotenv import load_dotenv +import os +import openai +import pinecone + +load_dotenv() +openai.api_key = os.environ["OPENAI_API_KEY"] + +class JaWikipediaReader(BaseReader): + """Wikipedia reader. + + Reads a page. + + """ + + def __init__(self) -> None: + """Initialize with parameters.""" + try: + import wikipedia # noqa: F401 + except ImportError: + raise ImportError( + "`wikipedia` package not found, please run `pip install wikipedia`" + ) + + def load_data(self, pages: List[str], **load_kwargs: Any) -> List[Document]: + """Load data from the input directory. + + Args: + pages (List[str]): List of pages to read. + + """ + import wikipedia + wikipedia.set_lang("ja") + results = [] + for page in pages: + page_content = wikipedia.page(page, **load_kwargs).content + results.append(Document(text=page_content)) + return results + +WikipediaReader = download_loader("WikipediaReader") +loader = JaWikipediaReader() +documents = loader.load_data(pages=['ONE_PIECE', 'ONE_PIECEの登場人物一覧', 'ONE_PIECEの用語一覧', 'ONE_PIECEの地理']) + +# init pinecone +pinecone.init(api_key=os.environ["OPENAI_API_KEY"], environment="asia-southeast1-gcp-free") +# pinecone.create_index("manga-reader", dimension=1536, metric="cosine", pod_type="p1") + +# construct vector store and customize storage context +storage_context = StorageContext.from_defaults( + vector_store = PineconeVectorStore(pinecone.Index("manga-reader")) +) +index = VectorStoreIndex.from_documents(documents, storage_context=storage_context) + diff --git a/server/query_test.py b/server/query_test.py new file mode 100644 index 0000000..06e70b3 --- /dev/null +++ b/server/query_test.py @@ -0,0 +1,140 @@ + +import pinecone +from llama_index import VectorStoreIndex +from llama_index.vector_stores import PineconeVectorStore +from dotenv import load_dotenv +import os +import openai +import pinecone +from llama_index.query_engine import RetrieverQueryEngine +from main import call_gpt +load_dotenv() +openai.api_key = os.environ["OPENAI_API_KEY"] +pinecone.init(api_key=os.environ["PINECONE_KEY"], environment="asia-southeast1-gcp-free") + +# vector_store = PineconeVectorStore(pinecone.Index("manga-reader")) +# index = VectorStoreIndex.from_vector_store(vector_store=vector_store) +# retriever = index.as_retriever(similarity_top_k=5, search_type="mmr") +# documents = retriever.retrieve("茶ひげ海賊団") +# # print([doc.node.text for doc in documents]) +# stuff_docs = [doc.node.text + "\n" for doc in documents] +# print(stuff_docs) +# query_engine = RetrieverQueryEngine.from_args(retriever=index.as_retriever(similarity_top_k=0), response_mode="compact") + +# messages=[ +# {"role": "user", "content": f""" +# You are a robotic translator who has mastered all languages. You provide the translation and breakdown +# of the phrase or a word directly without trying to engage in a conversation. When given a phrase or word to be +# translated, you first provide the orignal phrase or word to be translated, followed by the direc translation in English +# and only the direct translation, +# followed by the breakdown of the phrase into compound words or loan words if necessary and explain their definitions. + +# Use the attached context to first check if the phrase contains a name or a place from a fictional universe. If so, point +# out that the phrase to be translated has a name or a place. +# context: +# {stuff_docs} + +# To be translated: 茶ひげーお前ら捕まっちつかまうのかまえ +# Translation: +# """} +# ] + +# print(call_gpt(messages)["content"]) + + + +# ####### check if a sentence contains a name or place ####### + + +# messages=[ +# {"role": "user", "content": f""" +# You are a robotic translator who has mastered all languages. You provide the translation and breakdown +# of the phrase or a word directly without trying to engage in a conversation. Sometimes, sentences or phrases to be +# translated might contains a name or a place from a fictional universe. You first check if the phrase might +# contain a name or a place from a fictional universe. Say yes or no. + +# For example, "茶ひげ" in "茶ひげーお前ら捕まっちつかまうのかまえ" could be a name. + +# To be translated: "好きな食べ物はアーモンド" +# Yes or no:"""} +# ] +# print(call_gpt(messages)["content"]) + + + +####### check if a phrase is broken up or contains extra characters ####### +# Always returns valid currently + +# messages=[ +# {"role": "user", "content": f""" +# You are a robotic translator who has mastered all languages. You provide the translation and breakdown +# of the phrase or a word directly without trying to engage in a conversation. Sometimes, sentences or phrases +# that users provide might have omitted trailing or preceding characters. You first check if the phrase might +# be problematic. Say "incomplete", "extra", or "valid". + +# For example, in the case where "茶ひげーお前ら捕まっちつかまうのかまえ" is to be translated +# user might have only provided "茶ひげーお前ら捕まっちつかまうのか" due to a mistake. Say "incomplete" in this case. + +# in the case where "研究所までの運搬役" is to be translated +# user might have only provided 研究所までの運搬役にさ" due to a mistake. Say "extra" in this case. + +# To be translated: 僻地に存 +# validity check:"""} +# ] +# print(call_gpt(messages)["content"]) + +###### check if a phrase is broken up or contains extra characters ####### + +messages=[ + {"role": "user", "content": f""" +You are a robotic translator who is great at translating Japanese to any other languages. Sometimes, users +copy a Japnaese phrase from an image using OCR, but the Japanese text might have furigana annotations. +Your task is to identify furigana and remove it from the text provided by the human. + +For example, in the case where "スモーカー先生!!!" is to be translated +user might have only provided "スモーカー先生!!!せんせいえ". Say "スモーカー先生!!!" in this case. + +To be translated: 僻地に存 +validity check:"""} + ] +print(call_gpt(messages)["content"]) + + +############# langchain stuff ################ + +# from langchain.vectorstores import Pinecone +# from langchain.embeddings.openai import OpenAIEmbeddings +# from langchain.chat_models import ChatOpenAI +# from langchain.chains import RetrievalQA +# from langchain.prompts import PromptTemplate +# embeddings = OpenAIEmbeddings() + +# docsearch = Pinecone.from_existing_index("manga-reader", embedding=embeddings) +# retriever = docsearch.as_retriever(search_type="similarity", k=5) # or mmr +# can also use docsearch.similarity_search_with_score() to directly get the documents +# retriever.search_kwargs["k"] = 5 +# print(docsearch.similarity_search_with_score("茶ひげーお前ら捕まっちつかまうのかまえ")) + +# template = """ +# You are a robotic translator who has mastered all languages. You provide the translation and breakdown +# of the phrase or a word directly without trying to engage in a conversation. When given a phrase or word to be +# translated, you first provide the orignal phrase or word to be translated, followed by the direc translation in English +# and only the direct translation, +# followed by the breakdown of the phrase into compound words or loan words if necessary and explain their definitions. + +# You may use the attached context, in case the phrase contains a name or a place from a fictional universe. +# context: +# {context} + +# To be translated: {question} +# Translation:""" +# PROMPT = PromptTemplate(input_variables=["context", "question"], template=template) +# model = ChatOpenAI(model="gpt-3.5-turbo") +# qa = RetrievalQA.from_chain_type( +# llm=model, chain_type="stuff", +# retriever=retriever, +# chain_type_kwargs={"prompt": PROMPT}, +# verbose=True) + +# output = qa({"query": "茶ひげーお前ら捕まっちつかまうのかまえ"}) +# print(output) \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..d5c724f --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,94 @@ +aiohttp==3.8.4 +aiosignal==1.3.1 +async-timeout==4.0.2 +attrs==23.1.0 +blinker==1.6.2 +cachecontrol==0.13.1 +cachetools==5.3.1 +certifi==2023.5.7 +cffi==1.15.1 +charset-normalizer==3.2.0 +click==8.1.4 +cmake==3.26.4 +cryptography==41.0.1 +filelock==3.12.2 +fire==0.5.0 +firebase-admin==6.2.0 +Flask==2.3.2 +Flask-Cors==4.0.0 +frozenlist==1.3.3 +fsspec==2023.6.0 +fugashi==1.2.1 +google-api-core==2.11.1 +google-api-python-client==2.92.0 +google-auth==2.21.0 +google-auth-httplib2==0.1.0 +google-cloud-core==2.3.3 +google-cloud-firestore==2.11.1 +google-cloud-storage==2.10.0 +google-cloud-vision==3.4.4 +google-crc32c==1.5.0 +google-resumable-media==2.5.0 +googleapis-common-protos==1.59.1 +grpcio==1.56.0 +grpcio-status==1.56.0 +gunicorn==20.1.0 +httplib2==0.22.0 +huggingface-hub==0.16.4 +idna==3.4 +importlib-metadata==6.8.0 +itsdangerous==2.1.2 +jaconv==0.3.4 +Jinja2==3.1.2 +lit==16.0.6 +loguru==0.7.0 +manga-ocr==0.1.10 +MarkupSafe==2.1.3 +mpmath==1.3.0 +msgpack==1.0.5 +multidict==6.0.4 +networkx==3.1 +numpy==1.25.1 +nvidia-cublas-cu11==11.10.3.66 +nvidia-cuda-cupti-cu11==11.7.101 +nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cudnn-cu11==8.5.0.96 +nvidia-cufft-cu11==10.9.0.58 +nvidia-curand-cu11==10.2.10.91 +nvidia-cusolver-cu11==11.4.0.1 +nvidia-cusparse-cu11==11.7.4.91 +nvidia-nccl-cu11==2.14.3 +nvidia-nvtx-cu11==11.7.91 +openai==0.27.8 +packaging==23.1 +Pillow==10.0.0 +proto-plus==1.22.3 +protobuf==4.23.4 +pyasn1==0.5.0 +pyasn1-modules==0.3.0 +pycparser==2.21 +PyJWT==2.7.0 +pyparsing==3.1.0 +pyperclip==1.8.2 +python-dotenv==1.0.0 +PyYAML==6.0 +regex==2023.6.3 +requests==2.31.0 +rsa==4.9 +safetensors==0.3.1 +six==1.16.0 +sympy==1.12 +termcolor==2.3.0 +tokenizers==0.13.3 +torch==2.0.1 +tqdm==4.65.0 +transformers==4.30.2 +triton==2.0.0 +typing-extensions==4.7.1 +unidic-lite==1.0.8 +uritemplate==4.1.1 +urllib3==1.26.16 +Werkzeug==2.3.6 +yarl==1.9.2 +zipp==3.16.0 diff --git a/server/services/__init__.py b/server/services/__init__.py new file mode 100644 index 0000000..cb7aa29 --- /dev/null +++ b/server/services/__init__.py @@ -0,0 +1,3 @@ +from .ocr_service import OCRService +from .translation_service import TranslationService +from .users_service import UsersService \ No newline at end of file diff --git a/server/services/ocr_service.py b/server/services/ocr_service.py new file mode 100644 index 0000000..e5c2a63 --- /dev/null +++ b/server/services/ocr_service.py @@ -0,0 +1,50 @@ +import io +import re +import base64 +from PIL import Image +from manga_ocr import MangaOcr +from google.cloud import vision + +class OCRService: + def __init__(self): + self.mocr = MangaOcr(pretrained_model_name_or_path='./model') + print("using mocr!!!!!!!!!!!") + + self.client = vision.ImageAnnotatorClient() + + def annotate_image(self, image_data_url, source_lang): + if source_lang == "Japanese": + return self.annotate_image_manga_ocr(image_data_url) + elif source_lang == "Korean" or source_lang == "Chinese": + return self.annotate_image_google_vision(image_data_url, source_lang) + else: + raise ValueError("Not Supported") + + def annotate_image_google_vision(self, image_data_url, source_lang): + image_content = base64.b64decode(image_data_url.split(',')[1]) + image = vision.Image(content=image_content) + + response = self.client.text_detection(image=image) + texts = response.text_annotations + text = texts[0].description if len(texts) > 0 else '' + + print(text) + if source_lang == "Japanese" or source_lang == "Chinese": + text = text.replace("\n", "") + else: + text = text.replace("\n", " ") + + return text + + def annotate_image_manga_ocr(self, image_data_url): + # Extract the base64-encoded image data from the URL + image_data = re.sub('^data:image/.+;base64,', '', image_data_url) + + # Decode the base64-encoded image data + decoded_image_data = io.BytesIO(base64.b64decode(image_data)) + + # Open the image using PIL + image = Image.open(decoded_image_data) + + text = self.mocr(image) + return text \ No newline at end of file diff --git a/server/services/translation_service.py b/server/services/translation_service.py new file mode 100644 index 0000000..daded3d --- /dev/null +++ b/server/services/translation_service.py @@ -0,0 +1,55 @@ +import openai +import requests # make sure you have this line +from flask import request, jsonify + +class TranslationService: + def __init__(self, openai_api_key, deepl_api_key): + openai.api_key = openai_api_key + self.deepl_api_key = deepl_api_key + self.lang_map = {"Japanese": "JA", "Korean": "KO", "Chinese": "ZH", "English": "EN"} # Added 'EN' mapping for English and 'ZH' mapping for Chinese + + def call_gpt(self, text, source_lang, target_lang): + prompt = f'translate the {source_lang} phrase or word "{text}" to {target_lang}.' # Updated to handle long format language names + messages=[ + {"role": "system", "content": """ + You are a robotic translator who has mastered all languages. You provide the translation and breakdown + of the phrase or a word directly without trying to engage in a conversation. When given a phrase or word to be + translated, you first provide the orignal phrase or word to be translated, followed by the direc translation in English + and only the direct translation, + followed by the breakdown of the phrase into compound words or loan words if necessary and explain their definitions."""}, + {"role": "user", "content": prompt} + ] + completion = openai.ChatCompletion.create( + model="gpt-3.5-turbo-0613", + messages=messages, + temperature=0.2, + ) + + print(completion.choices[0]['message']) + return completion.choices[0]['message']['content'] + + def call_deepl(self, text, source_lang, target_lang): + target_lang = self.lang_map[target_lang] # Updated to use language map + + url = "https://api-free.deepl.com/v2/translate" + + data = { + "text": text, + "source_lang": self.lang_map.get(source_lang), + "target_lang": target_lang + } + + headers = { + "Authorization": f"DeepL-Auth-Key {self.deepl_api_key}", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + } + + response = requests.post(url, data=data, headers=headers) + print(response) + json_resp = response.json() + + if "message" in json_resp: + return jsonify({"translation": f"DeepL message: {json_resp['message']}"}) + + print(json_resp['translations'][0]['text']) + return json_resp['translations'][0]['text'] \ No newline at end of file diff --git a/server/services/users_service.py b/server/services/users_service.py new file mode 100644 index 0000000..b90b6f3 --- /dev/null +++ b/server/services/users_service.py @@ -0,0 +1,65 @@ +import datetime +from google.cloud import firestore + +class UsersService: + + def __init__(self, hard_limit): + self.db = firestore.Client() + self.hard_limit = hard_limit + + # Function to increment request count for a user + def increment_request_count(self, user_id): + # Get current month and year + current_month = datetime.datetime.now().strftime("%Y-%m") + + # Define the document path for the user's request count + doc_path = f"users/{user_id}/monthlyCounts/{current_month}" + + # Increment the request count document + doc_ref = self.db.document(doc_path) + doc_ref.set({"count": firestore.Increment(1)}, merge=True) + + # Function to store request data in Firestore + def store_request_data(self, user_id, text, translation, type, api): + # Define the Firestore collection and document paths + collection_path = f"users/{user_id}/requests" + document_path = self.db.collection(collection_path).document() + + # Create a new document with autogenerated ID + request_doc_ref = self.db.document(document_path.path) + request_data = { + "text": text, + "type": type, + "api": api, + "response": translation + } + # Store the request data in the document + request_doc_ref.set(request_data) + + # Function to retrieve request count for a user + def get_request_count(self, user_id): + current_month = datetime.datetime.now().strftime("%Y-%m") + doc_path = f"users/{user_id}/monthlyCounts/{current_month}" + doc_ref = self.db.document(doc_path) + doc_snapshot = doc_ref.get() + + custom_limit = self.get_custom_limit(user_id) + limit = custom_limit if custom_limit is not None else self.hard_limit + + if doc_snapshot.exists: + request_count = doc_snapshot.get("count") + return request_count, limit + else: + return 0, limit + + def get_custom_limit(self, user_id): + current_month = datetime.datetime.now().strftime("%Y-%m") + doc_path = f"users/{user_id}/customLimit/{current_month}" + doc_ref = self.db.document(doc_path) + doc_snapshot = doc_ref.get() + + if doc_snapshot.exists: + custom_limit = doc_snapshot.get("limit") + return custom_limit + else: + return None \ No newline at end of file diff --git a/server/test_image.jpg b/server/test_image.jpg new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/server/test_image.jpg differ diff --git a/webpack.common.js b/webpack.common.js new file mode 100644 index 0000000..3cb8823 --- /dev/null +++ b/webpack.common.js @@ -0,0 +1,107 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +var webpack = require('webpack'); +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') +require('dotenv').config({ path: './.env' }); + +module.exports = { + // Note: + // Chrome MV3 no longer allowed remote hosted code + // Using module bundlers we can add the required code for your extension + // Any modular script should be added as entry point + entry: { + options: './options/options.js', + firebase_config: './options/firebase_config.js', + signin: './options/signin.js', + content: './content/content.js', + }, + plugins: [ + // adds jquery to all modules. May not be necessary but need + // to find a way to add it before adding content.js + new webpack.ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + }), + new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }), + new HtmlWebpackPlugin({ + template: path.join(__dirname, "options", "signin.html"), + filename: "signin.html", + chunks: ["signin"] // This is script from entry point + }), + // Note: you can add as many new HtmlWebpackPlugin objects + // filename: being the html filename + // chunks: being the script src + // if the script src is modular then add it as the entry point above + new HtmlWebpackPlugin({ + template: path.join(__dirname, "options", "options.html"), + filename: "options.html", + chunks: ["options"] // This is script from entry point + }), + + // Note: This is to copy any remaining files to bundler + new CopyWebpackPlugin({ + patterns: [ + { + from: 'manifest.json', + to: 'manifest.json', + transform(content) { + const replacedContent = content + .toString() + .replace('__EXTENSION_KEY__', process.env.EXTENSION_KEY) + .replace('__CLIENT_ID__', process.env.CLIENT_ID); + return Buffer.from(replacedContent); + }, + }, { from: './icons/**' }, + { from: './css/*' }, + { from: '.env'}, + { + from: './background/background.js', + to: 'background.js', + transform(content) { + const replacedContent = content + .toString() + .replace('__BACKEND_URL__', process.env.BACKEND_URL); + return Buffer.from(replacedContent); + }, + }, + ], + }), + + // The following two are required for dotenv + new NodePolyfillPlugin(), + new webpack.DefinePlugin({ + "process.env": JSON.stringify(process.env), + }), + ], + output: { + // chrome load uppacked extension looks for files under dist/* folder + filename: '[name].js', + path: path.resolve(__dirname, 'dist') + }, + module: { + rules: [ + { + test: /\.css$/i, + use: ['style-loader', 'css-loader'], + }, + { + test: /\.(gif|png|jpe?g|svg)$/i, + use: [ + { + loader: 'file-loader', + options: { + outputPath: 'images', + }, + }, + ], + }, + { + test: /\.js$/, + exclude: /node_modules/, + use: 'babel-loader' + }, + ], + }, +}; diff --git a/webpack.development.js b/webpack.development.js new file mode 100644 index 0000000..2ba7544 --- /dev/null +++ b/webpack.development.js @@ -0,0 +1,7 @@ +const { merge } = require('webpack-merge'); +const common = require('./webpack.common.js'); + +module.exports = merge(common, { + mode: 'development', + devtool: 'cheap-module-source-map', +}); \ No newline at end of file diff --git a/webpack.production.js b/webpack.production.js new file mode 100644 index 0000000..812b091 --- /dev/null +++ b/webpack.production.js @@ -0,0 +1,6 @@ +const { merge } = require('webpack-merge'); +const common = require('./webpack.common.js'); + +module.exports = merge(common, { + mode: 'production', +}); \ No newline at end of file