From bab4eaef37346b4f9e85763505746a0d4f7c244a Mon Sep 17 00:00:00 2001 From: jonniebigodes Date: Tue, 12 Feb 2019 15:50:30 +0000 Subject: [PATCH] Docs/client search with js search (#11505) * revised draft with the changes applied. Example rebased * Style edits Grammar / typo fixes and various small edits to align with tone of existing docs * linted example files * Run linting --- docs/docs/adding-search-with-js-search.md | 576 +++++++++++++++++- examples/using-js-search/README.md | 6 + examples/using-js-search/gatsby-node.js | 41 ++ examples/using-js-search/package.json | 24 + .../src/components/ClientSearch.js | 224 +++++++ .../src/components/SearchContainer.js | 224 +++++++ examples/using-js-search/src/pages/index.js | 15 + .../src/templates/ClientSearchTemplate.js | 20 + examples/using-js-search/static/favicon.ico | Bin 0 -> 2813 bytes 9 files changed, 1127 insertions(+), 3 deletions(-) create mode 100644 examples/using-js-search/README.md create mode 100644 examples/using-js-search/gatsby-node.js create mode 100644 examples/using-js-search/package.json create mode 100644 examples/using-js-search/src/components/ClientSearch.js create mode 100644 examples/using-js-search/src/components/SearchContainer.js create mode 100644 examples/using-js-search/src/pages/index.js create mode 100644 examples/using-js-search/src/templates/ClientSearchTemplate.js create mode 100644 examples/using-js-search/static/favicon.ico diff --git a/docs/docs/adding-search-with-js-search.md b/docs/docs/adding-search-with-js-search.md index eb4fa287fb65f..e733f59789631 100644 --- a/docs/docs/adding-search-with-js-search.md +++ b/docs/docs/adding-search-with-js-search.md @@ -2,7 +2,577 @@ title: Adding search with js-search --- -This is a stub. Help our community expand it. +## Prerequisites -Please use the [Gatsby Style Guide](/docs/gatsby-style-guide/) to ensure your -pull request gets accepted. +Before we go through the steps needed for adding client side search to your Gatsby website, you should be familiar with the basics of Gatsby. Check out the [tutorial](https://www.gatsbyjs.org/tutorial/) and brush up on the [documentation](https://www.gatsbyjs.org/docs/) if you need to. In addition, some knowledge of [ES6 syntax](https://medium.freecodecamp.org/write-less-do-more-with-javascript-es6-5fd4a8e50ee2) will be useful. + +## What is JS Search + +[JS Search](https://github.com/bvaughn/js-search) is a library created by Brian Vaughn, a member of the core team at Facebook. It provides an efficient way to search for data on the client with JavaScript and JSON objects. It also has extensive customization options, check out their docs for more details. + +The full code and documentation for this library is [available on GitHub](https://github.com/bvaughn/js-search). This guide is based on the official `js-search` example but has been adapted to work with your Gatsby site. + +## Setup + +You'll start by creating a new Gatsby site based on the official _hello world_ starter. Open up a terminal and run the following command: + +```bash +npx gatsby new js-search-example https://github.com/gatsbyjs/gatsby-starter-default +``` + +After the process is complete, some additional packages are needed. + +Change directories to the `js-search-example` folder and issue the following command: + +```bash +npm install --save js-search axios +``` + +Or if Yarn is being used: + +```bash +yarn add js-search axios +``` + +**Note**: + +For this particular example [axios](https://github.com/axios/axios) will be used to handle all of the promise-based HTTP requests. + +## Strategy selection + +In the next sections you'll learn about two approaches to implementing `js-search` in your site. Which one you choose will depend on the number of items you want to search. For a small to medium dataset, the first strategy documented should work out nicely. + +For larger datasets you could use the second approach, as most of the work is done beforehand through the use of Gatsby's internal API. + +Both ways are fairly generalistic, they were implemented using the default options for the library, so that it can be experimented without going through into the specifics of the library. + +And finally as you go through the code, be mindful it does not adhere to the best practices, it's just for demonstration purposes, in a real site it would have been implemented in a different way. + +## JS-Search with a small to medium dataset + +Start by creating a file named `SearchContainer.js` in the `src/components/` folder, then add the following code to get started: + +```javascript +import React, { Component } from "react" +import Axios from "axios" +import * as JsSearch from "js-search" + +class Search extends Component { + state = { + bookList: [], + search: [], + searchResults: [], + isLoading: true, + isError: false, + searchQuery: "", + } + /** + * React lifecycle method to fetch the data + */ + async componentDidMount() { + Axios.get("https://bvaughn.github.io/js-search/books.json") + .then(result => { + const bookData = result.data + this.setState({ bookList: bookData.books }) + this.rebuildIndex() + }) + .catch(err => { + this.setState({ isError: true }) + console.log("====================================") + console.log(`Something bad happened while fetching the data\n${err}`) + console.log("====================================") + }) + } + + /** + * rebuilds the overall index based on the options + */ + rebuildIndex = () => { + const { bookList } = this.state + const dataToSearch = new JsSearch.Search("isbn") + /** + * defines a indexing strategy for the data + * more more about it in here https://github.com/bvaughn/js-search#configuring-the-index-strategy + */ + dataToSearch.indexStrategy = new JsSearch.PrefixIndexStrategy() + /** + * defines the sanitizer for the search + * to prevent some of the words from being excluded + * + */ + dataToSearch.sanitizer = new JsSearch.LowerCaseSanitizer() + /** + * defines the search index + * read more in here https://github.com/bvaughn/js-search#configuring-the-search-index + */ + dataToSearch.searchIndex = new JsSearch.TfIdfSearchIndex("isbn") + + dataToSearch.addIndex("title") // sets the index attribute for the data + dataToSearch.addIndex("author") // sets the index attribute for the data + + dataToSearch.addDocuments(bookList) // adds the data to be searched + this.setState({ search: dataToSearch, isLoading: false }) + } + + /** + * handles the input change and perfom a search with js-search + * in which the results will be added to the state + */ + searchData = e => { + const { search } = this.state + const queryResult = search.search(e.target.value) + this.setState({ searchQuery: e.target.value, searchResults: queryResult }) + } + handleSubmit = e => { + e.preventDefault() + } + + render() { + const { bookList, searchResults, searchQuery } = this.state + const queryResults = searchQuery === "" ? bookList : searchResults + return ( +
+
+
+
+ + +
+
+
+ Number of items: + {queryResults.length} + + + + + + + + + + {queryResults.map(item => { + return ( + + + + + + ) + })} + +
+ Book ISBN + + Book Title + + Book Author +
+ {item.isbn} + + {item.title} + + {item.author} +
+
+
+
+ ) + } +} +export default Search +``` + +Breaking down the code into smaller parts: + +1. When the component is mounted, the `componentDidMount()` lifecycle method is triggered and the data will be fetched. +2. If no errors occur, the data received is added to the state and the `rebuildIndex()` function is invoked. +3. The search engine is then created and configured with the default options. +4. The data is then indexed using js-search. +5. When the contents of the input changes, js-search starts the search process based on the `input`'s value and returns the search results if any, which is then presented to the user via the `table` element. + +### Joining all the pieces + +In order to get it working in your site, you would only need to import the newly created component to a page. +As you can see [in the example site](https://github.com/gatsbyjs/gatsby/tree/master/examples/using-js-search/src/pages/index.js). + +Run `gatsby develop` and if all went well, open your browser of choice and enter the url `http://localhost:8000` - you'll have a fully functional search at your disposal. + +## JS-Search with a big dataset + +Now let's try a different approach, this time instead of letting the component do all of the work, it's Gatsby's job to do that and pass all the data to a page defined by the path property, via [pageContext](https://www.gatsbyjs.org/docs/behind-the-scenes-terminology/#pagecontext). + +To do this, some changes are required. + +Start by modifying the `gatsby-node.js` file by adding the following code: + +```javascript +const path = require("path") +const axios = require("axios") + +exports.createPages = ({ actions }) => { + const { createPage } = actions + return new Promise((resolve, reject) => { + axios + .get("https://bvaughn.github.io/js-search/books.json") + .then(result => { + const { data } = result + /** + * creates a dynamic page with the data recieved + * injects the data into the context object alongside with some options + * to configure js-search + */ + createPage({ + path: "/search", + component: path.resolve(`./src/templates/ClientSearchTemplate.js`), + context: { + bookData: { + allBooks: data.books, + options: { + indexStrategy: "Prefix match", + searchSanitizer: "Lower Case", + TitleIndex: true, + AuthorIndex: true, + SearchByTerm: true, + }, + }, + }, + }) + resolve() + }) + .catch(err => { + console.log("====================================") + console.log(`error creating Page:${err}`) + console.log("====================================") + reject(new Error(`error on page creation:\n${err}`)) + }) + }) +} +``` + +Create a file named `ClientSearchTemplate.js` in the `src/templates/` folder, then add the following code to get started: + +```javascript +import React from "react" +import ClientSearch from "../components/ClientSearch" + +const SearchTemplate = props => { + const { pageContext } = props + const { bookData } = pageContext + const { allBooks, options } = bookData + return ( +
+

+ Search data using JS Search using Gatsby Api +

+
+ +
+
+ ) +} + +export default SearchTemplate +``` + +Create a file named `ClientSearch.js` in the `src/components/` folder, then add the following code as a baseline: + +```javascript +import React, { Component } from "react" +import * as JsSearch from "js-search" + +class ClientSearch extends Component { + state = { + isLoading: true, + searchResults: [], + search: null, + isError: false, + indexByTitle: false, + indexByAuthor: false, + termFrequency: true, + removeStopWords: false, + searchQuery: "", + selectedStrategy: "", + selectedSanitizer: "", + } + /** + * React lifecycle method that will inject the data into the state. + */ + static getDerivedStateFromProps(nextProps, prevState) { + if (prevState.search === null) { + const { engine } = nextProps + return { + indexByTitle: engine.TitleIndex, + indexByAuthor: engine.AuthorIndex, + termFrequency: engine.SearchByTerm, + selectedSanitizer: engine.searchSanitizer, + selectedStrategy: engine.indexStrategy, + } + } + return null + } + async componentDidMount() { + this.rebuildIndex() + } + + /** + * rebuilds the overall index based on the options + */ + rebuildIndex = () => { + const { + selectedStrategy, + selectedSanitizer, + removeStopWords, + termFrequency, + indexByTitle, + indexByAuthor, + } = this.state + const { books } = this.props + + const dataToSearch = new JsSearch.Search("isbn") + + if (removeStopWords) { + dataToSearch.tokenizer = new JsSearch.StopWordsTokenizer( + dataToSearch.tokenizer + ) + } + /** + * defines an indexing strategy for the data + * read more about it here https://github.com/bvaughn/js-search#configuring-the-index-strategy + */ + if (selectedStrategy === "All") { + dataToSearch.indexStrategy = new JsSearch.AllSubstringsIndexStrategy() + } + if (selectedStrategy === "Exact match") { + dataToSearch.indexStrategy = new JsSearch.ExactWordIndexStrategy() + } + if (selectedStrategy === "Prefix match") { + dataToSearch.indexStrategy = new JsSearch.PrefixIndexStrategy() + } + + /** + * defines the sanitizer for the search + * to prevent some of the words from being excluded + */ + selectedSanitizer === "Case Sensitive" + ? (dataToSearch.sanitizer = new JsSearch.CaseSensitiveSanitizer()) + : (dataToSearch.sanitizer = new JsSearch.LowerCaseSanitizer()) + termFrequency === true + ? (dataToSearch.searchIndex = new JsSearch.TfIdfSearchIndex("isbn")) + : (dataToSearch.searchIndex = new JsSearch.UnorderedSearchIndex()) + + // sets the index attribute for the data + if (indexByTitle) { + dataToSearch.addIndex("title") + } + // sets the index attribute for the data + if (indexByAuthor) { + dataToSearch.addIndex("author") + } + + dataToSearch.addDocuments(books) // adds the data to be searched + + this.setState({ search: dataToSearch, isLoading: false }) + } + /** + * handles the input change and perfom a search with js-search + * in which the results will be added to the state + */ + searchData = e => { + const { search } = this.state + const queryResult = search.search(e.target.value) + this.setState({ searchQuery: e.target.value, searchResults: queryResult }) + } + handleSubmit = e => { + e.preventDefault() + } + render() { + const { searchResults, searchQuery } = this.state + const { books } = this.props + const queryResults = searchQuery === "" ? books : searchResults + return ( +
+
+
+
+ + +
+
+
+ Number of items: + {queryResults.length} + + + + + + + + + + {queryResults.map(item => { + return ( + + + + + + ) + })} + +
+ Book ISBN + + Book Title + + Book Author +
+ {item.isbn} + + {item.title} + + {item.author} +
+
+
+
+ ) + } +} +export default ClientSearch +``` + +Breaking down the code into smaller parts: + +1. When the component is mounted, the `getDerivedStateFromProps()` lifecycle method is invoked and it will evaluate the state and if necessary update it. +2. Then the `componentDidMount()` lifecycle method will be triggered and the `rebuildIndex()` function is invoked. +3. The search engine is then created and configured with the options defined. +4. The data is then indexed using js-search. +5. When the contents of the input changes, js-search starts the search process based on the `input`'s value and returns the search results if any, which is then presented to the user via the `table` element. + +### Joining all the pieces + +Once again to get it to work on your site you would only need to copy over [the `gatsby-node.js` file located here](https://github.com/gatsbyjs/gatsby/tree/master/examples/using-js-search/gatsby-node.js). + +And both the [template](https://github.com/gatsbyjs/gatsby/tree/master/examples/using-js-search/src/templates/ClientSearchTemplate.js) and the [search component](https://github.com/gatsbyjs/gatsby/tree/master/examples/using-js-search/src/components/ClientSearch.js). + +Issuing `gatsby develop` again, and if all went without any issues one more time, open your browser of choice and enter the url `http://localhost:8000/search`, you'll have a fully functional search at your disposal coupled with Gatsby API. + +Hopefully this rather extensive guide has shed some insights on how to implement client search using js-search. + +Now go make something great! diff --git a/examples/using-js-search/README.md b/examples/using-js-search/README.md new file mode 100644 index 0000000000000..020b7214cc212 --- /dev/null +++ b/examples/using-js-search/README.md @@ -0,0 +1,6 @@ +## Adding client search using Js Search + +The code in this folder is the full implementation for the documentation on how to add client search with [js-search](https://github.com/bvaughn/js-search). + +A live version of this example is located [here](https://pedantic-clarke-873963.netlify.com/) +The endpoint that uses Gatsby API is located [here](https://pedantic-clarke-873963.netlify.com/search) diff --git a/examples/using-js-search/gatsby-node.js b/examples/using-js-search/gatsby-node.js new file mode 100644 index 0000000000000..27f1e0c869c8e --- /dev/null +++ b/examples/using-js-search/gatsby-node.js @@ -0,0 +1,41 @@ +const path = require(`path`) +const axios = require(`axios`) + +exports.createPages = ({ actions }) => { + const { createPage } = actions + return new Promise((resolve, reject) => { + axios + .get(`https://bvaughn.github.io/js-search/books.json`) + .then(result => { + const { data } = result + /** + * creates a page dynamic page with the data recieved + * injects the data recived into the context object alongside with some options + * to configure js-search + */ + createPage({ + path: `/search`, + component: path.resolve(`./src/templates/ClientSearchTemplate.js`), + context: { + bookData: { + allBooks: data.books, + options: { + indexStrategy: `Prefix match`, + searchSanitizer: `Lower Case`, + TitleIndex: true, + AuthorIndex: true, + SearchByTerm: true, + }, + }, + }, + }) + resolve() + }) + .catch(err => { + console.log(`====================================`) + console.log(`error creating Page:${err}`) + console.log(`====================================`) + reject(new Error(`error on page creation:\n${err}`)) + }) + }) +} diff --git a/examples/using-js-search/package.json b/examples/using-js-search/package.json new file mode 100644 index 0000000000000..c1ac2ca36bdf9 --- /dev/null +++ b/examples/using-js-search/package.json @@ -0,0 +1,24 @@ +{ + "name": "js-search-example", + "private": true, + "description": "Gatsby example site to demonstrate client side search", + "version": "0.1.0", + "keywords": [ + "Gatsby", "client side search", "js-search" + ], + "license": "MIT", + "scripts": { + "build": "gatsby build", + "develop": "gatsby develop", + "start": "npm run develop", + "serve": "gatsby serve", + "test": "echo \"Write tests! -> https://gatsby.app/unit-testing\"" + }, + "dependencies": { + "axios": "^0.18.0", + "gatsby": "^2.0.104", + "js-search": "^1.4.2", + "react": "^16.5.1", + "react-dom": "^16.5.1" + } +} diff --git a/examples/using-js-search/src/components/ClientSearch.js b/examples/using-js-search/src/components/ClientSearch.js new file mode 100644 index 0000000000000..965141ac6815d --- /dev/null +++ b/examples/using-js-search/src/components/ClientSearch.js @@ -0,0 +1,224 @@ +import React, { Component } from "react" +import * as JsSearch from "js-search" + +class ClientSearch extends Component { + state = { + isLoading: true, + searchResults: [], + search: null, + isError: false, + indexByTitle: false, + indexByAuthor: false, + termFrequency: true, + removeStopWords: false, + searchQuery: ``, + selectedStrategy: ``, + selectedSanitizer: ``, + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (prevState.search === null) { + const { engine } = nextProps + return { + indexByTitle: engine.TitleIndex, + indexByAuthor: engine.AuthorIndex, + termFrequency: engine.SearchByTerm, + selectedSanitizer: engine.searchSanitizer, + selectedStrategy: engine.indexStrategy, + } + } + return null + } + async componentDidMount() { + this.rebuildIndex() + } + + rebuildIndex = () => { + const { + selectedStrategy, + selectedSanitizer, + removeStopWords, + termFrequency, + indexByTitle, + indexByAuthor, + } = this.state + const { books } = this.props + + const dataToSearch = new JsSearch.Search(`isbn`) + if (removeStopWords) { + dataToSearch.tokenizer = new JsSearch.StopWordsTokenizer( + dataToSearch.tokenizer + ) + } + if (selectedStrategy === `All`) { + dataToSearch.indexStrategy = new JsSearch.AllSubstringsIndexStrategy() + } + if (selectedStrategy === `Exact match`) { + dataToSearch.indexStrategy = new JsSearch.ExactWordIndexStrategy() + } + if (selectedStrategy === `Prefix match`) { + dataToSearch.indexStrategy = new JsSearch.PrefixIndexStrategy() + } + /* eslint-disable */ + selectedSanitizer === `Case Sensitive` + ? (dataToSearch.sanitizer = new JsSearch.CaseSensitiveSanitizer()) + : (dataToSearch.sanitizer = new JsSearch.LowerCaseSanitizer()) + + termFrequency === true + ? (dataToSearch.searchIndex = new JsSearch.TfIdfSearchIndex(`isbn`)) + : (dataToSearch.searchIndex = new JsSearch.UnorderedSearchIndex()) + /* eslint-enable */ + if (indexByTitle) { + dataToSearch.addIndex(`title`) + } + if (indexByAuthor) { + dataToSearch.addIndex(`author`) + } + dataToSearch.addDocuments(books) + this.setState({ search: dataToSearch, isLoading: false }) + } + searchData = e => { + const { search } = this.state + const queryResult = search.search(e.target.value) + this.setState({ searchQuery: e.target.value, searchResults: queryResult }) + } + handleSubmit = e => { + e.preventDefault() + } + render() { + const { isLoading, isError, searchResults, searchQuery } = this.state + const { books } = this.props + const queryResults = searchQuery === `` ? books : searchResults + if (isLoading) { + return ( +
+

Getting the search all setup

+
+ ) + } + if (isError) { + return ( +
+

Ohh no!!!!!

+

+ Something really bad happened +

+
+ ) + } + return ( +
+
+
+
+ + +
+
+
+ Number of items: + {queryResults.length} + + + + + + + + + + {/* eslint-disable */} + {queryResults.map(item => { + return ( + + + + + + ) + })} + {/* eslint-enable */} + +
+ Book ISBN + + Book Title + + Book Author +
+ {item.isbn} + + {item.title} + + {item.author} +
+
+
+
+ ) + } +} +export default ClientSearch diff --git a/examples/using-js-search/src/components/SearchContainer.js b/examples/using-js-search/src/components/SearchContainer.js new file mode 100644 index 0000000000000..bdbb3cb08c591 --- /dev/null +++ b/examples/using-js-search/src/components/SearchContainer.js @@ -0,0 +1,224 @@ +import React, { Component } from "react" +import Axios from "axios" +import * as JsSearch from "js-search" + +class Search extends Component { + state = { + bookList: [], + search: [], + searchResults: [], + isLoading: true, + isError: false, + searchQuery: ``, + } + /** + * React lifecycle method to fetch the data + */ + async componentDidMount() { + Axios.get(`https://bvaughn.github.io/js-search/books.json`) + .then(result => { + const bookData = result.data + this.setState({ bookList: bookData.books }) + this.rebuildIndex() + }) + .catch(err => { + this.setState({ isError: true }) + console.log(`====================================`) + console.log(`Something bad happened while fetching the data\n${err}`) + console.log(`====================================`) + }) + } + + /** + * rebuilds the overall index based on the options + */ + rebuildIndex = () => { + const { bookList } = this.state + + const dataToSearch = new JsSearch.Search(`isbn`) + + /** + * defines a indexing strategy for the data + * more more about it in here https://github.com/bvaughn/js-search#configuring-the-index-strategy + */ + dataToSearch.indexStrategy = new JsSearch.PrefixIndexStrategy() + + /** + * defines the sanitizer for the search + * to prevent some of the words from being excluded + * + */ + dataToSearch.sanitizer = new JsSearch.LowerCaseSanitizer() + + /** + * defines the search index + * read more in here https://github.com/bvaughn/js-search#configuring-the-search-index + */ + dataToSearch.searchIndex = new JsSearch.TfIdfSearchIndex(`isbn`) + + dataToSearch.addIndex(`title`) // sets the index attribute for the data + dataToSearch.addIndex(`author`) // sets the index attribute for the data + + dataToSearch.addDocuments(bookList) // adds the data to be searched + this.setState({ search: dataToSearch, isLoading: false }) + } + + /** + * handles the input change and perfom a search with js-search + * in which the results will be added to the state + */ + searchData = e => { + const { search } = this.state + const queryResult = search.search(e.target.value) + this.setState({ searchQuery: e.target.value, searchResults: queryResult }) + } + handleSubmit = e => { + e.preventDefault() + } + + render() { + const { + isError, + isLoading, + bookList, + searchResults, + searchQuery, + } = this.state + const queryResults = searchQuery === `` ? bookList : searchResults + + if (isLoading) { + return ( +
+

+ Getting the search all setup +

+
+ ) + } + if (isError) { + return ( +
+

Ohh no!!!!!

+

+ Something really bad happened +

+
+ ) + } + return ( +
+
+
+
+ + +
+
+
+ Number of items: + {queryResults.length} + + + + + + + + + + {/* eslint-disable */} + {queryResults.map(item => { + return ( + + + + + + ) + })} + {/* eslint-enable */} + +
+ Book ISBN + + Book Title + + Book Author +
+ {item.isbn} + + {item.title} + + {item.author} +
+
+
+
+ ) + } +} + +export default Search diff --git a/examples/using-js-search/src/pages/index.js b/examples/using-js-search/src/pages/index.js new file mode 100644 index 0000000000000..31b54ad108b9b --- /dev/null +++ b/examples/using-js-search/src/pages/index.js @@ -0,0 +1,15 @@ +import React from "react" +import Search from "../components/SearchContainer" + +const IndexPage = () => ( +
+

+ Search data using JS Search +

+
+ +
+
+) + +export default IndexPage diff --git a/examples/using-js-search/src/templates/ClientSearchTemplate.js b/examples/using-js-search/src/templates/ClientSearchTemplate.js new file mode 100644 index 0000000000000..7c7f1551c6d63 --- /dev/null +++ b/examples/using-js-search/src/templates/ClientSearchTemplate.js @@ -0,0 +1,20 @@ +import React from "react" +import ClientSearch from "../components/ClientSearch" + +const SearchTemplate = props => { + const { pageContext } = props + const { bookData } = pageContext + const { allBooks, options } = bookData + return ( +
+

+ Search data using JS Search using Gatsby Api +

+
+ +
+
+ ) +} + +export default SearchTemplate diff --git a/examples/using-js-search/static/favicon.ico b/examples/using-js-search/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1a466ba8852cf099ae40335e878897bde72e1735 GIT binary patch literal 2813 zcmV7wtO>0C#@O9*BXj0vnU3Txw<*2nLRVINq?LYz0THvB9>K0QA`=kA45 zH>rZGmlV#qnH$uCDPe+;0o=3Sp?kdq2WgKYI^SXKIZBYn-k+Edd<+6KvLh|TKS!{V zpmkAJ1tGJUk)?NIbT2}HbKK*+590b+#Ctc)-(n5gtDWwb65MI+Khl=Qpa}`SObc0D zcb3qzwnfCnoGI(U#i&M#k<6JI2B@4&y3QodXL{VnHQ%G>xde1yh{Cr~tOY{ox`SZ3 zFw9dcCna73B99M6W#~MX2swyNG~(c5oRRa8b|0Nk?#}ws8a4BarE2z<*Qz=9u2pl# zuVeW2SIYPvp5Yz5D+FN;y;dZLqypy9bp!1;&a#cc5n;b5oF_sa0%!7BRs4v9i?i=p zqX3V8cZ(`5*sFGw9ait`JF6NSE~)dUuQ7aZ^s+i!b5`wij;J+-`_z)}RWL!b=sS^| zO^BzKp+16XqQW+A10pY+rv>N1_&lQo@}62?LqfovcVD?$mA9J_zImft4fg10?@>o; zo7Cz>d(`}i5l|h2y!IA=7#7ARw4n>m6=zqgM&hh} z#G>&5kq^Fe?%k`ZWZ`a>$fLA2_RvJIZRJnhVu%oRO7cz?Wuo&BX|5aObF;!O>9V`< z{`Km>wvz^dXe6dw*G+Ku;~v#~qD3A4N0WN%_0#ID*G{p7nh4QbZGI`?TR9HxJfjvp z_#)d5_R>VKH`9!KL_3wy;w62^Lqz%k8LZQx2{6b*WtRSUtGd{D-EcA<^SIg5*{2Tf zcwdz+-m8kfvzgU`4OiO-u8F&<@LQYI3;BE0KdVlu&YQjbts8fZR4#wmu2%fKlE1Nt zh~;>08um9qVuc|h81)cb5(Z1iYkhzE7q1#U@9T{k&ed&wPd)j=m)W~$50h}6aH*bK z<$E9k>WBA!^Zg1{v-y~cX(q^;UAKDGA7{Mo5s^@*sFuPpg3(EKkTBZN677#8qVrIj zr9b+cx_P~eH;CwsuAFaIf0^@lHRJA5HIH0nu1405zNrB3;N5xmt>bT>nO5fMajV)(PZ_$2v<7h#4o znmJ7042Zk{znjUb$;OL4jve^WlSGfV$Mq{6>alNbV#7{Ea%NBu)ZAnU7WY@`AQBNj z4+QsDp74qIeL^13nd@2MAt9cM5Uc^UgGBzbzu9Nh3m@2^vM2HSI!=TlT0_FNEl6V4 zD%=-8zjFRg&tSElUcKo@{>d9AtKW=au9*NMa?WGi7G|C1kicPHsS+1 ze8-skgyi!%yL`TS|Enj}OftH3BOkO2R+&ueUedcxOh@PjyBO)#qG79to2%d5==o_1 zQoUUfwTxl_60@Ni?2w6=(jW3h3LpfwIv&P59X^)M9J-My7JYlOYQNTL461%tqx#jD z)hymm3;7{{Q6nNE4-zOY(B4Np&#K&AzP#lIxwL1dRdE0suIEV;#5|@u+d)9Bj&d;( z9XO3#hHaoPW^$ZAJiIO0i2pwl5O351U)rSeo;Y12rDc}}K+0lhAY!<&!y-gw zc-@1r6)e2qLQ$BB5J?y!hA9boA7^3IU;qiC^{oUpH^V~Ql}(U6B2Wnt>6V1}{l(wd zIA|#N+KV;=1lI%sILpGt3#vJoAVOxRTN1QB+%Zz~>z8k_w`*(ZRPDEc8&^A2C+&q4 z2DUkWQZfNuMyF_T6cnhms;Pbz*V&&Wp8n;~hya7;jw@5kfBFj70)ss+P(pb-2{ell zOB6mt#R-ne=l;df*CrxK5<^rcApq&}KnH@TxbpW7UEs{sO4*GO*NGcN0f8i{>V>z> zBHNy?=e#|gl91|b+p2o?8tqk8@;2`Yz;E2&vF@MX|sG!3A}Z@m1jshJ^(Wk1=DB2E!+sLOz&I+iC<5BC1M~iAaZwl>j

}uA3 zJBZ51Gh-{LePn)666(D9~}hFkGgt_C%~*Ee_( zDUfKEOKU|MTSzV78qq#Xmn8DYRpK2OEK&-wH9;QdKh4^XnBZF7Io5r;E)KR-OrsQI z9TdyjBSlSLls5hI?Cq+jD`JQU+C(ZBn@SR>?52OAgnOtv%a52wmZ%MoM>46aKhs*& zf$I!if5vXeCC$O;JPyMJJ&B3gM1X_W{(CdwUQ>9dfh5sJ*(HLM?dPTQfoI@+0Qq>r zW+F=s+jHQ+bf!=b7hHS50mFt}tS#Kf<0Jc!yruHFL!64|q$r9wvRi5NM`v%S z^5537F~eXjooD2=*I6MWfP}g`{f?NlIyr^NlXAQ?BdSMC>=htpc#pE88g_Tm@0SDh z#9ZBiG~TJklU#R4AE(F86SYhv{JfrQ8l8vAXa=qjo$q%}W*f7<7Mpm)8@z2w8uG!l zfsi>AFP`|pORDVg8ntJ`QFZjqW_AA5H69&td772zec~N7$#7pY!5h6M%G!ulUqH52 z@eub02b z+Kr!~dt$IYwGsRVm=!zr2hx5YP0yZ1f8NteQ7b2=%TZUS$Ka^R`2#7m8H8)Z^>jb1 z61kXDY`Mx&fw}eI|7zf=k&`s7$8Z7td9-7&sfe52je5pzBBsq<`(8}Ajc+x_dypPT zgtfiRfNC@N$$cpBw*zhpKSc;Ocu}%K|A`~a{hHJrwXjCILg(|&ab%VFCqSxyt=M)s zti%7`2_85H|86s$Hp-*(b~?BCC6FjW=(?58)u^QCdZLM@iD~@Ep>q9yx$uEL-Yj-E P00000NkvXXu0mjfF#vB* literal 0 HcmV?d00001