From 9ed57d15c703f0df81b1acc3daf84c77f09bb3ec Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 16 May 2025 16:07:23 -0400
Subject: [PATCH] update
---
Cargo.lock | 224 +++++++++++++-
Cargo.toml | 6 +
README.md | 174 +++++------
marlin_demo.md | 109 +++----
.../0004_fix_hierarchical_tags_fts.sql | 289 ++++++++++++++++++
src/db/migrations/mod.rs | 260 ++++++++++++++++
src/db/mod.rs | 46 ++-
src/main.rs | 129 ++++----
src/test_hierarchical_tags.rs | 240 +++++++++++++++
target/release/marlin | Bin 6986912 -> 7032328 bytes
target/release/marlin.d | 2 +-
tests/e2e.rs | 103 +++++++
tests/test.md | 68 +++++
13 files changed, 1425 insertions(+), 225 deletions(-)
create mode 100644 src/db/migrations/0004_fix_hierarchical_tags_fts.sql
create mode 100644 src/db/migrations/mod.rs
create mode 100644 src/test_hierarchical_tags.rs
create mode 100644 tests/e2e.rs
create mode 100644 tests/test.md
diff --git a/Cargo.lock b/Cargo.lock
index 9e46be2..cb384f6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -94,6 +94,22 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
+[[package]]
+name = "assert_cmd"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "doc-comment",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
[[package]]
name = "autocfg"
version = "1.4.0"
@@ -106,6 +122,17 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
+[[package]]
+name = "bstr"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
+dependencies = [
+ "memchr",
+ "regex-automata 0.4.9",
+ "serde",
+]
+
[[package]]
name = "bumpalo"
version = "3.17.0"
@@ -202,6 +229,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
[[package]]
name = "directories"
version = "5.0.1"
@@ -211,6 +244,15 @@ dependencies = [
"dirs-sys 0.4.1",
]
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys 0.4.1",
+]
+
[[package]]
name = "dirs"
version = "6.0.0"
@@ -244,6 +286,22 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "doc-comment"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
+
+[[package]]
+name = "errno"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -256,6 +314,21 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -264,7 +337,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
- "wasi",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.2+wasi-0.2.4",
]
[[package]]
@@ -370,6 +455,12 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "linux-raw-sys"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+
[[package]]
name = "log"
version = "0.4.27"
@@ -381,14 +472,18 @@ name = "marlin"
version = "0.1.0"
dependencies = [
"anyhow",
+ "assert_cmd",
"chrono",
"clap",
"clap_complete",
"directories",
+ "dirs 5.0.1",
"glob",
+ "predicates",
"rusqlite",
"shellexpand",
"shlex",
+ "tempfile",
"tracing",
"tracing-subscriber",
"walkdir",
@@ -409,6 +504,12 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@@ -458,6 +559,36 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+[[package]]
+name = "predicates"
+version = "3.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "float-cmp",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.95"
@@ -476,13 +607,19 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
+
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
- "getrandom",
+ "getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
@@ -493,7 +630,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
- "getrandom",
+ "getrandom 0.2.16",
"libredox",
"thiserror 2.0.12",
]
@@ -556,6 +693,19 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "rustix"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "rustversion"
version = "1.0.20"
@@ -571,6 +721,26 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -586,7 +756,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
dependencies = [
- "dirs",
+ "dirs 6.0.0",
]
[[package]]
@@ -618,6 +788,25 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "tempfile"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -759,6 +948,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -775,6 +973,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+[[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
@@ -1062,6 +1269,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.25"
diff --git a/Cargo.toml b/Cargo.toml
index 1bc20b9..da85dad 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,3 +16,9 @@ shlex = "1.3"
chrono = "0.4"
shellexpand = "3.1"
clap_complete = "4.1"
+
+[dev-dependencies]
+assert_cmd = "2"
+predicates = "3"
+tempfile = "3"
+dirs = "5" # cross-platform data dir helper
diff --git a/README.md b/README.md
index c4a57b2..a615e86 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,74 @@ sudo install -Dm755 target/release/marlin /usr/local/bin/marlin
For a concise walkthrough, see [Quick start & Demo](marlin_demo.md).
+## Testing
+
+Below is a **repeat-able 3-step flow** you can use **every time you pull fresh code**.
+
+---
+
+### 0 Prepare once
+
+```bash
+# Run once (or add to ~/.bashrc) so debug + release artefacts land
+# in the same predictable place. Speeds-up future builds.
+export CARGO_TARGET_DIR=target
+```
+
+---
+
+### 1 Build the new binary
+
+```bash
+git pull # grab the latest commit
+cargo build --release
+sudo install -Dm755 target/release/marlin /usr/local/bin/marlin
+```
+
+* `cargo build --release` – builds the optimised binary.
+* `install …` – copies it into your `$PATH` so `marlin` on the CLI is the fresh one.
+
+---
+
+### 2 Run the smoke-test suite
+
+```bash
+# Runs the end-to-end test we added in tests/e2e.rs
+cargo test --test e2e -- --nocapture
+```
+
+* `--test e2e` – compiles and runs **only** `tests/e2e.rs`; other unit-tests are skipped (add them later if you like).
+* `--nocapture` – streams stdout/stderr so you can watch each CLI step in real time.
+* Exit-code **0** ➜ everything passed.
+ Any non-zero exit or a red ✗ line means a step failed; the assert’s diff will show the command and its output.
+
+---
+
+### 3 (Optionally) run all tests
+
+```bash
+cargo test --all -- --nocapture
+```
+
+This will execute:
+
+* unit tests in `src/**`
+* every file in `tests/`
+* doc-tests
+
+If you wire **“cargo test --all”** into CI (GitHub Actions, GitLab, etc.), pushes that break a workflow will be rejected automatically.
+
+---
+
+#### One-liner helper (copy/paste)
+
+```bash
+git pull && cargo build --release &&
+sudo install -Dm755 target/release/marlin /usr/local/bin/marlin &&
+cargo test --test e2e -- --nocapture
+```
+
+Stick that in a shell alias (`alias marlin-ci='…'`) and you’ve got a 5-second upgrade-and-verify loop.
### Database location
@@ -129,112 +197,6 @@ The versioned migration system preserves your data across upgrades.
---
-## Five-Minute Quickstart
-
-Just paste & run each block in your terminal.
-
-### 0 Prepare, build & install
-
-```bash
-cd ~/Documents/GitHub/Marlin
-cargo build --release
-sudo install -Dm755 target/release/marlin /usr/local/bin/marlin
-```
-
-> Now `marlin` is available everywhere.
-
-### 1 Enable shell completion
-
-```bash
-mkdir -p ~/.config/bash_completion.d
-marlin completions bash > ~/.config/bash_completion.d/marlin
-```
-
-### 2 Prepare a clean demo directory
-
-```bash
-rm -rf ~/marlin_demo
-mkdir -p ~/marlin_demo/{Projects/{Alpha,Beta},Media/Photos,Docs}
-
-printf "Alpha draft\n" > ~/marlin_demo/Projects/Alpha/draft.txt
-printf "Beta notes\n" > ~/marlin_demo/Projects/Beta/notes.md
-printf "Receipt PDF\n" > ~/marlin_demo/Docs/receipt.pdf
-printf "fake jpg\n" > ~/marlin_demo/Media/Photos/vacation.jpg
-```
-
-### 3 Initialize & index files
-
-```bash
-marlin init
-marlin scan ~/marlin_demo
-
-# show every path tested:
-marlin --verbose scan ~/marlin_demo
-```
-
-> Only changed files get re-indexed on subsequent runs.
-
-### 4 Attach tags & attributes
-
-```bash
-# Tag everything under “Alpha”
-marlin tag ~/marlin_demo/Projects/Alpha/**/* project/alpha
-
-# Mark all PDFs as reviewed
-marlin attr set ~/marlin_demo/**/*.pdf reviewed yes
-
-# Output as JSON instead:
-marlin --format=json attr set ~/marlin_demo/**/*.pdf reviewed yes
-```
-
-### 5 Search your index
-
-```bash
-# By tag or filename
-marlin search alpha
-
-# Combined terms:
-marlin search "reviewed AND pdf"
-
-# Run a command on each hit:
-marlin search reviewed --exec 'echo HIT → {}'
-```
-
-### 6 Backup & restore
-
-```bash
-# Snapshot
-snap=$(marlin backup | awk '{print $NF}')
-
-# Simulate loss
-rm ~/.local/share/marlin/index.db
-
-# Restore
-marlin restore "$snap"
-
-# Verify
-marlin search reviewed
-```
-
----
-
-##### What you just exercised
-
-| Command | Purpose |
-| ----------------- | ----------------------------------------- |
-| `marlin init` | Create / upgrade the SQLite database |
-| `marlin scan` | Walk directories and (re)index files |
-| `marlin tag` | Attach hierarchical tags |
-| `marlin attr set` | Add/overwrite custom key-value attributes |
-| `marlin search` | FTS5 search across path / tags / attrs |
-| `--exec` | Pipe hits into any shell command |
-| `marlin backup` | Timestamped snapshot of the DB |
-| `marlin restore` | Replace live DB with a chosen snapshot |
-
-That’s the complete surface area of Marlin today—feel free to play around or point the scanner at real folders.
-
----
-
## License
MIT – see `LICENSE`
diff --git a/marlin_demo.md b/marlin_demo.md
index 7f51e19..6b5b3ac 100644
--- a/marlin_demo.md
+++ b/marlin_demo.md
@@ -1,19 +1,23 @@
-# Marlin Demo
+# Marlin Demo
-Here’s a little demo you can spin up to exercise tags, attributes, FTS queries, `--exec` hooks, backups & restores, and linking. Just copy–paste each block into your terminal:
+Below is the **“hello-world” demo** that matches the current master branch (auto-scan on `marlin init`, no more forced-migration noise, and cleaner build).
---
-### 0 Create the demo folder and some files
-
-```bash
-cargo build --release
-```
+## 0 Build & install Marlin
```bash
+# inside the repo
+cargo build --release # build the new binary
sudo install -Dm755 target/release/marlin /usr/local/bin/marlin
```
+*(`cargo install --path . --locked --force` works too if you prefer.)*
+
+---
+
+## 1 Create the demo tree
+
```bash
rm -rf ~/marlin_demo
mkdir -p ~/marlin_demo/{Projects/{Alpha,Beta,Gamma},Logs,Reports,Scripts,Media/Photos}
@@ -72,18 +76,31 @@ chmod +x ~/marlin_demo/Scripts/deploy.sh
echo "JPEGDATA" > ~/marlin_demo/Media/Photos/event.jpg
```
+*(copy the file-creation block from your original instructions — nothing about the files needs to change)*
+
---
-### 1 Initialize & index
+## 2 Initialise **and** index (one step)
+
+`marlin init` now performs a first-time scan of whatever directory you run it in.
+So just:
```bash
+cd ~/marlin_demo # <-- important: run init from the folder you want indexed
marlin init
-marlin scan ~/marlin_demo
```
+That will:
+
+1. create/upgrade the DB,
+2. run all migrations exactly once,
+3. walk the current directory and ingest every file it finds.
+
+Need to add more paths later? Use `marlin scan
` exactly as before.
+
---
-### 2 Attach hierarchical tags
+## 3 Tagging examples
```bash
# Tag all project markdown as “project/md”
@@ -98,101 +115,69 @@ marlin tag "~/marlin_demo/Projects/Beta/**/*" project/beta
---
-### 3 Set custom attributes
+## 4 Set custom attributes
```bash
-# Mark only the “final.md” as complete
-marlin attr set "~/marlin_demo/Projects/Beta/final.md" status complete
-
-# Mark PDF as reviewed
-marlin attr set "~/marlin_demo/Reports/*.pdf" reviewed yes
+marlin attr set "~/marlin_demo/Projects/Beta/final.md" status complete
+marlin attr set "~/marlin_demo/Reports/*.pdf" reviewed yes
```
---
-### 4 Play with search
+## 5 Play with search / exec hooks
```bash
-# Find all TODOs (in any file)
marlin search TODO
-
-# All markdown under your “project/md” tag
marlin search tag:project/md
-
-# All files tagged “logs/app” containing ERROR
marlin search "tag:logs/app AND ERROR"
-
-# Only your completed Beta deliverable
marlin search "attr:status=complete"
-
-# Only reviewed PDFs
marlin search "attr:reviewed=yes AND pdf"
-
-# Open every reviewed report
marlin search "attr:reviewed=yes" --exec 'xdg-open {}'
```
---
-### 5 Try JSON output & verbose mode
+## 6 JSON output & verbose mode
```bash
marlin --format=json attr ls ~/marlin_demo/Projects/Beta/final.md
-marlin --verbose scan ~/marlin_demo
+marlin --verbose scan ~/marlin_demo # re-scan to see debug logs
```
---
-### 6 Snapshot & restore
+## 7 Snapshot & restore
```bash
-# Snapshot
snap=$(marlin backup | awk '{print $NF}')
-
-# Delete your DB to simulate data loss
-rm ~/.local/share/marlin/index.db
-
-# Bring it back
+rm ~/.local/share/marlin/index.db # simulate disaster
marlin restore "$snap"
-
-# Confirm you still see “TODO”
-marlin search TODO
+marlin search TODO # should still work
```
---
-### 7 Test linking functionality
+## 8 Linking demo
```bash
-# Create two demo files
touch ~/marlin_demo/foo.txt ~/marlin_demo/bar.txt
+marlin scan ~/marlin_demo # index the new files
-# Re-scan to index new files
-marlin scan ~/marlin_demo
-
-# Link foo.txt → bar.txt
foo=~/marlin_demo/foo.txt
bar=~/marlin_demo/bar.txt
-marlin link add "$foo" "$bar"
-# List outgoing links for foo.txt
-marlin link list "$foo"
-
-# List incoming links (backlinks) to bar.txt
-marlin link backlinks "$bar"
+marlin link add "$foo" "$bar" # create link
+marlin link list "$foo" # outgoing links from foo
+marlin link backlinks "$bar" # incoming links to bar
```
---
-That gives you:
+### Recap
-* **wide folder structures** (Projects, Logs, Reports, Scripts, Media)
-* **hierarchical tags** you can mix and match
-* **key-value attributes** to flag state & review
-* **FTS5 queries** with AND/OR/NOT
-* **`--exec` hooks** to trigger external commands
-* **JSON output** for programmatic gluing
-* **backups & restores** to guard your data
-* **file-to-file links** for graph relationships
+* `cargo build --release` + `sudo install …` is still the build path.
+* **`cd` to the folder you want indexed and run `marlin init`** — first scan happens automatically.
+* Subsequent scans (`marlin scan …`) are only needed for *new* directories you add later.
+* No more “forcing reapplication of migration 4” banner and the unused-import warnings are gone.
-Have fun playing around!
+Happy organising!
diff --git a/src/db/migrations/0004_fix_hierarchical_tags_fts.sql b/src/db/migrations/0004_fix_hierarchical_tags_fts.sql
new file mode 100644
index 0000000..273079e
--- /dev/null
+++ b/src/db/migrations/0004_fix_hierarchical_tags_fts.sql
@@ -0,0 +1,289 @@
+-- src/db/migrations/0004_fix_hierarchical_tags_fts.sql
+PRAGMA foreign_keys = ON;
+PRAGMA journal_mode = WAL;
+
+-- Force drop all FTS triggers to ensure they're recreated even if migration is already recorded
+DROP TRIGGER IF EXISTS files_fts_ai_file;
+DROP TRIGGER IF EXISTS files_fts_au_file;
+DROP TRIGGER IF EXISTS files_fts_ad_file;
+DROP TRIGGER IF EXISTS file_tags_fts_ai;
+DROP TRIGGER IF EXISTS file_tags_fts_ad;
+DROP TRIGGER IF EXISTS attributes_fts_ai;
+DROP TRIGGER IF EXISTS attributes_fts_au;
+DROP TRIGGER IF EXISTS attributes_fts_ad;
+
+-- Create a new trigger for file insertion that uses recursive CTE for full tag paths
+CREATE TRIGGER files_fts_ai_file
+AFTER INSERT ON files
+BEGIN
+ INSERT INTO files_fts(rowid, path, tags_text, attrs_text)
+ VALUES (
+ NEW.id,
+ NEW.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path AS tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = NEW.id
+
+ UNION
+
+ SELECT t.name AS tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = NEW.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = NEW.id)
+ );
+END;
+
+-- Recreate the file path update trigger
+CREATE TRIGGER files_fts_au_file
+AFTER UPDATE OF path ON files
+BEGIN
+ UPDATE files_fts
+ SET path = NEW.path
+ WHERE rowid = NEW.id;
+END;
+
+-- Recreate the file deletion trigger
+CREATE TRIGGER files_fts_ad_file
+AFTER DELETE ON files
+BEGIN
+ DELETE FROM files_fts WHERE rowid = OLD.id;
+END;
+
+-- Create new trigger for tag insertion that uses recursive CTE for full tag paths
+CREATE TRIGGER file_tags_fts_ai
+AFTER INSERT ON file_tags
+BEGIN
+ INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
+ SELECT f.id, f.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path AS tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = f.id
+
+ UNION
+
+ SELECT t.name AS tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = f.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = f.id)
+ FROM files f
+ WHERE f.id = NEW.file_id;
+END;
+
+-- Create new trigger for tag deletion that uses recursive CTE for full tag paths
+CREATE TRIGGER file_tags_fts_ad
+AFTER DELETE ON file_tags
+BEGIN
+ INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
+ SELECT f.id, f.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path AS tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = f.id
+
+ UNION
+
+ SELECT t.name AS tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = f.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = f.id)
+ FROM files f
+ WHERE f.id = OLD.file_id;
+END;
+
+-- Create new triggers for attribute operations that use recursive CTE for full tag paths
+CREATE TRIGGER attributes_fts_ai
+AFTER INSERT ON attributes
+BEGIN
+ INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
+ SELECT f.id, f.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path AS tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = f.id
+
+ UNION
+
+ SELECT t.name AS tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = f.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = f.id)
+ FROM files f
+ WHERE f.id = NEW.file_id;
+END;
+
+CREATE TRIGGER attributes_fts_au
+AFTER UPDATE OF value ON attributes
+BEGIN
+ INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
+ SELECT f.id, f.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path AS tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = f.id
+
+ UNION
+
+ SELECT t.name AS tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = f.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = f.id)
+ FROM files f
+ WHERE f.id = NEW.file_id;
+END;
+
+CREATE TRIGGER attributes_fts_ad
+AFTER DELETE ON attributes
+BEGIN
+ INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
+ SELECT f.id, f.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path AS tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = f.id
+
+ UNION
+
+ SELECT t.name AS tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = f.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = f.id)
+ FROM files f
+ WHERE f.id = OLD.file_id;
+END;
+
+-- Update all existing FTS entries with the new tag-path format
+INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
+SELECT f.id, f.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path AS tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = f.id
+
+ UNION
+
+ SELECT t.name AS tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = f.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = f.id)
+FROM files f;
diff --git a/src/db/migrations/mod.rs b/src/db/migrations/mod.rs
new file mode 100644
index 0000000..2e00341
--- /dev/null
+++ b/src/db/migrations/mod.rs
@@ -0,0 +1,260 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+};
+
+use anyhow::{Context, Result};
+use chrono::Local;
+use rusqlite::{
+ backup::{Backup, StepResult},
+ params,
+ Connection,
+ OpenFlags,
+ OptionalExtension,
+};
+use tracing::{debug, info};
+
+/// Embed every numbered migration file here.
+const MIGRATIONS: &[(&str, &str)] = &[
+ ("0001_initial_schema.sql", include_str!("migrations/0001_initial_schema.sql")),
+ ("0002_update_fts_and_triggers.sql", include_str!("migrations/0002_update_fts_and_triggers.sql")),
+ ("0003_create_links_collections_views.sql", include_str!("migrations/0003_create_links_collections_views.sql")),
+ ("0004_fix_hierarchical_tags_fts.sql", include_str!("migrations/0004_fix_hierarchical_tags_fts.sql")),
+];
+
+/* ─── connection bootstrap ──────────────────────────────────────────── */
+
+pub fn open>(db_path: P) -> Result {
+ let db_path_ref = db_path.as_ref();
+ let mut conn = Connection::open(db_path_ref)
+ .with_context(|| format!("failed to open DB at {}", db_path_ref.display()))?;
+
+ conn.pragma_update(None, "journal_mode", "WAL")?;
+ conn.pragma_update(None, "foreign_keys", "ON")?;
+
+ // Apply migrations (drops & recreates all FTS triggers)
+ apply_migrations(&mut conn)?;
+
+ Ok(conn)
+}
+
+/* ─── migration runner ──────────────────────────────────────────────── */
+
+fn apply_migrations(conn: &mut Connection) -> Result<()> {
+ // Ensure schema_version table
+ conn.execute_batch(
+ "CREATE TABLE IF NOT EXISTS schema_version (
+ version INTEGER PRIMARY KEY,
+ applied_on TEXT NOT NULL
+ );",
+ )?;
+
+ // Legacy patch (ignore if exists)
+ let _ = conn.execute("ALTER TABLE schema_version ADD COLUMN applied_on TEXT", []);
+
+ let tx = conn.transaction()?;
+
+ for (fname, sql) in MIGRATIONS {
+ let version: i64 = fname
+ .split('_')
+ .next()
+ .and_then(|s| s.parse().ok())
+ .expect("migration filenames start with number");
+
+ let already: Option = tx
+ .query_row(
+ "SELECT version FROM schema_version WHERE version = ?1",
+ [version],
+ |r| r.get(0),
+ )
+ .optional()?;
+
+ if already.is_some() {
+ debug!("migration {} already applied", fname);
+ continue;
+ }
+
+ info!("applying migration {}", fname);
+ println!(
+ "\nSQL SCRIPT FOR MIGRATION: {}\nBEGIN SQL >>>\n{}\n<<< END SQL\n",
+ fname, sql
+ );
+
+ tx.execute_batch(sql)
+ .with_context(|| format!("could not apply migration {}", fname))?;
+
+ tx.execute(
+ "INSERT INTO schema_version (version, applied_on) VALUES (?1, ?2)",
+ params![version, Local::now().to_rfc3339()],
+ )?;
+ }
+
+ tx.commit()?;
+ Ok(())
+}
+
+/* ─── helpers ───────────────────────────────────────────────────────── */
+
+pub fn ensure_tag_path(conn: &Connection, path: &str) -> Result {
+ let mut parent: Option = None;
+ for segment in path.split('/').filter(|s| !s.is_empty()) {
+ conn.execute(
+ "INSERT OR IGNORE INTO tags(name, parent_id) VALUES (?1, ?2)",
+ params![segment, parent],
+ )?;
+ let id: i64 = conn.query_row(
+ "SELECT id FROM tags WHERE name = ?1 AND (parent_id IS ?2 OR parent_id = ?2)",
+ params![segment, parent],
+ |row| row.get(0),
+ )?;
+ parent = Some(id);
+ }
+ parent.ok_or_else(|| anyhow::anyhow!("empty tag path"))
+}
+
+pub fn file_id(conn: &Connection, path: &str) -> Result {
+ conn.query_row("SELECT id FROM files WHERE path = ?1", [path], |r| r.get(0))
+ .map_err(|_| anyhow::anyhow!("file not indexed: {}", path))
+}
+
+pub fn upsert_attr(conn: &Connection, file_id: i64, key: &str, value: &str) -> Result<()> {
+ conn.execute(
+ r#"
+ INSERT INTO attributes(file_id, key, value)
+ VALUES (?1, ?2, ?3)
+ ON CONFLICT(file_id, key) DO UPDATE SET value = excluded.value
+ "#,
+ params![file_id, key, value],
+ )?;
+ Ok(())
+}
+
+/// Add a typed link from one file to another.
+pub fn add_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, link_type: Option<&str>) -> Result<()> {
+ conn.execute(
+ "INSERT INTO links(src_file_id, dst_file_id, type)
+ VALUES (?1, ?2, ?3)
+ ON CONFLICT(src_file_id, dst_file_id, type) DO NOTHING",
+ params![src_file_id, dst_file_id, link_type],
+ )?;
+ Ok(())
+}
+
+/// Remove a typed link between two files.
+pub fn remove_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, link_type: Option<&str>) -> Result<()> {
+ conn.execute(
+ "DELETE FROM links
+ WHERE src_file_id = ?1
+ AND dst_file_id = ?2
+ AND (type IS ?3 OR type = ?3)",
+ params![src_file_id, dst_file_id, link_type],
+ )?;
+ Ok(())
+}
+
+/// List all links for files matching a glob-style pattern.
+/// `direction` may be `"in"` (incoming), `"out"` (outgoing), or `None` (outgoing).
+pub fn list_links(
+ conn: &Connection,
+ pattern: &str,
+ direction: Option<&str>,
+ link_type: Option<&str>,
+) -> Result)>> {
+ // Convert glob '*' → SQL LIKE '%'
+ let like_pattern = pattern.replace('*', "%");
+
+ // Find matching files
+ let mut stmt = conn.prepare("SELECT id, path FROM files WHERE path LIKE ?1")?;
+ let mut rows = stmt.query(params![like_pattern])?;
+ let mut files = Vec::new();
+ while let Some(row) = rows.next()? {
+ let id: i64 = row.get(0)?;
+ let path: String = row.get(1)?;
+ files.push((id, path));
+ }
+
+ let mut results = Vec::new();
+ for (file_id, file_path) in files {
+ let (src_col, dst_col) = match direction {
+ Some("in") => ("dst_file_id", "src_file_id"),
+ _ => ("src_file_id", "dst_file_id"),
+ };
+
+ let sql = format!(
+ "SELECT f2.path, l.type
+ FROM links l
+ JOIN files f2 ON f2.id = l.{dst}
+ WHERE l.{src} = ?1
+ AND (?2 IS NULL OR l.type = ?2)",
+ src = src_col,
+ dst = dst_col,
+ );
+
+ let mut stmt2 = conn.prepare(&sql)?;
+ let mut rows2 = stmt2.query(params![file_id, link_type])?;
+ while let Some(r2) = rows2.next()? {
+ let other: String = r2.get(0)?;
+ let typ: Option = r2.get(1)?;
+ results.push((file_path.clone(), other, typ));
+ }
+ }
+
+ Ok(results)
+}
+
+/// Find all incoming links (backlinks) to files matching a pattern.
+pub fn find_backlinks(conn: &Connection, pattern: &str) -> Result)>> {
+ let like_pattern = pattern.replace('*', "%");
+ let mut stmt = conn.prepare(
+ "SELECT f1.path, l.type
+ FROM links l
+ JOIN files f1 ON f1.id = l.src_file_id
+ JOIN files f2 ON f2.id = l.dst_file_id
+ WHERE f2.path LIKE ?1",
+ )?;
+ let mut rows = stmt.query(params![like_pattern])?;
+ let mut result = Vec::new();
+ while let Some(row) = rows.next()? {
+ let src_path: String = row.get(0)?;
+ let typ: Option = row.get(1)?;
+ result.push((src_path, typ));
+ }
+ Ok(result)
+}
+
+/* ─── backup / restore ──────────────────────────────────────────────── */
+
+pub fn backup>(db_path: P) -> Result {
+ let src = db_path.as_ref();
+ let dir = src
+ .parent()
+ .ok_or_else(|| anyhow::anyhow!("invalid DB path: {}", src.display()))?
+ .join("backups");
+ fs::create_dir_all(&dir)?;
+
+ let stamp = Local::now().format("%Y-%m-%d_%H-%M-%S");
+ let dst = dir.join(format!("backup_{stamp}.db"));
+
+ let src_conn = Connection::open_with_flags(src, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
+ let mut dst_conn = Connection::open(&dst)?;
+
+ let bk = Backup::new(&src_conn, &mut dst_conn)?;
+ while let StepResult::More = bk.step(100)? {}
+ Ok(dst)
+}
+
+pub fn restore>(backup_path: P, live_db_path: P) -> Result<()> {
+ fs::copy(&backup_path, &live_db_path)?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn migrations_apply_in_memory() {
+ // Opening an in-memory database should apply every migration without error.
+ let _conn = open(":memory:").expect("in-memory migrations should run cleanly");
+ }
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 85c69bd..575f591 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -12,15 +12,20 @@ use rusqlite::{
OpenFlags,
OptionalExtension,
};
-use tracing::{debug, info};
+use tracing::{debug, info, warn};
/// Embed every numbered migration file here.
const MIGRATIONS: &[(&str, &str)] = &[
("0001_initial_schema.sql", include_str!("migrations/0001_initial_schema.sql")),
("0002_update_fts_and_triggers.sql", include_str!("migrations/0002_update_fts_and_triggers.sql")),
("0003_create_links_collections_views.sql", include_str!("migrations/0003_create_links_collections_views.sql")),
+ ("0004_fix_hierarchical_tags_fts.sql", include_str!("migrations/0004_fix_hierarchical_tags_fts.sql")),
];
+/// Migrations that should *always* be re-run.
+/// We no longer need to force any, so leave it empty.
+const FORCE_APPLY_MIGRATIONS: &[i64] = &[]; // <- was &[4]
+
/* ─── connection bootstrap ──────────────────────────────────────────── */
pub fn open>(db_path: P) -> Result {
@@ -51,6 +56,14 @@ fn apply_migrations(conn: &mut Connection) -> Result<()> {
// Legacy patch (ignore if exists)
let _ = conn.execute("ALTER TABLE schema_version ADD COLUMN applied_on TEXT", []);
+ // Force-remove migrations that should always be applied
+ for &version in FORCE_APPLY_MIGRATIONS {
+ let rows_affected = conn.execute("DELETE FROM schema_version WHERE version = ?1", [version])?;
+ if rows_affected > 0 {
+ info!("Forcing reapplication of migration {}", version);
+ }
+ }
+
let tx = conn.transaction()?;
for (fname, sql) in MIGRATIONS {
@@ -89,6 +102,37 @@ fn apply_migrations(conn: &mut Connection) -> Result<()> {
}
tx.commit()?;
+
+ // Verify that all migrations have been applied
+ let mut missing_migrations = Vec::new();
+ for (fname, _) in MIGRATIONS {
+ let version: i64 = fname
+ .split('_')
+ .next()
+ .and_then(|s| s.parse().ok())
+ .expect("migration filenames start with number");
+
+ let exists: bool = conn
+ .query_row(
+ "SELECT 1 FROM schema_version WHERE version = ?1",
+ [version],
+ |_| Ok(true),
+ )
+ .optional()?
+ .unwrap_or(false);
+
+ if !exists {
+ missing_migrations.push(version);
+ }
+ }
+
+ if !missing_migrations.is_empty() {
+ warn!(
+ "The following migrations were not applied: {:?}. This may indicate a problem with the migration system.",
+ missing_migrations
+ );
+ }
+
Ok(())
}
diff --git a/src/main.rs b/src/main.rs
index 897bc57..f9809bc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,36 +6,39 @@ mod logging;
mod scan;
use anyhow::{Context, Result};
-use clap::{Parser, Subcommand, CommandFactory};
-use clap_complete::{generate, Shell};
+use clap::{Parser, CommandFactory};
+use clap_complete::generate;
use glob::Pattern;
-use rusqlite::{params, OptionalExtension};
+use rusqlite::params;
use shellexpand;
use shlex;
use std::{env, io, path::PathBuf, process::Command};
use tracing::{debug, error, info};
use walkdir::WalkDir;
-use cli::{Cli, Commands, Format};
+use cli::{Cli, Commands};
fn main() -> Result<()> {
- // Parse CLI and bootstrap logging
- let mut args = Cli::parse();
+ /* ── CLI parsing & logging ────────────────────────────────────── */
+
+ let args = Cli::parse();
if args.verbose {
env::set_var("RUST_LOG", "debug");
}
logging::init();
- // If the user asked for completions, generate and exit immediately.
+ /* ── shell-completion shortcut ───────────────────────────────── */
+
if let Commands::Completions { shell } = &args.command {
let mut cmd = Cli::command();
generate(*shell, &mut cmd, "marlin", &mut io::stdout());
return Ok(());
}
- let cfg = config::Config::load()?;
+ /* ── config & automatic backup ───────────────────────────────── */
+
+ let cfg = config::Config::load()?; // DB path etc.
- // Backup before any non-init, non-backup/restore command
match &args.command {
Commands::Init | Commands::Backup | Commands::Restore { .. } => {}
_ => match db::backup(&cfg.db_path) {
@@ -44,18 +47,29 @@ fn main() -> Result<()> {
},
}
- // Open (and migrate) the DB
+ /* ── open DB (runs migrations if needed) ─────────────────────── */
+
let mut conn = db::open(&cfg.db_path)?;
- // Dispatch all commands
+ /* ── command dispatch ────────────────────────────────────────── */
+
match args.command {
- Commands::Completions { .. } => {}
+ Commands::Completions { .. } => {} // already handled
+
Commands::Init => {
info!("Database initialised at {}", cfg.db_path.display());
+
+ // Always (re-)scan the current directory so even an existing DB
+ // picks up newly created files in the working tree.
+ let cwd = env::current_dir().context("getting current directory")?;
+ let count = scan::scan_directory(&mut conn, &cwd)
+ .context("initial scan failed")?;
+ info!("Initial scan complete – indexed/updated {} files", count);
}
+
Commands::Scan { paths } => {
let scan_paths = if paths.is_empty() {
- vec![std::env::current_dir()?]
+ vec![env::current_dir()?]
} else {
paths
};
@@ -63,26 +77,21 @@ fn main() -> Result<()> {
scan::scan_directory(&mut conn, &p)?;
}
}
- Commands::Tag { pattern, tag_path } => {
- apply_tag(&conn, &pattern, &tag_path)?;
- }
- Commands::Attr { action } => match action {
+
+ Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?,
+ Commands::Attr { action } => match action {
cli::AttrCmd::Set { pattern, key, value } => {
- attr_set(&conn, &pattern, &key, &value)?;
- }
- cli::AttrCmd::Ls { path } => {
- attr_ls(&conn, &path)?;
+ attr_set(&conn, &pattern, &key, &value)?
}
+ cli::AttrCmd::Ls { path } => attr_ls(&conn, &path)?,
},
- Commands::Search { query, exec } => {
- run_search(&conn, &query, exec)?;
- }
- Commands::Backup => {
+ Commands::Search { query, exec } => run_search(&conn, &query, exec)?,
+ Commands::Backup => {
let path = db::backup(&cfg.db_path)?;
println!("Backup created: {}", path.display());
}
- Commands::Restore { backup_path } => {
- drop(conn);
+ Commands::Restore { backup_path } => {
+ drop(conn); // close handle before overwrite
db::restore(&backup_path, &cfg.db_path)
.with_context(|| format!("Failed to restore DB from {}", backup_path.display()))?;
println!("Restored DB from {}", backup_path.display());
@@ -90,20 +99,24 @@ fn main() -> Result<()> {
.with_context(|| format!("Could not open restored DB at {}", cfg.db_path.display()))?;
info!("Successfully opened restored database.");
}
- Commands::Link(link_cmd) => cli::link::run(&link_cmd, &mut conn, args.format)?,
- Commands::Coll(coll_cmd) => cli::coll::run(&coll_cmd, &mut conn, args.format)?,
- Commands::View(view_cmd) => cli::view::run(&view_cmd, &mut conn, args.format)?,
+
+ /* passthrough sub-modules that still stub out their logic */
+ Commands::Link(link_cmd) => cli::link::run(&link_cmd, &mut conn, args.format)?,
+ Commands::Coll(coll_cmd) => cli::coll::run(&coll_cmd, &mut conn, args.format)?,
+ Commands::View(view_cmd) => cli::view::run(&view_cmd, &mut conn, args.format)?,
Commands::State(state_cmd) => cli::state::run(&state_cmd, &mut conn, args.format)?,
- Commands::Task(task_cmd) => cli::task::run(&task_cmd, &mut conn, args.format)?,
- Commands::Remind(rm_cmd) => cli::remind::run(&rm_cmd, &mut conn, args.format)?,
+ Commands::Task(task_cmd) => cli::task::run(&task_cmd, &mut conn, args.format)?,
+ Commands::Remind(rm_cmd) => cli::remind::run(&rm_cmd, &mut conn, args.format)?,
Commands::Annotate(an_cmd) => cli::annotate::run(&an_cmd, &mut conn, args.format)?,
- Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
- Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
+ Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
+ Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
}
Ok(())
}
+/* ───────────────────────── helpers & sub-routines ────────────────── */
+
/// Apply a hierarchical tag to all files matching the glob pattern.
fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> {
// ensure_tag_path returns the deepest-node ID
@@ -114,13 +127,15 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
let mut current = Some(leaf_tag_id);
while let Some(id) = current {
tag_ids.push(id);
- current = conn
- .query_row(
- "SELECT parent_id FROM tags WHERE id = ?1",
- params![id],
- |r| r.get::<_, Option>(0),
- )
- .optional()?;
+ current = match conn.query_row(
+ "SELECT parent_id FROM tags WHERE id = ?1",
+ params![id],
+ |r| r.get::<_, Option>(0),
+ ) {
+ Ok(parent_id) => parent_id,
+ Err(rusqlite::Error::QueryReturnedNoRows) => None,
+ Err(e) => return Err(e.into()),
+ };
}
let expanded = shellexpand::tilde(pattern).into_owned();
@@ -128,9 +143,10 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
.with_context(|| format!("Invalid glob pattern `{}`", expanded))?;
let root = determine_scan_root(&expanded);
- let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path = ?1")?;
- let mut stmt_insert =
- conn.prepare("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?;
+ let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path = ?1")?;
+ let mut stmt_insert = conn.prepare(
+ "INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
+ )?;
let mut count = 0;
for entry in WalkDir::new(&root)
@@ -148,7 +164,6 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
match stmt_file.query_row(params![path_str.as_ref()], |r| r.get::<_, i64>(0)) {
Ok(file_id) => {
- // insert every segment tag
let mut newly = false;
for &tid in &tag_ids {
if stmt_insert.execute(params![file_id, tid])? > 0 {
@@ -236,7 +251,8 @@ fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> {
let mut stmt = conn.prepare(
"SELECT key, value FROM attributes WHERE file_id = ?1 ORDER BY key",
)?;
- for row in stmt.query_map([file_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? {
+ for row in stmt.query_map([file_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
+ {
let (k, v) = row?;
println!("{k} = {v}");
}
@@ -244,8 +260,8 @@ fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> {
}
/// Build and run an FTS5 search query, with optional exec.
-/// “tag:foo/bar” → tags_text:foo AND tags_text:bar
-/// “attr:key=value” → attrs_text:key=value
+/// “tag:foo/bar” → tags_text:foo AND tags_text:bar
+/// “attr:k=v” → attrs_text:k AND attrs_text:v
fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option) -> Result<()> {
let mut fts_query_parts = Vec::new();
let parts = shlex::split(raw_query).unwrap_or_else(|| vec![raw_query.to_string()]);
@@ -261,8 +277,15 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option
fts_query_parts.push(format!("tags_text:{}", escape_fts_query_term(seg)));
}
} else if let Some(attr) = part.strip_prefix("attr:") {
- // keep whole key=value together
- fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(attr)));
+ let mut kv = attr.splitn(2, '=');
+ let key = kv.next().unwrap();
+ if let Some(value) = kv.next() {
+ fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(key)));
+ fts_query_parts.push("AND".into());
+ fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(value)));
+ } else {
+ fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(key)));
+ }
} else {
fts_query_parts.push(escape_fts_query_term(&part));
}
@@ -347,7 +370,11 @@ fn determine_scan_root(pattern: &str) -> PathBuf {
let wildcard_pos = pattern.find(|c| c == '*' || c == '?' || c == '[').unwrap_or(pattern.len());
let prefix = &pattern[..wildcard_pos];
let mut root = PathBuf::from(prefix);
- while root.as_os_str().to_string_lossy().contains(|c| ['*', '?', '['].contains(&c)) {
+ while root
+ .as_os_str()
+ .to_string_lossy()
+ .contains(|c| ['*', '?', '['].contains(&c))
+ {
if let Some(parent) = root.parent() {
root = parent.to_path_buf();
} else {
diff --git a/src/test_hierarchical_tags.rs b/src/test_hierarchical_tags.rs
new file mode 100644
index 0000000..5c36911
--- /dev/null
+++ b/src/test_hierarchical_tags.rs
@@ -0,0 +1,240 @@
+// Test script to validate hierarchical tag FTS fix
+// This script demonstrates how the fix works with a simple test case
+
+use rusqlite::{Connection, params};
+use std::path::Path;
+use std::fs;
+use anyhow::Result;
+
+fn main() -> Result<()> {
+ // Create a test database in a temporary location
+ let db_path = Path::new("/tmp/marlin_test.db");
+ if db_path.exists() {
+ fs::remove_file(db_path)?;
+ }
+
+ println!("Creating test database at {:?}", db_path);
+
+ // Initialize database with our schema and migrations
+ let conn = Connection::open(db_path)?;
+
+ // Apply schema (simplified version of what's in the migrations)
+ println!("Applying schema...");
+ conn.execute_batch(
+ "PRAGMA foreign_keys = ON;
+ PRAGMA journal_mode = WAL;
+
+ CREATE TABLE files (
+ id INTEGER PRIMARY KEY,
+ path TEXT NOT NULL UNIQUE,
+ size INTEGER,
+ mtime INTEGER,
+ hash TEXT
+ );
+
+ CREATE TABLE tags (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ parent_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
+ canonical_id INTEGER REFERENCES tags(id) ON DELETE SET NULL,
+ UNIQUE(name, parent_id)
+ );
+
+ CREATE TABLE file_tags (
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
+ tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ PRIMARY KEY(file_id, tag_id)
+ );
+
+ CREATE TABLE attributes (
+ id INTEGER PRIMARY KEY,
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
+ key TEXT NOT NULL,
+ value TEXT,
+ UNIQUE(file_id, key)
+ );
+
+ CREATE VIRTUAL TABLE files_fts
+ USING fts5(
+ path,
+ tags_text,
+ attrs_text,
+ content='',
+ tokenize=\"unicode61 remove_diacritics 2\"
+ );"
+ )?;
+
+ // Apply our fixed triggers
+ println!("Applying fixed FTS triggers...");
+ conn.execute_batch(
+ "CREATE TRIGGER files_fts_ai_file
+ AFTER INSERT ON files
+ BEGIN
+ INSERT INTO files_fts(rowid, path, tags_text, attrs_text)
+ VALUES (
+ NEW.id,
+ NEW.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path as tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = NEW.id
+
+ UNION
+
+ SELECT t.name as tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = NEW.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = NEW.id)
+ );
+ END;
+
+ CREATE TRIGGER file_tags_fts_ai
+ AFTER INSERT ON file_tags
+ BEGIN
+ INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
+ SELECT f.id, f.path,
+ (SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
+ FROM (
+ WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
+ SELECT t.id, t.name, t.parent_id, t.name
+ FROM tags t
+ WHERE t.parent_id IS NULL
+
+ UNION ALL
+
+ SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
+ FROM tags t
+ JOIN tag_tree tt ON t.parent_id = tt.id
+ )
+ SELECT DISTINCT tag_tree.path as tag_path
+ FROM file_tags ft
+ JOIN tag_tree ON ft.tag_id = tag_tree.id
+ WHERE ft.file_id = f.id
+
+ UNION
+
+ SELECT t.name as tag_path
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ WHERE ft.file_id = f.id AND t.parent_id IS NULL
+ )),
+ (SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
+ FROM attributes a
+ WHERE a.file_id = f.id)
+ FROM files f
+ WHERE f.id = NEW.file_id;
+ END;"
+ )?;
+
+ // Insert test data
+ println!("Inserting test data...");
+
+ // Insert a test file
+ conn.execute(
+ "INSERT INTO files (id, path) VALUES (1, '/test/document.md')",
+ [],
+ )?;
+
+ // Create hierarchical tags: project/md
+ println!("Creating hierarchical tags: project/md");
+
+ // Insert parent tag 'project'
+ conn.execute(
+ "INSERT INTO tags (id, name, parent_id) VALUES (1, 'project', NULL)",
+ [],
+ )?;
+
+ // Insert child tag 'md' under 'project'
+ conn.execute(
+ "INSERT INTO tags (id, name, parent_id) VALUES (2, 'md', 1)",
+ [],
+ )?;
+
+ // Tag the file with the 'md' tag (which is under 'project')
+ conn.execute(
+ "INSERT INTO file_tags (file_id, tag_id) VALUES (1, 2)",
+ [],
+ )?;
+
+ // Check what's in the FTS index
+ println!("\nChecking FTS index content:");
+ let mut stmt = conn.prepare("SELECT rowid, path, tags_text, attrs_text FROM files_fts")?;
+ let rows = stmt.query_map([], |row| {
+ Ok((
+ row.get::<_, i64>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ ))
+ })?;
+
+ for row in rows {
+ let (id, path, tags, attrs) = row?;
+ println!("ID: {}, Path: {}, Tags: '{}', Attrs: '{}'", id, path, tags, attrs);
+ }
+
+ // Test searching for the full hierarchical tag path
+ println!("\nTesting search for 'project/md':");
+ let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'project/md'")?;
+ let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
+
+ let mut found = false;
+ for row in rows {
+ found = true;
+ println!("Found file: {}", row?);
+ }
+
+ if !found {
+ println!("No files found with tag 'project/md'");
+ }
+
+ // Test searching for just the parent tag
+ println!("\nTesting search for just 'project':");
+ let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'project'")?;
+ let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
+
+ let mut found = false;
+ for row in rows {
+ found = true;
+ println!("Found file: {}", row?);
+ }
+
+ if !found {
+ println!("No files found with tag 'project'");
+ }
+
+ // Test searching for just the child tag
+ println!("\nTesting search for just 'md':");
+ let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'md'")?;
+ let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
+
+ let mut found = false;
+ for row in rows {
+ found = true;
+ println!("Found file: {}", row?);
+ }
+
+ if !found {
+ println!("No files found with tag 'md'");
+ }
+
+ println!("\nTest completed successfully!");
+ Ok(())
+}
diff --git a/target/release/marlin b/target/release/marlin
index e7ddccb14ce6d329c166562f9896cf52b85bb9ee..5694a6588edd74dc31a23125e69b16da659c5f15 100755
GIT binary patch
delta 2109375
zcmZ_Xf8Zqdd_VAaez7Ey2}hP(WWufFiafAbqR-M<9+ud;Offc!s)
z6#qBVasKZo4u5i4Q2x7K@8B}>)bf9oGXJ~x`Xfrme_y25E8Z^;wSVH31^B;>cNI~k
zYVXtkm3jx}R!F$-`9*l`zZZS-fDb;Hn`!^#qURp?lH)eN`i!zP|99J$OIF*ye9?Cg
zeD-not*r+)eu^H%_$e1X?Vy)z_{$$2`lp9q@s{(>{lup(y>Qpp-uUE=AG_;6b{%xi
zMt_dk=-ew69r2X1gN)o7=@JX_1glp2sqR?Xu`haJ*^X7=b?v^A3dxfmSyr&ALNCzX
zsBnioIpz4WohEtb2HlQpxSy}bdvt}C5y%}Kd*p6YH`FG#&fipa+#&Z+eoh`{#IRnPHttsuApu-
zQ+h&rdN}}#{MZ|`+vG>X9r6R=F8L>ZquXiu6uA9YI^dCi_Ezm}^56badx!kJcWV#G
ze~tDBa9@s}j3}fEkH$!ca(H-wAYu8hNx=p8|`#bEY0pojiWK-t9Jd59P;&7yZi;
z1h3aAil~AIpOB~5J+_=#Odi~+kMWe;`sL%x^2wT)=P!7#QwC;hfdYEB>xNQt?;@R{
zIe9eDo{^`g>kKW(t+O0`{?*UTB?!+ywj5B6eV6XQVt<#OK%G2zu`X|uTc6PbYW&Zjsw?PaN}C)>hqdTLR_$m2%#pJMOUmLa${&p1t@P
zWz7M3<0aZda=(qhkOComL_UR&$rtd5y!ley@r1mm-B&RMCV0|K$-e^6$fGx6bCG**
z(yo4CF0uVe?5)!C{LAr+eyl54R3Uzd_By%!OuJ0AO`hJZ%Qwg~wBxLMvHsR|x~ueeJ9Je}+In6&COT`91KE{J=YO$3yZOd}MgO{trW7Yy_0r
z2#?5*hfl~)gvaC#d`kX=!i)WvP~gd^Fe85=JSBJGbMj}xGxF!a7rB@FU!H(%1k^9h
z-TP8_jr?!n7I_a|Cx0W{Ub#Mh=ONHo1M3hGzp
zDL4UMBR>glkv|?@C;t<;U3jto<@lY7K!Ylr4tL0(2XB(U1n!dm5>L$5nwRHq3o3YP
zf#UqV2Hqxr1H40iF1$C5N
z0%NLhH9R8!ANYj)yYQHN2A_)S`73oD0*M65=butPg3rirf~VxSz~|(5!ZULJUIZ2t
zxF4Pe>Sps4JOo}Nufr|!bAG2EICb((y1cJ!3Y>_B8svWhcgXL)OLx#D|1*?#$sY)B
zm7eEcj^8s7@Tfu;GiZ}P7v($TFNSx?UkUH6d$InnL!iGN$h{8_$ln4VkPqM?`Du7B
zXh?ohUcTIaBMNLsLu2xPgGb~$;S=)9;W7DD@afHT|Nj~S2~|koGxF=;Dfy4#bMjl@
z+0t?TZbx9T4CwQB5Y}GpF;Bta@EZBiaEttSc%A$t9|4;JO?ZR+sc?t<40x0Lxp0^K
z<8Z%4ffpg*k$)E6CjT3Fhy3s1UGi=4p7z!K|D6c*sltW0Pz2;pgAd5xhjv2p55b3q
z=j;Db1V%S67nCzXXH1-Q}R3EbMkxP
zS?=ZjmnYz%c$6-vLLIJdF;Bs9@EZ9^aEttp;PsX3^Y>>6*sDN!{?33m$e#yy$j^c|
z$^QoKlE3OM-2Yn?cmpbUG}T4DDVVSSdc#ju6}Kvf)>0+{%p8Keipo5c(MQG_-#SJrV4)tZ;-zZ?vlS6Z)UZ~
zABH!ryfrV+-<#1;do56$zX0ALf8x*em8(ns4wUbaKMm#kvV<&&D
zxJ`bFc3(LZcq%G%$Y1<_dd5BSx5@j4B&%!`m@|WRz#I0ND{=WqkJgRU2c6*0>`W@YImwYSQ>634R
z2TRBK`v3xiWk8?5e}#wSm&1qT&&NPVKQ~7_zpUlkOzN&%|(6#%1_BN
zctZYwdvw44i~`3aFeg6^1I@^{V8#pbCtagEP`8AH0Y75JM=fH`s5DE2jo`a#r_*mAp3^?
z_E<>nqM?}FgHOpj@Pu3q^|$3_xtIH2o`C8I9Z0D{@0EH$b8-)!k-PB5Z_TyO@Hqfy
zb{af%5#NJM{;QTyhWIBDc_vNAAJfAIbOJcSQ@3aIz!4-|ytL3n&QpdopRjz{Fy^L58#@^ph;wiCgZ_kU-zt}vkr-otf;
znA~mY%uUJT=je9kIycwCm&8FSL(542QBi^DY_j`9M3=1L&t3il-2VQqC$tD|Dc~9?0E!u0Lb`w?3xZ
ziOD0p+$T%N`3q0bGng#{WyX|)k5Z=O)_e4xn3Jn(bmlU0`vg6p1$pKNdS>cQa}znJ
z5Rm^2FG54|+u#xT0r)C-O8y|YpHko$1Qz6il^i2R@66Y{h0CRAd0zW&=$VQvJJ`XpTa&Yba8@H%+{cgTMMZ;@w(7yGY6
zfdlW&1Nomm`B%_TNd6F%ACn&kkI7$wc4oPk`(K`bC!<0}6`lsK-DPItxp15O)$k_y
zTj1Wx_4#`j0^L=h%)rI)fc#~6H)}{fLivdNGWe7{zSq~OPATxesIVYU;nv+|CVm5N
zkpB+ulD_~~y|zz*jW_85^~gVi_jU&4hbaA3^N9RN_=J2TJb^Fo{|yA@RN={Rwa=XK
zneaOK-@+a8*TY+;`|9lobj*PIAiPh$10Is^f{)2#cuamv`G!Wm|7H~UCMsm)*THM|
zn3?z~+$O&h-Xy;l?iF6_e>r}KAE4K=OBIfW2jqVUACmtWJSM*%-jtrMd3pYxg$l`9
zpg4c$27v}ucoyEGb;!rx*Bvy;YxwxCOD+R#iR=02pZ~!~JqeW0zx*`-
z18S3>3-6G>4R5V>$pe({k^7R;J_Tf?0r_w6^FRag^U*;_ekp!HX-JOteKn%Md(hCB
zTrw1qUxe}#@(;md^6l_x>3RO;_??I~OQ?eUMAMA?V`wNP|2TY3z7w9Ud$Im<{1)qh
z++_*Wz2+(SEE=kj|L&)HGg{;ml&_N)$GhBrHU-YORyWij{}zs!Lms1pCb>+^C6|e{
z?xp+xqvS0Xs_<2G&?bK*zW381{|3r;$+Y8MC3=KoeBA8i|?8h`)^8t=v9c#nRcz$SN()a|rZUiPm%1iV$C
zoKXaCllL&tF1dG@{su;m+^t}bW5Ym$b`IupM;Ldd+*X6B;+A}_;dze-v5JF>Ix}UP{_ob+&M#MC?g-C
z;{|yK6RRC$>>*!;56}%+WVewivSbZ%Su%%QFIm3-niQ}wQkOi&v1ySj
zl=sMG$=c+yWSzo`{V&H))~rVrWX<~IvSb0dEZKlumMmQJ^8Cq~4c7w2Zu2m+F?obz
z7Lm)6O~_@*V)A^+^!cAsK-MfFmt!^~kG_V@OP(C8_tu=;+N#TE2l>WsRI+9Zsvv8o
z4mQinlGVuV%k-@S~}#ilrH%gFG4MH|4IZr
z3MBA0`On}T@_q0w`GXI_%R2dEwfm}1fv2NFKz}3B`TY*o?M&CbSpTCDNY(?np9G(g{{=iHe?ELp{&ILm{-)f^
z{kNdN`KX`{F?aiRc#Zr&;THLq;dS!w!0khxqxZio!4DB=P=#CJ4*3Cx=#)0ekA%DA
zC%{`v*Zr&05b%}(eg4|;Hu-Dd9rCxsyW}5*_sGBCBhaV7weWy^4j+&oct4$qko@8B
zA^8*F{)hr+ATTC>2|OZy4SYiWdU#C!cKB5L>i!=hkWht>z-Q$4m+{OexBgZ8oZP_|
zB$?s)`oDjJ{smCA`?#5E(1*}
zd`|w1hv@;S`_uhj0`)vl{E=fR?vQ^J4YkM*I7)ZWA(!&~mFx2-fp8Tl2Ou7kkHSAI
z<5Th&o{*XJ+Ofc#Y`AHtXSf2lC03ZF)W3Hb~@C3g>L=$oF5D0#YF$|JX)7vyl8t@DaJRGa>&e%1;X~_P-pn-ykrf
z3VY#ma_K-l(9F<1C~uKV`No=;=kL&C^vs%Tfnpa*1&{pUDBmZKaLfX7X=g}&{4r0}
z=Rc-E{Ki9z{#8mI{e$*7xpbfoH8XKC25ON@`NpAi|35`CQBlDof0<;0T)jyTG$5Cb
zhvItvO1%*cjU`adf3*ZL`NvT{A^#jaCEo?l$bH#d>M%2PGSWKvRcNR|F6G?{Z|~oI
z)uX^G@Zu4WO9#UWkI9dGq~6pCxja|W()0Yw@skb~R6*Q&(Ec;jAb%)2ZjxvJrhD?%
zy;%R=*L0x09>`rf?vdYs4u<3-oSF%F08hv_c42W=Ps;
zu={w`aybBRlm9Pf(5>)3xpW+o{~GQ16AC1KeF2HdJKxZobV_cIw5Q}hLW`X*HhDf_eg0b%_y|VYCYOe~#fXu8-E;H+rUyqp$$iEIBl4C%=
z8dE?9G9kAfrSAbTxy*P0Ba(Ny&A+hLv>0nAO1I@?}Jsx+xqs&b$
z?N~?A{a;3EQ-wBC+8~#KI^@!UOD+TLE*NY$hmk#FSk_qeK<`TRT1GUM^c8dMy
zP(YsfF8KrlZIR28dE_$C4!I0;kbAlRhV{-lH1
zDo`Fj8L4`NnbHd}P>WpJvB~8WIOOw3`T9krNdXzDOD-LF48S%GSCURbTB2CfoAaK{r?J#R2^-mS{ky*WuP{B9RqEU%Rn8|{pBOpH3Q36
zzc#r%Si0mlVFG>f<8ezKk|$;PeE*FpATyqj%Rr{&4rZK?%Zz8_(*C^gV*ktWleJMB
z%_Wc-TjcU6waFzD4!LB)Tl4b#?Lq1~Yk}hY$w+(TGQfbmg-i^{B@-ce-o8HnBML|+
z67ux54=A4tGx94Rt1lW^h1ZTTGkU*s`7r7xX2y{%-z(a)v`A^Y-{U|d-Hp;u?0lYOC%eQ7y^3gSV56#K5tF)^}n;AL*?OVB*
z`(K`bbp&jxAOm#ByLi2ClE)Z`OD^quE7#{wmY}~1l;`iyF`xmtG&CfafsV-o3^XE-
zkMnhECKQkkrsOivjQp=LP_@ZSwX|cA%Rp_P0z-_nK`tqE$fX09Tn5@E--?0u;LH2}
z_YnxF!d>u?T)hpun*1R6g!~M6V!E#+FgFA0qo|-BW2RKf*U4{2`3Cvx@p*zK`QEa8
zzW-VjxCafj$$t;;R(PLW1~eeo9V;~~yx9M8{G{VCRk#-&Psr`J>m^9Y9}iE-rTk*e
z%k%dvRHz+qrdlf4J0_w45CO!nOlUv*LKppne4=)GQB=0q}yW)EO
z`HxzhpaU%ll-2W3K%wI{xeRDPZoOU)XiV@`IQ9Hq$K!|p%()0Yw+DCoNj4CLZ
z8F{E@pt|H4Iv9|5_UPXaI9~T+{Uz1WdLVbHFd^^Z?=d?za{JCdFAD_ZQhrDtAQK~U+ebrV3dlera>>Mm+{Mgd@(7uj
zlG`ZnClruOWaJ|}!>tp|weR398HYT?x%bEu45+Prb^j0Es8ije3f7hS*!0Pzd`Rx$
zxDLssosr@B`eP(&Yy|R0?_2eNB60_NWkTM;CXLAh_>{cNbg};u3V3hU9nZ|H~6l24Yi%_}jXn26->i?vP6+n(Uv^<=vH+{VUmYtyQ2r
zUJg>%A&-%XF1eHs$P-LpKptP?>k)+%kbw-zJMYltN95UOwU5aq6A`(EOiX+ZU~|Rf
z^5QgO|E}&hWyg!zg1m?F>JRi@S>FF61ZoJ#o{)i9Vy{kq5Y*TjUPP*U2NeO`c~$
zpMRGEqhIQdJLDc-M!V!vzDM4`-}~gU#K9lZ{olj!8&HK2UbRAUX=q3;Ydj)P@o1lj
z>-p!Y$L@_KP%KCOM=H$7v+v;ClS?~uav4ZQ?nihuE-2vP9IMBhdqQ@5joiaAw#X$D
zb#hOCL6ZOee+rE7Y;TgIg7U~a$V8i5+UbzXK)R*p`IqC@!?V3d6(XGbKDlHfAnzd)
z19HhkxbDUJC)gC@^+2)q$V5ag<)`Ep$|vN~&WyYmNV)$~3V6uGoIJyx$jBuV3vvgU
zP>#8WBoj4*HyT>RoM91qY|2K`!N8avSAa7xqi6ObnU_>tEC*+cem|WWNrxcKZB;+!K8M%c_q~wx`
zIk~ixXn{ztMj-#L6$a8Gml=5E4l>au
zmrQiX%XW(W*QJ1DqDLO#i_rnOOduqe@?&zzM3j5E|K$lN4Na(m3?wF(Oial`WFjG#
zOw3lU&!04ut^)e}VNcA-J9uKMKi>bIsF6!~o80~`?*9!6NG2R|8Ay{{Cg75Xcw)B5
zB@>=c0cogBE_)DL;fS@BgwXMpQu>8k5UFB669)ggn8MG&bE=l8UJr
zSU!3aa>>Mu+`)4qW5;X3g1s!C??3Aa<|dL%)X60iHn|L>K`xna$Sv%NX5q#Dm*Xdy
zaH)bc)FPKmcT~Z_V}jLxsUwpg4b$iI7|x8j{OEM&vSqF?kZ<{vT05GBF{S
zhGKHb#FRY1yWT0eOkn;5y8lZBb@KlAM2%b;vdCp1b#j@2Ew1yQe-9X&q9FnO{9B$5
zhg=$Jl1nCB@&uV^llwA*4h5t_pIr7tKrZbJ$YmfQIVRw%AqA|@>6_1pT=vA6Trv@n
zOC~1d))#d9ap`&f<@l-dbYMyqY}|$u@@%&*KO>K>(w>sLU)DZf_hS7==r~&s-GM{i!GN0N3K?~uNcaEj
zTe_hZRY;Lik39R5F5f1Xfp*Acpxve8{J9uuZyC_%FN62Vy>IG{19JC^+6Uw^(2#uO
zqk|y@tSfXwBXS4j$K)5UV(q8olG23S!`jctLku(}x8!R}?W_BL
z_%%J_j4C7;>4H4^Z(UyfiFpcSpf&OUr^zxrU;h9jts8;-kqo!VdtcWbH`p;yhun*G
z`6hWe&|?3&6bP~QEpi(J^2lR&n|w6Y9e2nbWUiZgx&P$}=-@c@s6si?e4u^u0FTmu
zyoUh|$P;+Da((_r|D#7bTm{PW=X^!`h&+Oi$wSO6BDe7Tn6RIR`+rOU2fK7i9(`30
zC?W4*Kr`~>YF$1hAIawRpJeXd_%i)D0Gm8KS2xrkm+}tz933{vGrU3J!k72|1YfhY
zxB@!%$X(p$+vM`zPKSK&yY)vd`lkEp+3(ST&&+)&j-(TYh$f9G~4#1M=lZH^`SC-5}3Dx}neioC3?wZk%GyVENe%
za&;+A2YDhBBVT@W;}p98%V#%wRN>zD=`|jadvC#S%OF?qDY=wS#P$61Z$bfP5-6X4
z`4ZrmrR4VM_}C74_Gz7oj9j7og4~bMLH$q7RL9TLGi#EENU2LMY_3Ns2wCKl}Y7*74q%(a(~;WWwRV>oSc8%yK&DG=e)YXQ0QDV@3@xqNeJ
zL@wngj6nWnIebB$?9`dC
zo?->NEpi6~^~f`1qD?ND=;U7Re|Z8*LtUyMndp%#
z+;9fuqib}ALUJiTUb#Mh@;%&W6)4Z2G&CWXfyC^{#FRXeZ%B}<&*1()qd*T0rR0){
zIeBoIp0WCKb5F#`M2%d^+dc*4lS&P8X~-d$fi%f2Ou!}2F
z6Ekufm(q-U`7xZri~TRhPbyfhxryZSJauwu$R?M8G|1)SJkFYz=TAP*(_9M_=TAP*
zi7t7FwI7gs-^Kg?AqAwukle%HN8~P+W=!s3w@2i%#1q#yQ!Nd}R6%Ao
zC6_NeQt}8ho|8*?^;Gn$pMUvX4;5+>D4&1%n^16zTn17nmkHS9j(mTC+?P~16p)6R
z|kjs*F$xnR0e&Zq_
zf6>L-{UHSs1S0ZNKAq%t_=j883I}7cr`@eOd?!Y=t)WYf-*MF6BFh=j$&&&DJvl`E%ieNFDj;Rz2e(xw=>Tgj{|=X+}OeFn_DJ*ncSn
z+ynG`z;p7TsF453$kk8}R6X6yTpMnYzYuQcUhaQ+0-le6Llr`}OMW5TBbSal~$oE}>A3Cp{Zl+EeYLdG<^b@d6
z-oZfo2=CKu%POY~l`T4pA)zIKN^DSwxIzW5CFTxL_*U3j{*d~__9CFEMi#*SeKL2eBEK^N>$Vc=h8jwrJLvrbGOg@$0
z2XF@6|7B(qsvsRq$z^6K`I#8#g8Xv0^-L^*p1)EOXh@)Z{;k}t@OFjw$lXhI=0@bc
zygH33@Spgx`3d>E(a@CqLimjQ68M}P?fYs$0qLOjm#bY`B;zA!s80Sb@CNzv=l;pZ
zDBmhQ&%YeM&mz#K3jYD`lFLB*d_xyA|H9@Lq)v
z4A0kJIv5#&{F;pcO~_k2^*|HyFT&^Kjmva-^;c#lE-t*-e{~AnfC>%rd*CklVV~9=
zc;r&POMW8C_j51zzdQk-g+NFZmYazDr6@lom)DdzxvYJ*a(({3@SjMLXQt>TxJ5pP
zH^~3$a@|go+{M?HE%NYkUsq^T;6gOitMEaEk1BjZ9=Q7Jh{UHr`Y0Wkv2W0xk_Qjd
zJ|~Y*J|mA&egR+J|2+iMbIc_O9;!R2k&n=UMV_I2ojiuyrsu!^AAyD$$p8Iq-LXTS
zAwx}ab(Ajel3Qq}MedZI?>~CKOo2DRC*=PKPso#xVXu(?
z2<0>VZb&**5JHa+Q}a_g_qb
zr=sJ8ywlSS&B>i}v}fem|I@x8x9-+ne?Hy+6ExI(zL`1?OXHHCi-GpYv$yCDLh=yp
zL`yH{pMM6Vr7KL9fpSl&03MT%;8XGxo{(E-==Nviv5yKV1yVfo=j1LL%E(8X^o!Gi
z+-_*Ezrf6B`dIzi;WsJJ!#kO6@))V>llNYszcmw(yEq*Ka{KSGG}>49|C_OvBdYM&
zEA_FNkbe%(gQUXe6|UOrnaI~)8mb$Cl{@5TUZn@rB3EzMGwYBi_=s0P?)+47%MFN1sJkNL7LAHbLQfBEp}
zh$@u3G+&G{xs;E{yZHNrTt4#|o1XvvZ>-tW3@C*eC*;!5jNCyxDY?veLH>`Y7n#WS
zU+rvj2|kV#+2rz7utRr?Ur|(enOt%OSZW5;{5AOD+x@g!t=kbcW+AmQMmO|Go=7S`Xx2|EBJ^L%t2(BbRmt?6>pz+Zn$4`@LCQuvVk4^zv;M6F2Cv6CYN7y+|a(d|Np_a^^BWTL1xw>KN;ma=nBG@yhl2i%=n21!fo=y;g0FPIuZfb
z45$rokNhZjhx`%n9{DlwfZQ%U-+v(m+LCJW#&tSF6LKk^ke`6^Df#V3A=8By`(KWq
zg9_>uW~!eEx5$^NCjTKWu@3nSaCgni^LGk5@YVvw`E%hN@-yH)@_UZbGYiNMscR3(
z^UUeR;MD=O6f*7S#>f8%
zP(g(cD?F<3X@#ey=lPf8Co@=31@8xX&8)7OiS6(P`Oo1l`S6Fjo%XsH>;I(x!=_jd
zv)7_IG9`q+`3nn-%W;9o_$>p>9Qmpu&e0
z9#!~s={SGVak>l?^H)lIQQ_9B_MeFcxpTclNwzy8{iT7|G`u8=rcOibMlwmq}x}$JQKzKm*e+w1nNa#&F_FamR)F$uUtOwL3eCUmB`Yg#Z<7@&uV^l6x2G0lDN-zHNBE{s}th
z7=iqA!Dy&UE(7V2%LMx5*=zM43drpT<^wJE-+%%sDum>ci6Ob{m5BVnOz)wX{9*8U
z?&bcMC!pNtGpeu?6>9%zuCaKNTwdi|_FMIUyp`+o_e=zWRY0G=H@W412jrKbd_->J
z5<4ZA^0mG>fuG!p`@cryl8$Tn02H@0_ALn3D(ajNEyWF25+e*#FDphncB2
znJN7`22>}v7uclaa{u?p--hztH80Pf1cJ3daqh&26&{hR=jfSD$-}p9D35VM{@~xq
z=YJ^h%HQdksW+RM_|tvb>lN-)c#HhjdvrVfH`D#!emFJ@Rd64neZYRS_K@7hJ0?SN
z4?Yst&p)N?jk=++1j>Zv|8tD?h&(_;6Y>ZildEHOJ1M#Ey+#M-6j1Pt`~|<)sjF=>
zm*$gjoBaOw>hewUo!X0k|HEtu0oASWpu&e09##0X^gRD^{G{WwqQauWt#kK3fkuV9
z>t3wCblhGKta-1(2Ngc5@X3C>+<(b_0>%EDSGao1{%2gTaHqmsZ=w6YWTHbAtPAue
z?UVQZNqa!+oYVMT>;g~t^>tMF{)`us`9wYQm>5VtG5S>axVckkoAPHAwTfc9a9
zM-@J;@U+4genr4~`~EZ0sBpK!+ZEn}FYo`7>OnAsSV)p`4$alOKw
z3U5_-XTP5Bzy5v$`HMt_k1IT`@L7dtg%|suk6-N_`_F`3;mr#7D!jYq<@u8(2-X6{
z>!0|r!lMeGR(N_}*XMt6-$42LYn{LUOf)Lot?+h*_s*wRCz;l=)!rBN4}naD2IUR(3>{6!b(fVCDV7Ck%T!R6Z5$xl5T86pqSjz=!#yX5aY
z{Av392NXEy2;ISu{0;C4`MK~Z`4j8<&F2~UTTp&}A>IGqiNK<~P_#6!aP9*tP6zr_A^N`dA$b6wkbCeMxpkdxXR&mgzY#`M|KM}V6w01(C%QtD
zT>V~qo7{!>$vw0`B+vZs>4qj02#?T@-WjX4+Tx{|-7{Pz4LFhUN@j^)P*0>lN-)c+2p7{S!3Q
zF#<|8j?x|UD?F_5afQeG^J4$a_7})KtMJ-Co0*V-+7;fca4+|A|I2QdhPo9Mf(jp2
zcvRujmFx2-9jB{6xm(2-6>eR!|4cM0+`W(E{@=b&053unKB(|fg-&zwO4~EZ8_mu?Hc5`MU
zRItczfHx}Kt?)Ma@eeNsn(x0J1=KV3qj5mq`Ah8~xsCEea=AT^$X{1>tkk6NV*ktW
zlR#2YVP4_tBWC6-
z&;RJYfuesksqm!2=M}ClH8W(rR4--yQo8?-&eee?Rao9w$d?xu^5unve0gCJ*YnRm
zY$`Vv36%3!YI$QJUtU=L#hlsl!b0Bj^~BobzI~|<^eG@WoFTdM`y=(zkju>{CJ+Bp
zw?89~wCDf+9|d0fJiUn)kJJMi
zkRJ>mk=Ni8^26ZCx)|4jrks-QOOC8&MO%*3fEZ#LkGu~bkZ*&J
z$ZJ@#33-6>iT2g~|2zcdRAB&DJIonh0I!pWaEJV2c+2p7{f7v2j6i-YF@rw&c9ajv
zNANNE4tPvHF1*-(GYafPg^WCc*Z$4S#4fl^UdIHQ_^3*IN+01wIQ
z@G<#Dcx<|_Yy@UzKy89&utlAj6>$X)o5yn)>w
zk)MY0)544WFUPNiKuQ(PgfGZFxb+D$6Pw`;@;2OE^YZ+ijX--XP@KOGyhpwTJ|OSH
zN90@K6Y}1%c>h14z&WTeC-1}6C(RjegV)IexI=y(y!A=nY-oT$hbmkE?~{k{ko;o!
zn0yG2#r5+q|78;h%p_1g|MK6}4A01Sz-yl}CoqQF3a(kk8>G@;&g0Pk{`9
zgnTc2PQHMv|1f8~4_+r%kHTvKe0l#r7=ad5sKGnrhr#>g7Ca>103Vy~t2zR)8BiPH
zGjbcAk#B<6cA1%Iz-{uAO3(LSlL8JZc;qL;yW~xHKz=HGNbbU;!i)Vc$L}-*rc|K?
zPsz`OFUUQ(^`B-YHp3fhUY@@;0`6L%IDcou+vFX1k9-S!K;DIq$hSTU_x}k6dZ>_)
zp97zh_u=YtbH>}?b@Bl2Tu%4@^AKoJg#o-negV8s9>PO%2VWD8#r5+~sf$q|mO%OZ
z%io-W&&apKGx8C<_FrZucED|Ne~dtr0z2Uzc?9p0?}7*96Znw)O6|UiC=jEBOV
zN0XR
zH^MV=8(#Z@nTbtsd+9iT4FsCYfIfdG!98*Z-X%X79*{TTL-JF71R@H!@G1Fe@RYm-
zUyz>(x2`ZV;lcd|1vVq#lDFY)^0VPR@(z4Jz6CzgzPkT+5tvYgt?-1r2cMIl16Nm?
zGj8J1TQ@vke-{DA2;@JK1#gji@D6z!-Y4(CL-KCn#r_*ppoa=Ec^^Jw$BZ-bZSdMv
zW+no-oqM_e945j-W|0bh`h
z;no+;Ozb=k_x}b3B2;k6cfs4_6L^pOO89_0hL3y-T#dkld6&Kcuc+*J|kbiGxB}#T5M)QJ-T?2
z%lDs6fdI#>Nj`vkMV*wLo$HHo;x;2E0vv61+$5zz5_fKN|P{5e1s4Fd;t`o{+onIr(XD_2t!!
zi;1=1^)LHo;7kM@s^Gy}`tTL~z`Nw-^dUkmS$&)|LX>*3*7>HeQ0Fs2GO!ejC|d`7+po{?wp+Seo#
zIDdN)u$O^6|M{N$3eiFP#?!X7ySNH#u5g1X0CVWDEDm)=~;dAoS;A*-);e7pD
z2-J;$QfI;)au41j-wf}Nx8Z&AvkNcwUr2!tDvZguz+>_*d`7+%o{{(9wf~{}|2YWQ
zd7$_sPsTpHNxlv4kq7WD`FZeQ<@)>$5E!lkPk~(sjL0YO3Hg=qggl1N$*+d1Z|Vsz@BdQ->Ilf%
z?}j_%3A{yqExbcMgZEAM)%6I3W)S8f=jPeP26Fw&&p&j*Yy8oYw3iWTB
znQ-9_`A%%E7Wo9;As?gTzPNt=DRmk;2qjQH|CEYQVNBja`I!7n_>A0xXXO3_9n`*K
z&NznKEs1Qw!Egk32LZJ3t
zGZS^VO+G@$P4XRZkK9H(UGlMy3IPRn!iVGyG!&6XC_g3N1y9K*aDPF8D-p2%*UUr=
zZ;)RNcgYi*wEg#lG?;Un@XI-Zd4h9~3+d~SHY{?{U)t~F;ogV)Kg
zhdbmcyhVN^yhA=Oyx4zz3hY6JkUWEr$@juz@&$ZGz7L+|UhaQ+0;=Qnv8jE}%*4TP
zo4f{Zk{<^5$Srtx<@)?>Kpwwwa;lhM$Kya}I>p9)XNUHBZny#JqufV$3{aSL82KNIed
zd+-+dW_ZW+{P%w#&^H6>Y_*d`7;t^nCwi6zHKsZDwZT9Jo#1hd0T$
z!9DT--YvY?|8o4!Lm;3E1Ne~q0(e9o!l&dH!_ze{&)*P%#af^^e;qjmKQJ?~9pxM3
zU6gmpM=0MW-*G(d|2+zfQDH#76Fwr3;1lv)@PvE6ijaIa8XA))@RA12A&^nvdU);s%uJ+k
zoBT$2lRUu0Jo33Npa1*66xf4?0`d$#B;N~<$QSS_`964BdY*qde(JIM)o(!+2AF|$
zy_t!FQNBSQqP$C9L;3c)7wdl*0=@M>?iPGNz5zZWufr$g8{rALoqM_e<`me33hGDZ
zj2rMe`4BU9$WKD~7P$lO{D|)VBQ(^f3hIS=X+rXo(a@N@36IH7h0m6b^XDRvEd%=e
zod&PnU}mBPx5>|hH_6ACm`CpUsL-XrW_Uo}h7ZZlhDYQb_>_DL+)pXcMPNa`6>k05
z%tQ~~AU_B0lK0_l?W_C$HUxT9A%G9a&x4Q12k;5`1@Of1eEmZN=0+g@NG4pR=8T8%
zI{9|ELq3AH$afT8?7t2L#;DLIkFbeC@(FxQz7y@lIu~EpY25GZS5S
zgM2I8CGRzG|8G;^98~C$_u&KbZSWC!0H2Vb2TyMD%^3_3m{Wxd;OggQ`4C~MuA=MjC=yG{ld(|m2jIp
zhBwKt)}H_SUlf?4LYI6uJRnctL-K3k5%~;0Ej`b_9KY)kNU1^!Uy$Djx8`Oh=I{pj
z9=N;i#rkImwATZ}his7w`f3KKO`Soq*Q@@`H0P_g_MR8Y;}m4}+^;nlrZGb@B~x
zhrAAN{gUqg8xiPG1smQc-vkfI8}KptN$_~-IDZZTvt>Y^zmwq^c@tjym6?fC;WoJo
zZ<3$pBj8b>1@Drd2@l9U_>g=vJR)zy{V4^`Mj$2cz!&6O;MUD%Cc5wj`Bu2AeRcou
zA<(7@=fHd9efWTU8+=3_z$b?1>wg{si4n+u{ue$czW}cGm@^LHb@Geh4*9U~V*j-$
zupJdTm
ztperwo5H)~yWs(O0w0oJ3y;WWC*b}+rNH&5kdmkH1^JC|>(^!`=I{pj9=PjMAVZ){
zz8BsjU%&_C``{yT^*Fp1z?b*`gAqumLJdAAKMbyZW6s!u*U2})9n2GmA)
zhuntu$v446@&eZ}Q{f0qJ1RA`f*
z1MiXd;REt*@DX_cpWI6K|ML(?sKNj~C%*u$ZZl^b!t3N0!yR$`{L6pN7lD=p%I9DH
zbpgCXK7#kjcfdpPF?>w!??fP`Km?zW?}BIK6L{^nW+twL+vKr!Uo|OkH7a=IQ+Str
zH#{Iu;6w6j;Zf;%{^j`15SUVh>)|PR3SW@l2)Fi{nV7>H>t3w?9t7O=K<*j5O}-c2
zBVWJ=$eT_1ra1x|x|Bk#ipOVh(Tk6xf4+OP;~o+nlilubb|x4G1`9K-J+b@{RBgxef1=Z-R&9jneb|H>SWz
zs1TDo@EQ5Z@Ql0(ukAB4aVp#{yx9M8{9FW@RN*wZN8W;W$a@j6k#&
zD9&FSJ|#aJo|1Rq3-T>+>mD-`-IH+tZ%|+>D!Al5c$@qjc#pgfACPZ@kM5!Se}KS*
zDx3#T$OrH_`2}$GdvnGiye_VvfB83?5pX0>KL7Hs3*asC|4-N5$G@@Gb^af)K)|9A
z3l2~)umz*^XoLy@3j`@LK-DPa5d3v9O4p58HAsaKiW9LwfC>RBL@kol3IVGIEjUWi
zh*bvcO2Dd73P!9F)+&*$8nx=q&FhoodOv>W{CA%3>oYgk&E)!~?R?MUZSfHvieJD7
z;?bC3EWt&5Dn7yI;+OE1c#N0+%k7EFg{SZTm0(IiUHl5(6i@Ju_*J|oKEsE(r~Pls
z?;62G3g&nsejQ(kFYvYa4ZQ4qM?Sdud9(3f5>$7C)NkSq@fF?@zlC?j*LYw2cIx^3
z8%f~Z!DoT^9(*QV!k6NEaqn(Bcbwi>8Taq@9mV{&A=pPylL8+P#P{QE@d_S_AHWA2
zFV62(3C5csA0qD{J{7OwbMZs?O1zGj?(X));fSCjK?ARgAHkdA0p1Zmiuc5ucr=vY
z7{Nrmg(u?2@r8IBUyGl>%U_iD#CHGRA*dF?W?7!Z8{%EOC4LI;iiddL@pSx86O5c7
z{kZ^-#n0d~@jkv3KZ|>N+_4{Ip3OgBf^!tq#D{nwejaa&kML0Z0zODRpZ~V}#sp(2
zxQI{1C-_|a6220T@zOmuJyEQ`%LJ8ekgvZfUKhWDH^mdYBYqX{iO=p}|38%A8U+*a
zIi84L#~0!Yd@X(hFGu&>bVYv0OME)jGzb(Hd!B7fT_(c2?cbtgF_(J?L
zzTWk0{HFxvy>3rj!K>m4-Vncvx5Q_7SNvM)`TXlkFsEQ7ejSg+7x+y42EG(u;@*8_
z|9_LfzpvX9E4(It3lGHCcw7879&X&$-`5qNa)V8fuRd=NJ{B+GQ}Mm{T)d30#P>x6
zr7v;s*vBj4`|-MX1#gNUz&qkqJnBhskYFfY!zbd0@I<_hFT@Yy>%zDD{{}(%OWmG0
zf>*@@ydi!RZ;3bYuH)(WA0y~HLHg|%J`z8U$Kq{#CVm26igz;4=U>^qq_9>G8g&fsJ5K0Xyci_gUe_-gCL`a4HZ`ZBjChImE%JYE+c
z;Z5-ict?Evb?pCp5?rKUC_cd_;+OD5JjNH|m+^HZ!IYqUKes2Y;8pPiZ-`&TTjDdk
zi*NS-*9iJjFvmyY*YQ|NxqHVqabJ9e*Tiq(f%qD4i{H*YoqwSO
zUX9NJ@jduhyo681_u_N$GQP?@oBy`__7Rl!xjo_I74iLeUA%%f#Sh?}9naTam7up1
zWS@Tr@u7GPpNJpA6Y)B}5I5y#G82c{<8l+M$ngn
z7CsU`j>qC{d?tPZUz!)sKhNtBcn@&z_$2O&ck!C|DLfDl@wRw$njn;*hY!Th;A8PV
zJ{3QU&&3CYN8U<;a}<=m!tIG6UJ*Z!*TqM8Q~Uzn$vy3VTYh7Lo)lcfhvE}_B7O-^
z#AAH1>)H5UCRpzVsZa5;@AkwMyegjH4e_gZOMI4kKL5HBT%({bKF3Gm*YQ|CQuK7sq<9lR!f5)Z_?csuoc{@WAq6hSBjAwCd4jgQ59_*DE1KHqw={`v%~
zZICa&vv}ztZchyGiugIaENq;%j^?ejA^Pdyg(YBj%ZB^WT==9)gt=l<-o;
z?TNj3MZApH#rNUO9naUFPte&3vh}wg?}=CNq4)uOB3{K4@q>?M|G$u+M!{PA5MF+m
z+Y@!XDt;Jmh&S-o!y@-UM+mx75a50BqxeX?iO1r{@R@n>{7b)VB3N3GKmXEyf`NMv
zcQ3S!`{F0?ns^5f#G{i0Z3((~D1Hhbh==%C{4_ol?-ib|e+kY|uoCa%rT^yk#96!|
zKEUhZ=kR9kY5&{u8xnM+;5^LEjsHc0#cq)L1Ye6^!pjHTo`~_P
z_+`8yK21HJe=P~FP|y`m@V@v}d?Y@@WASVF?11e5=LAbBxQ=^|aPN45`{Fn7n)nhA
zHqQFHNzmQ|#rj*}q4+I)Ail=O;jV%QOB#|hw+Bv>G(GYT27F@
zk{R!c2Y6rnC_WNz;<5O#%(MA7lb}VxQv5jXRoy#o|g;WAW4YbnC_X>k-ViLB9UZ;4ASyUixacC(hy(@c~{JKld2+|4j*o6m-PT
z<2~^aJ`}%zPsGP~5=n58U?D!i*W#D(^4GXM5#v?y%XkCd?Ej|(Eh)Hycf}LDFMbst
ziO=xZ^~k$MFmnTMjxWWpkgY!-?}+cmd*T&*D1HE+
zh*uA>|4$@1NWnt9hOfmB;pIEro~Ywh@xyrI4%z=V2wGBb1n-ImcwhV|J`!)@v3c?Q
zOaBm>U}i!7{7Zi>z?b63aqny0J8t8?_zAow9(4!;2~Of|@h%>UpTY;?AwCvAU3irK
z?HBhBdKApX&)_TZK3@7d_c&+qiueGp=brYzEx&UFO(_`S9r5#cPke+A#V_EKUC+jU
zOpxpbsb9nw;uCx=ehDwv+@6T>s`%y9^ZD11U`jzt{0iO`Pw>9@ReU5q!{eIl|F03u
zq+pIO#joSu*SmMTzcx~gXza>Gi35xZ16K{*J@KF2~J`i8yWAWP&!Bhh88~7{`
z--EBjOL*zgZcpsRE8=B5s!OnspegR-9r68mPrQN;#Sh?UpTY;?AwJ%EvHngIOt(S4{(AUa{0zPl@8hL!
zaC_n`UJ)OB1N;BF1m`GdiVyLQ_<6i1KEj9M7w}0W!I&TszlblyC-_?Y5?+3++Y>Qf
z#W(x^%LEN6nBpz*D|lBt!TaJ@@saD1HzSDMz`KUe#OL@@{5tME&b{LW?u#GD|9)^f
z|7sH4pdb)m;%)JpcqqQY2jaKzapu|lx8=7cm`cHId@k-iw)l)#iSNNnb+;!
zcyx@QCP51i#E;``@irccpTGy=ox&q;EWt?%rs7?EE`ADMiHCUU8{M8bjaPC{``?yd
zkDx9EXYi(YAMc2t#e3ode7Nh`_@5(~>;|b1@kIPQz7QYbYw-(s`LNp)+Y`6(s3L*)xZ*)n7vF<7#Y=cc
zd@tS;FXO|)xBLHn1QRLn@kD$-z7VhAYw-hk`N_LIk&b_rpy~wa-+sj#;x)V_ehBZ1
z*YUpi;mou7HH$#oKr&
zegYqCy;y%8g7G%U*WXEeD&EEC;-~PHc!-yt;`YSp$FcvfNYJC8E`A1Yiudu3_*uLs
zKEQ{O1m_4Q;zK+UKaVfONBCO&0$%>6q9-={|1m+8z~;n7ydgfpTjH1Su6T_1U5~uW
z1S2=_rg$uV1)qs0_)`2T?mg8V`&sVk{PQKaMnO${jtAn`@wWH^55;fbgUqw}Z(Y44
z7)!xTd@8=e=i;~UmG~Mj9ohCozW#0#RCa=V{nhy_5Z{A0#Y=ccd@tS;FW1@s4<*<~
z!9?806Y>4{LcD^n#Sh@+r$yVY$nLmGP<@)~2l0k@4R47b!n@*iyl-AS|I#m^2u2p<
z{hxjbg~#GY@R@jkFU61I-Z#597BvZc369}4@fIG4AIIC`Z9Eh|QF!DHBOi}!Xt8~*{pa5qT(96k{r
z;)(cqd?7x<*Wwpa&*xt`a3{r>f~xpMydgfpTjH1Su6T_11KIyyCKyS<6pzKP;4|?A
zUy5JFy=T}Bv;JlT{w65a-!;4@KF0&`>v&syfrsKZB7%VgOMEPT6Q7E&@VWRcd?mid
zqtY|2D~db5O;8c{9?xfi_#V6|Ucx)#d+}c3+x>r;U?>Iq@QJvOC*u3@g?I&DJD!gJ
z0fO?g+@7f7Rq=y(L%fE!#1G+J@p|Uj{Oe0_n1Ydb1CPaz;4|?6Uy2{ay`!@KZxZ-v
zko{**#$$L*yoCqi$MLp!8xOZ$tiKZkgKdysKnEX-pTwu)U3@Nn3SWtbk7xg1`WE+&
zPg774@8NavGk855%wI
zZSe&jW}cnjme~!0ffOw9vG`4VD!#(!;=j$_whu0KfVyJ;OnOB{|^w9zuoPLDqa;oh&RM*cuV{c-Zd|t
zf1Xz-=v$CK|I(ie@R4`}kHwGRGw}dlibqEYyzg-DxQYAX$MBkX3lGGP<8ASF;gJ_g
zaDsw?cn2SgpTwu)U3@Nn3SZ@(_P;H^kf8Kzwc=wp>|6_u_6kNtf
z;!`{pzk<)i6MVUG*56eE?>X)r&v0M-8eS8hKUutt40zl7$1o@@L2o^J`)e{W$O9-
zwC-JFx7oUrt`bPHu
zD+xjhO3!n9;xt|n@8NavGk8v%i!Z2sHwTM&d&a04HRFY&SXO?)c8!sk1lufJOatDPWQf7LsSXM5?m
z+Y>dsBEIH0b@AJHQ`|eu{=Xx^p2Nk7d*UU0D83h;h?nt1d>_6zF8hCO@haXjFP?wtA2t(oEy$mL>0eI6`{IZ2k$4@C#Sh~%@u)$tl;8;NJ>R|K
z0Qbd@;x+Lm9*7?+Jo4HSv?vJ0kK+UJHa-?VfltLd_&oQt|84o5Bv?s77cYI6+Y_ho
zig<|E#ZTkSUC+k9N6^^~Qa^+D#QXSA{472ZAK;1jxzzLdw~$~+!CL%0UT(WRF~Y0j
z7x0Go7;m*@|9_F7D+Lq0FMbIhiN|;>ei@%_ob@*)SZ;!1{awMm?{@Dv!F}zXo*3a(@e6pv_4M!m613dFyNGwiCwO1{
z5r#qgnza_zZC&<>{O?)N3!b=^uCvM>t@ikr-zx_n^|4j+JC-GSzz6bA#m++zZ
zUVI{6#*`SUORxd88q*YLjh
zA$%lW$7Av6Fu_cM2EG(Of_pD??>NAH@uPT6yjghU1ri*ipe^3QL-FJIK)j8Q#ZTbV
z+|&NI<<}vYOTkHeCEmqL-|zOsDZC;c;`Lq6#{V=yb2mu6hj+x!;63p^J`_KTPs9hQ
z=kqU-;2Z@D@gcqzKaZDBx;-(%tKt{%#!1=#j|o~*a1rl{Pw>9@C43|vf0qen
zo1j>KQ+z3Y1^0fyz2gM;#joNu@mWL=NN^2ri_h^;{5n1mU*Kc$8+bI8U`a3+zlpEJ
zS9s|M-JZCGSH#zNz3}b+|29EW3cM%tSs=a#?}?Z2q4-{W;&?j#WrD;B(w__Pg}9He
z#rNaoA98!5f>*^4WS-5xh6Gg#TH*)su6Paaiyy*A;&nVuJ)i&f1UyVIlY$1m6hDG{
zUH6Uy+!sHJ*S21)za~Mj4f6GO3~!6K@KF3XJ`iu?WAPJDj*7`Om7qhxT>K=y67S-r
z7r8xg3a^NVcs-KfG(l6mhj+x!;63p^J`_KTPw>tDe?X8(!8v>(KE&7J=kf9nyFD?&
ztFA}h1%iefcw@XJei84APw>9@C43|v=bp~LSc1zG%*3boQv3?;z1Y3u1oy?S;VuGJ3+SomiS!!CcYA1;iXe-a!?fk$HrZV*hxm-t-#CcYA1;iZ?kJ#h=K6u#a6uLa2s!mdr#rBKztA06EERI
z$J6oOOE7VQ^ydOR5#NU|#C?1%z8^2Y-0g`<=GpwKN^pRJhIkcki66wf;x)W4eh43>
zp3i@K0@ev)DL9PJ#2ffh{0Q!ycJDaA{jC@4?NKh9)jW@-6ct`vU-V^WRLwvLUKT9x?
zf&re0pTig8LwqfM9xwlx(-V<5BB;87cL8sRkMWlHMZ7CM!TaKua!=>qNP?JxSo|_R
z6QANs@hi9|d&~s)GtcI~Ex)S-H7S_kf%rANEk4IX@$2|t$Mf~KAQK`!5?|q^o-_8|t*5a6uSl?_pe}wJZ;E^0#Aku{9=s=B!iPP%(!B%|DJbKK_&$6g
z?&E9m{doDu>51a`m;NPGf~p1i^Dq6m0B?v_@s{{OyenSA`{L>Q2aB#7Nl?dQ@x%B`
zyn!#pkKo=b-LVe}k33(3qZHJ{n|L683~!6K@KF3XKFB@oe_MWSg0U2wz^CFJd@gLFeiKaDrVdw56u4Biv(r=HKhp#*0sn1~PXMEo4S5Fg@e
z@$-23jO_nM1l2P;U6l631-v0X##`bS@vis;?{A#-cZp!M35xX>GC4La^ir4Ud>iPV)C*UE1krdSNSo|Ilware#0am7U%(sU
zW4tAP5$}pma!==9UxG^%jKpI+7Qc+o#HaXD{0i>9KE2^={@e0P2>dMA@vC@Ee1-?&
z*YLLZ91nLqUw_vL20KA^0SkOAegmJ1FY&qfO?)N3dMf+>(pmS8Z&6SYU*mQ0+jvvl
zJHlsy_#V7>R`&lTf}s@b#V6urJQ3fAFT{O(ZC*V8(qFYkQ2t4`Cn|VV`~cn%ui`E7
zgLqdwsuA=hIE0VH>v$}F7@vtZ@TK^X!qfYIgL}sT1-|%Eye8hn1My>cTfBvbxu@s1
z<#(K5AO&rFEPeu?ig)n2_(^=V>)H5s2}*Btd*T#c5fAaY_-VW;-orcMXHw7SUr&NQ
z1w-+(_(XhwC*tSuh4>I(zft!8=LyOKwFSj&{(A^|PLMtq@S*r#d?H@P
z6Y+icLfp?hn}2Hw_ES)Pi`x?wyefVGZ-`g%miR%un|eO~?Fm>T=u5#Nd?a4SWAVfI
zOuT_Fw_dEjBLv<#_l^VH7e9*E#G7~^ehhDmx1PrSKa}7&1q1OmJ{CWLPsKaK=y
ziX`X~lzz(XiBot*JjCnbr}3tE5AWcc{r?$)o)q-)q4-&RB0j(q@pJgX^~f6%tlhvn
zkC)%-_QVLUieJDR;$yreelhoS{&gjoP|z2@gpb5yJQlx<&%~$rGV^Ty+w!|Y;JwYg
z;{^A`ui`cF86JpV!`nNaufI7#xD#Zbe%JAV_yQk`-@vEhOMEVV^J(n=R}!o!C=K17
zxP@25*LYq0Hr^EXzM0R0q3r+n5cH&=gb&5{;uG;Qo`~qu~hf}VIEABvyFC*lJ<5kH47epdGXLxQywoX5-O-JTfXRq+dW
zLwt<4HqQFHNYLE`+2TuoF2MWZm++BzjK|`a@tOEEB3Mdr1^3?W-f@Ea;#cvS_zVxk
zui;T!f;mAbejOi(FYvMW4SXuT#OH-?_y0EuR#LFSOF!rK#4WrczQ*g~xACUq+4w)5
z4+AGie=fj#;w5}2z89Z}m+?e=U*_5Q7ZUgsti|`^<#)I}QNgR?2k?e?6>p`U&wqOY
z9wg{WK@IPVAHqlCbvzb7jL)`StiJ}qavNl;&pU#9BlnI2+!sHJ*TkE6Ab#xW?El*m
zv?vJ0kK+UJHa-?VfltLd_&k!}B*99&i7tf{PS%#V2@Q{1QGA
zkMUUiGCs>ZoBy`_rUXkVxPp7X;NEe9`{Gyen)nP4c06By*9h7>LALzncqo1yABZpT
zvG@&qD!zO=`~SHFHz`<&ukg|Zw4d@nu`
zFXM@M@%&5wu^GX_f~^13|9pHcz8^3DqI&}syefVGZ-_@#f|dja@ve9c?~5P8N8)un
z7C&5g`uqPRXi%^eKZ1MjbniI8eet7sO}vQ*xu^Ya%kLOLTMAluD1ICth_~^v_z8Tv
z>)H5s2L>A)co#3d%k7C%ctt$K>*A+V&*xuLf*u7O@iTZ&ypIpX&*BsD0iL`|
z_W$Py7E&<8*W%~#^4RT(5ndI)fHyYI`Wq9pHbJ)d(qF-hcf}`oU;Gk25|8m%{BlGv
zlVFN3#joJryWKlZa9{i?UK5|;Q6RxJg0}b^55=$J1Mvku7QcZ{3*YYlmjrVuxQVaC
zS9s|?Zcp68E8=Us?sz)>w+Wg~kPiMc_$(0LgZIQs_)vT=J`pcxp3T2Rf_)S$#C?1%
zz8^3DlG_s%yefVGZ={~je|rK}30hKc5buiD@V@vVd?a4SrQZDQ*Yq+=chVuW+1!c+3TjGSB9}Ex!?WFp+`}bD*jCpW-v|
zPvQ&lzrj~Kp07VE@P5_py3bQk7Pkwkh~K7tP5eLchIr{E?Ejk*-1jBL5VXY~hPG(|#as#~=Nw?EmkeU?K&N#;4*>#%JQzej)xW+ON!u=bz{OcLHzX4#A7?viK|U
ziuh0AHSzc04e{th1WgHkA8(6)6z_^#`=0p6Xg?6=hSPulBf%#qn26gQPsRU)_A_z2
z?df?huq-T1>{W_=@$bT?;@^+Y#6x@`ekSv5{;ec<69wKScL?5&
zm&JbxuZYKZP5k%qM(X+ew-scX%1-58qaeLBL#K*L+iQ9v&5lPVH
zN}J;L4Xo}zc<;9$6i|@s|;?}+={s7t!GSB9}
zEx+Q5(tnUF1wI86@dx8m@dNlw+}ba8JYRozP_Wtwve~~Gg5Pk5;Ol5#7T*kk_}A0E
zCO+Eplw$oiBzO!3P4ToKUzKg~r_jDD{>^w#{CW7`H)Q|+e1ee_Tw?Z4#D9SHQ}LJM
zGw~k2FfZ=k^Ik=;vLL@t&$A2iVs{AMLi@6~T~I}QMEja}WL?yd;Jp+y#Xp3%#joLA
zackcb|MQ}K`t!d83l20Azl~4Ct^HK|0jG=QIun05zQ{f8e_MVvf|V2;#=YNkhv1ob
zS^T+pMf`j5+OB8g|6+p1Zjkyb@uqliCl4a=SJA#J{?m9*yqO*+pML`h>;xn6U*SL#
zaXZ0O{C%{aiGKiJ{HE;xKT5E=ivsVr+#&cg+Ly)c^S&bfdD_=DZtIT+OJfrh>+c^a
zXo_F`PKH4I7VW#@9>?#Ae^K;`;y?olz7!vc@5d+Nc0p6|8trG|Pr{>x1m8li5`P}<
zeZU=p@5RgFb^{ghleDiDzTN-Z6*Z*bMHDo}?Sk6kFQt7~{Iz(`@pSy(PB3tS^kWzw
ziMLpO6LD)l6@NF!nTh8Yl+C||1Q#h-iT@hzU3P~c#>?U#z$@Z^fY(yb=f6Dxt)L+V
zF`s@-@findi+>XDid*~M){FJ`w-gMvLB9UlboEI5vV9zje~#l!#c$&?@z1@2{r^IO
zDJNKo-}lFg4OV9fF79WpTTKig=awwMYUxK|}lr6g0)Z9dC=j5bugdcn{y~|F3Yz
z11Yc*jKmWg0&zRRRQ#=+c;`SPIxV^5cDQ;gvwZ)HeL0xhC
z5~}w*k$VTvrC=Zh_9fIv{Cj9W5w|a)rs6+H`z4YsxAIb+IPkI5-Rd~61ONr~Pls&%T6ONWq+fmAHKg<^8TZ1fQXOS^NfG+4XGv|AC;k8>GI%
z8{&8C6(2%PaXUd<{64hrir+W&eE#($I6%QbJS)il8;L(2pNKyRpNc;npZ%`v|E*vl
z1rxT5EIz|48)yABxuV)8$ZyoUMnOaTJ80h&e-7RjpL3k9__c_j
zC&3Fi&_MhoJ`#TkJ`sO8J{5m69?c|p8^J>So%l-J+Izp}4#BU}zAPT&mBP3C{|^(?
z?xLU}evS4`@de%%|9iaacsl-8&~pOM`@a+n#HUQgk@&yTej;w|r{eee@pLt1^KT}>
zj1w%x@0%5*|E?Fm?;pe6;6#v9^K!kgmOzAgR?+IP2J
ztiR_F^tM5Mr{9AQ#I5~E`~>YM;tP6oD&D!1N9;_3&8AfRg`8j|{sXx8`|c3D5ig7X
z6kdrWxX$ySCjK4@8sZDuH^o0d`?mN;@GidD|9_02Ck3Cz2jYK?kHr5TpNMaU!1c(x
zO~K3!JnPbh_=8@_DiF6W_5Q#ef;(tm7B6}5k{?@0AqHc7kmE_3?%HTk(~+wf8>k4#6+b
zzAS#}mF)j35`36~ns{1}KNlL}SMjF!$MClJ-{IX4NA8tcK~D~2jcg^N8&y{
z5x4eJ^WypEd5@rAW0dy=7vhh>SK>!-FL7_|PP{B0SwTgD76mo&&+yG>L;O2w
z-xQzNH>Kjoi}sP%mEgNLP*41a@qxIVU?jfcnLiQ#02egPJ?(#6ejz8A-9^Dd{N=P?
ziN6B(KH?5RA208EHvZP7mE9m4|9cdF+Eo+(DGt;Se<$7)w;pYazdLQ8&%dq&R?rjw
zElw~HpW!3%B|Z`VPkj0j+5bP_OtH*nQg9Goh#$dM;&wsaN8KTKI_=9FXZ_ijRW?Df
z{=S`pn)sb~L;Sn(rnud4Tl_>sL01ABt-W{E9fFz-LF)PZwOf4xAr6P=g@v4{-9T}
z|DQ_mTnc95D?U6H;?JY~O5C0k-v4%o;03fVM-tcxD&i+8sEOMN8sba)xj_7fIZhkj
z?Eim^pnDeuJ@K2IU?Bcljx!SP+Mk-a9(h(Ubp!9s9B3xK`SW}6x6ytjzWL+(KXQlQ
zeBM5te`N_ae||6i^PHe2zWL*O@fvfbB|hdjoy@cOZ_95*mxfYs3-62n5(gTJuW3IP
zpU^(u@qGQ=rXbk~vi0{q3g+V8GmGcJQvA1QzZTy^`_jkUA^4qFvH$laC{a)qzrqRX
z;(KWyh<}9kE%86XJ0Fw%f0+Y?Qt)vK`r`X&KNP=4`?0uB``EmA{(0V?QIJ@WKmXGA
z|4=X&{|nkL#VfR5i+_strI|Yf(E$p42^JJo#jCWhi~lw41M!2jZ;AhH(LVAz64WRN
z#XrXh`r?OZKNP=7`>}YP_HpiM|J(BWJOzmq9Hw9{{sr1E#T&F=i~ketOCR4Ef^7Vc
zP~h(dsjn%hiU+i>i~l?A1M#D@Z;AhB+CHCu9SNEggz9|5*%!attBdEsQ2dMWvADI5
zKQ87y~ff>69e`@Xn68HeI0X+IXXCu0;#;M1jvxIG!?;t#eV5Vt4eTKu83
zFa2>o1l#?;JsSNY*euJ3QBW1PCu3dw0PO>Ddos2hPsjhODCjsr`m0yyx={RUXx|sN
zC*x534%&~!^Cx3A|6&Ph6eQ|g&|Lg6v|ozblW{HnSlXAa$^PFSjeZ(r|JjrA@f1|W
z?a5eIr+px9PsY~Pi}m+p3Od^$zW{qOhT`8u`@Xn68HeIeqy1R?=rh^>#}Wh-B;xjD
zoQpq;_Dk`j_*(qi@ly0BZdV^C@WnfLRs4l`UEKZ&S0Mf(+PCn{{=dNwg&iq)DFvZ;
zAMcCXvwbN3I@*t2k374B*bTflQjmzh37?Cf$Cu(=R?S-c-Ff?T{+0gJ9fA*XAYc3w
zcvakPpf3LBv=78TiMKM(=D#ho8w4FG_yQh^-|aQUXGCAT$xsf(%d{Wwc)tD~LJ;o+
z+4_4Fo`_rfxp6-@tLk=Ed_b{i7Cw*n<4|=XvMwM0|wL#eW%JivJeA7LO8w
z(%c<_E|bU?|0wON;#cvy_$To|{8NQTUQ2?{QP2^$3kt>8wC{`eY!!(A8|}xrXX~G?
z9#9ZV!EFi>aeJ`L#ZU3TS&Ccx^{!{*{~w&7^ylsn+^b)#Z(sa=cvXBKUKg+6fq1qo
z^ZD13;NcW>#1G)1_+#+CxXp>7_)};<{&U&?e;Yw81<%J5@$bUt;{O9*ia&&&UT>WB
z_dh8p{e?ROw#6n{PK`{E;81>!$L`)DkI
z?O0;*cTkXsvyQ#F_Xu#Zb$+^2n2+=i?!{vg^1TQAn1?de+EAb%(ypr9jevpN)iB<=g+
zHpD~mN4=K)|5ySmh{Yev2@>(g;d62OQ}Ly^ZRysL1W)8ZrBAs-@Xfd{{%v?wok?03
z|9093_-6laA2uy1cs2zcaa(4g_;YCA7k>dhbUpHZm|*M%o_(6d;;*26B5vF2x%jJT
zzZ5TqES-OA3C>bb`m{R)wxRIF?OU#@xcwwl7yns~A7q}*e_MXG0clCWdno9L+nfl+
ze~I>eaoYn9cRXKzzf8e+C&<>HJ?UccU!{E_Zeu(bx1SZ4;^|LL#rj`MU_WY={?Z)+
z`%%jme=iqQ75{a-F8)3|_{+#W!EX?>q`>}xaYx+RhvFfB+SM2TAjcn?7x(XZA0`-E
zkU#%C&kADkkJ3I7zlzVr{}f+}M^>xj`v!vbCMee5WAV~w+#z@z?u*y)s<;hV
zUHl0V1%U)l#9QJGyd(ZJJQNR~$);4?+DAhPPE#-zegE
z_}Tm$O7JG`U@ShwWASsGAQ89rb9LG;Q_ttWJpl*Y@mdS;(hYYAti3OOo)c8X-@y&k
zw_dEj3k1P7$k(40w8Tf8pd{>OM7-|YV{KC^gG1b0!;5}(k%BR;09L-C0X
zf$NcH1w%LRE^&gf_;v0e7XJ*Mh+F%)c$^hKC{Eyue?49mxAyhVM(%+gM?oM3&!p>G;sNbD
z;?_f<_>3Fqn-|YN&$~u2v><=}rN4p!AB!iM7Mvgu|3MDa5~!D87di
zjK#me4aDO2JX>5)B5v*Hg>U!&B~GxEf(LS-wRi~!D87#qjKxdYfztnC@iLx>Tl;zH`TVyh
zpl?GU1>aypApT?SYHx5N+7z9aqu+K1xb
zcb5HsUji!_idQ+oSp0`LK`j0fJQ26{^GJeMP_Pt#CB7Cv$OV=D-W>vK?~D7)i7LL?
z|5pg=Qc$xY5V!U%@z-#n9dR4t(Dlfxa^k)lcr|<|e#nME+}g+DZ?Pc|e{1gP{F_Tq
z=LAdfpSB?oe+ORr2X_dpy)S;4<5x4!=D#h!;)>E=NFW8j$O!`RU&mYG4NlMz{}AoN
z9naU_M+o{mLAL&^U?_gXhCqDAabod5#S`(geX;)M5(G8`;@3IQTKqG3>GSRoSbJam
zs13pAW&dBdRUicoJP^0`E%Bxefw+Ci6`B|K?|H{4=v$CK|I!~$xwD~oi}qvjTeOeG
zkJCO8kM8r6#fQgSf;I(9@rTlWE&eFHv~q{Qz7_MupHQ?<*S`c$^bKgk7^
z{?Q$R7AN+_yJ`D;{#7MtQ&1OwKPL{vKZv))I~=DYZpRP*QTG2=I8a{-Y?2PePuUQN
zhj=XhryM`oIP1^4Xub)G^>>;BEyX{@f!5-m#Ye{yUg~M_!GIYj(8*UZ2pB33^_qxJTFMw
z55=F#3C7|99*dvn_(|&d{I@5d70jhzM8Q)0TR6d5{MmTvmOBL2-rst${w{EW>Nd#N
z-}5+7UHtiYAa3nj;$x2A5fANsf1v~yDd>y$7~`S%3%H=Mcu4zL{DrhnA_?pSbMc7{
zf%qkSEgs{ge{zT5MV#2jH~asW5>)S^pe}xy69nR~;5aSupTIk=N1hdgZs7Ily1w|p
zhCqDE1&zh6eJp+@Z=cS;M1t3HrE~ECz7)6iYw@?yzVy%T5R7m?^KAax@=LgbsuaAN
zg1Yzw55%o~OZ+Ow@9cQK{(gf4g*!pE{(cwli(C7l_{@et{0})!EdGf%u>Vgau!6bx
zpL3w4_?`B1f%xChzVt8d5UgGWPc(kA(k>F9BcrN~Ad?{`xSc`uv?MrKS2snP^
z`4Zf)ArN2Ub@7{cAa2X7CBCA4C-=1fZTa0I2&G_+_r<@PI~$6>5Fd+M`*_#0@qaM|
z$!?JPOYph)Z7ygjZtd6N-qB(*mj2Zpf^3rJ^Us%H4+T~6ALWAT;ypYNxAraZ*U-N6
zud@Gt13@SSB`&BhZtaKSKgDsz;y;JS8)yCP7Qczdg>U!&
z|3Z*R!N1{i@dKQADQ@l8;#Jz0{(W}{(((TfPT)I%=iPl!ESsvhwXcici}r!IeI?Tp
zKbT!mHvc*jJeUK8;uX9vZtaKSkD&cnd}y1})bsgoPrw>ol-xzZT>Ky>Sc-4HBNDIE
zzI1yt1jYKZuUPzTkgva|P*4@OSzQ-z(moJ>9^Mjv!GQ1oI}-c=1)+F|_r+g{55<2H
zAB&&I<4A&ECP>8JkI%)Y_)`2M_*(pr@zQ@3L$KNZ&k1}2n-hna6IF4$pt^XS_JOz^
zzvX)5eToxz+`#(`9*Q641buO9KNN4!ek}gk{P^koizWC6PMnAr1?is`h#%nuOYzMR
zi2oDEFa2ja1ljzzuD0)b{Vdq=fD=^3y*CvPsJi&Q@j%?#w{|>Ve@8h%XD7%m;C>t^
z6n_Zb7q|99@um%dc)Bc$^&d-cjDkdb#FJ|-{zxupDQ@l8;w_F}`Y+l4+n34yf4M_&
zoPw(O9h|r>-llyZewg+x^Wy%~_x}@gEXevl{m%+Q@e`b&FK)Z!p?HV(WAVto4v!^h
zazTl>-SJ%fBqvyk|KRJ3NxByA7VWdo|7YiSYOBD$|M$h8&jnS*PvCX&Q(RCWZtYvS
zr~Pjy4k_qJf&DRCDE>lD+!y~Ld?(9oxwG(8Yf7dDKi2nj74#nS%_rh}?p?&Z1__~JJ?K~;Q(*Tp};@dNYX`Ir7xD}t5Li2Uy3uSBX2E%_btW8WvS#2!5-Wf
zFX2`389h-KpW{L9Y5&{uvx1fs{26!L5&twEivJDX7hmGTUC+jUFLyBB4N|v)SiDU8
zM0~-W&Bd4aQao#)&%d<f)b#4g3E!G>0y;EX|Z}$K8hJv*e*c*&W_jQNhg`C(Ie=%MaxAt|{
zBkwc?fg5-~hPT9j9Pfx*`%wH0?fc@d&OM!fLkap6jKyDv$KogW^&5$}wV#W>k>e~g
z&*r}^zc&%ArNDk4Na;)5A+Ywo_~s=P;x@$f9naU_TRCyC6J+b}?RZOk^B@x6Jcz_E
zaGbvQufB!-|4@PtP%sux3-aeeEN<`fNyP2-Jach-ozL=1WdCmkYbgl%H(aGJb%(%S
z&*O{R>wK!>*1m3DJpVk;-sclokl&~0+3R^);`TnDj<~fC#W$Y?;?W1`(xC*K&jNA#
z@QB4f!f_IDJN{gp&yL7jO0fAX5VsGHQrR5>YwwHmf+4ReZtn}K=brYzEkAp~P;eIo
zE%86$jyvLig@@w*fcJMj8-FVp?gpu^C>V>k+0Vz~9Xt`Y_H*&<(>0%eO9^b*h{3w;?_PEKSa+Z;!nosj;G`QEE@tR@H{J6
zi+>C4OJD8|!JW7-zWF#7&wD7Fe{~6-@H&1x7T>%RN<8Kl4Ryp@e^mS-b|~)gn-Tk|
z=kwp5fc94Vp%h%@1Y>b~&tEKVzmhQ#Z}DoU`PPf|XTOtixefC5X9a6<`;CdEeeMw0
z&wReP{Z_52c-=n#>k`=SZfc6Jo>2@V*a__2H<
z?$drM-uSuV1T*pdv|r$x{eS7_i-MIDR4DN7?{;GjRK(-oD~?|?FP?wttCskEKMf1A{!jlK{C-i;6t~~s
z(-!xVqJ3A~ej!0mJn}wL6bvM=mr9Ss!~b2hpNQLAuBYPjj}`4_;=IZ=@)i=5nWZan
zdu6icyF*Z#6(=Z*H+f=K#3!_`<(~GxEkAoXd_xLOytp_)Q#^Zpe)eBmJotFwU2%Ku
zc5m0S@$XVF*bP#*pKM0rQ`%3&?KRy~@y;iT3z~^%zjGp=e+vnw6s*MUZTQ{;-63dw
zwm3mq++H|b5%*d}``QC#|KDZI8d4B*f~I)a9z^27ZH7SHUUJ>rIO}hHkEdos=?ykP
z{=c6627{6K`d&r*iMag&gQeGt$
z6LEVn`BdC{R?&Xucsl;}mg|KRq|XIr=}P=f{5l@*!R`=Do5cyr;`T22iugQhpUuCT
z1omFi@H7TgMfoHEyZHTvN-xRkup|-`#=ZYTciWhG}
zP3K=vf)!ml5FgtRi1#?bMBH9HIu#$%ewKMQ|84o%i$oVv;M;>pJm!MDhq^=1wILAy
z=hqdVij^JD*I&gxk9UG>{rUXqcSC%_9W=%5wVG}5s;vU?^sSo3`tM25;|>Pm_FB!6
z_<;5kaeJ%gRJ{DAVouB+D*OM0E?r1L!X2-~?X8#ISGq$`-=Xv(m=Nsa|&lGpi6t};y&=#+8$6fL0)r@gZf;J}@h`)^XBk?+SJQ25dMoz^C
z#qlF=CV{=-a3S8{1S@fS2c%bVhhV`Cl*R3pj+NZg{*2z*R%2ef(^lLkb09748-jXha>Tp4S~45uy88gPTS}6Zzh4gfN&w+yJce{Y!~^cQE#CV1;)c7a=kwp5fcD0to)j!N!9d(zsWcMra)ODt{Xyh(>&5z;Q!v{G
z`T7f=RV>Sec<>9w9jwG#+_87S9fB1%P!|8xPxAMFDiX9gK~3CVI@J*O?p1uX+7!3f
zY_-Lk_Hi6ZV6V;UiI+LSK-~UW&PcpM`-!-{Lu!g|_WvX9cqRq*PN;?W+J->f-tpu;
z!X1LzFBU^qc0Ka!olO-t@B#{I;vr+)5bx5yDc-YHAl}d0r}M8XfxS|xCtkB55Vuz<
zjl@IyI2N}T9!)dP=D#h!1}B(F!GJqhi1#?bO59#Z{
z)P_LZUNF=UZ*l`oaeH4-TfBWQe*bS*0(<{YPkhY<4aD!{!)7F2erGZ1CgS$~pXnnb
zw~GdJ(M$^LeLf5EfD^35?e#p~SGmXU(nDqQ;`!%!Gy9&-g8cdCd2LQm6Svp%G{lG8
zKvUfQU2t1G3OPYn0(%=yPrPTVK-}JLGZG)z5Qy`#n#h|+%02CWTYmQDlZq4!xZ|3*z3ZhRKC&SYw^z5ccRd?_pDXI_2C3V>Tyl
z-|qhx6m+G)UL4XB4;Zq6xV;BtB)+1DCXT1$Z}0V(Izc-ArD9Jv6SvoNEW~3jXeDm1
z-S8ge4#6_J;cWhuC9v0MRK!;{1mYuG1>$QP0n%L_76-{@W9<%pG^7z~1@L6JOX6
zh}$a`M&cEY|NrQ^|M)rneZKp{mG;^m(GF6!1cP7^&LEsc9OIlERs~bySQSi(V-dOt
z7GVcbi(sm8SsIK=SsJ~zgf4)|>qlb=A~OWy=J)ku@tqk0ar4{sskl31M}+V9|K``@
zGcBM6D{=GN?z#BT41u`$Wpw9VtsyAB|BoBEcHo#_F89P^Zs3cX-xUwUJu?L2_3w%o
z^DmNMZhkfs{}_IoEf!CBLy7o|WtobP%n+1b&VRG~%x^YlQjl_kmH5#7Tp*rsgRS_2
z>z#Ky3_-R2=G?$N3d;5O1+>@`&&&{ro8N>E#237wQ2d*}kw5>BBv^5Sp}6_Y=2(1X
zc0}Ul7mri%-uJQ!-c9!Z=J$LvDd?D0AZ~uoHy6)oz*gM++OG3*hM<1_6<;>ZJ914>
zJ^zXy!5A}7e8cs=xcN2QKzz#^3dP6f*J>jPd|EsdU-5=wap&*VPtru({1R&_-mPDM
z?93&|xj`oGd|y3gD{=EXpt-oq8`_GS-!XOkatMn4H_OkXz^#IVo8Rg5!~@=tFK&M2
zG7$H9{qWe!@z43$FFFni56lpVo73!Ayw4j-#LZ!LDqbFD*Xv&bbDEuryKE>{;^s6v
z7ms;ETXA!k?Yz6}|IKN(`|gJ)RJjn$X|^ZsnLjBNH;37QxNrWXbnmP`bDACPgYve;
z5Ku4_H>cULc*ymMxH-&D#iKC=a|z68b|&uKWfh2Mb{L
z#m#B9CvHx&eQ|S`9f-HhrnL6M{@@p?J!J^YkH_NXG}{w*x!xByhuML;IleEv
zy1#h>n$zq^3Vd2T6gQ{Yu{uMRh?~Rg^x*aSbH9<#<3mt&r8&*c#C=-45;uq0xw!KK
z_3LlNzvyfE^MB`@Jp`foED$%R*`9ch8~EbpFgp+r%qkd5U{13m@st}3#m#AUEN%|7
z6LE8xo#Olbf6fi&TEO3k$i#PCzY;fx*|~T~i?_Cq9dnxPyq7%$eF|K0bDHgmM_liV
zo5Sotygtk>=3gj*In9p5y&vR3ByLW#WAOpkC*tNXJ1xDO|7Q6OXu(_x%xQKezTgHc
zadViRi^um^1xH@3KhOMN@ZR<)b-n&W3CwAB
zB;NXu41u^g&5p&pi~9OR+#F`7?=AcPoPxO&nA7Y`yk~wc5I2X}xwtvZ-WsplUwqY)
zIlq5}Jp|@7+ZFGcp9{p>Kf(}*o5Sotd_3pVDU`sRW=G;FHyDbWZ%&QHGqWQS=P-Nh
zq!O&mj!4{`W@qBTkJc?-iJQahT-^W0`uc6>(IZ;EJ2mY){-AX8YpiFgw^g>n~-@!hKNfKOA$K9f_OM
z?4h_h%#Ov|KVHv?M0_-6j8h2`T09qzxjqx`af6k(In2(*eTI0vmB5^4JMU``!N`0Z
zi<{GIPdwlTzPLHe4r)K_|EIJdlmc^_9f?nPLqlrAH%@9~#jK4X}POYGLF8oA2
zW^-|Knw^PzT)z@GhuOJ!b(mewzpVu3G~0PUdkDJRz!f*A*`9b}c0}UlFgqx`n*Zhr
zXil?3DKMwmk@%1?9*Ud8>{#3!W+w-)*Pl7fP7gt~{-$O}ByLW#GjVg6y%INv*}3@W
zC;0pSTM5i*wsYPdg2W7gxcR47JaKcF?Teek>|iXxf*XY5<}^DJpYxm;ikrjiSUh3K
z5`4e^H>cUD6r^Sd#La1TCcZJNK-?T==eCcXAs-%FJ8;Ztw)6h>5Tvxg6*vFxhbQj;
zBp=7(^?+j*ru1Pk7fD{lUo4NpAcr)gijILxlse;`3_hCtk$
zW=G;n-q29o9A?MjD>DSIl>NUs%}%9YZH7SHoMva@5tD8uZVt0^<8}Lse*=<&tqH2<
zpR@m0Fq{vthhS?~fw(!$_QV}?WFtN{r`dr7<}^DLH>cT=_{I!@xH-&@#kckKV<(Xy
zH9I2lR>sG%xH-+v#9dmv5;uq0dF4g_o8`AMyHY7Ir`b+m4}r%GTyb-l?TPzl2#&oR
ze{-5090!F56olgDG&>Rxxqc{a4zpu%ujtWg{v{Ha)9h5-oMz9(&1rTf9`S}&;^r_r
z4`lyuPP4aCU{1505449MWKOu^<}lk6ADRcz-dTU|40&|+3if3jOh?{Rd&&1bUzY;fx+2dS-?oZXrY%6X~vz=GjL$EbJnu(jk
zY)?Go_5Iop`+swq9Y}#W%?`!QX?7$&H>*J09A?Ls7vt~#tGdOB6*!JL%}&J!v|uh?
zoMu;(CKF$oRUlp+W|#9XmtbXfMB?T&+j+G;1pZIg4RFQHVYVl};CjFCYW|xipgGMB
zq#!q|K-`>WN8;u%dnlgqXpawGuRn8|og9LyE6r(kD!!!!b8&N+or#;n?3MV+{QJMT
z1m-k*D{fA+oeTC5aGLG7;yEq$#LZ#0KbF9pW(VTtG&>YGr`eIXIm{l4o5SoF-|zpu
zpJ5e9fjP}i#rs@87dMC5nYcO3UfDi&e*W_~HnRgK;Rajr-{E@aHTDq9xZV|i!_Ti@
z-xK$%WnaucUxN3bAP}GYcHKjv_4W!B+h2sBW?ILG}=I-=e_@_#b@{Lm=LMb$$I%{Pn-MzCQk-vE71{f(Y$uOEu{>T|`h6HCzj=(->g{|8@E-yjtaURz&3
z7k?$!XX4Y(uCHHJUi80NejiIgE(P1`b-`9V<$9-Q55fFP>+4%G0R{!$A3eNeBzm3a_}Pk*Yu
zK`8#k+#nL){A_*wP&^;=U5v2=qo1n_67kRcGFE~3?w9N9=i=YQ^_h71EA{o`l?2~M
zK`tJ$OWunA7}q=h*dBr**Sq4s%Jp9Dhy8#1SL=7=OTiye5Qy)%J{14VFJ~2qcOKQR
zKeW6U{|N=L6%>CjfPzH)9@nSh{;x9x;=juEnRvgvzMOw6310RUbqjLwgd1$d&vL!<
zq4p5$xZV{v-<;|dUd?~=1RS!=d@1-QZV-sOzfre16#o*gkHnKTLvZkV{f+qw_4p7}
z>o5JSx*!q%39e7Yo!_mmpNl`^`b<3jU7mC+3ErHq|WsuUp`Yf3^93KymkFuc>+}6gOYR9*K|4X92$7|MPu(PAmoaTh%v6#Lf4B
zr{bfxuCJeqzl^^fm)TzY`5&KVD?4z^mrCd2vH2_zevyi-kSqSC)%C^v
z^CSpgQ5X2)Z@~=$@#U-Q>qBv~%p!5K%7&$v^WQALcj5-I6a*LQ8zkcI#r3IpN{`OP
zU%~a+kyq<)Zie6}C})-V!r@$eVunE6d~dk(;r0*=K7=6)#Uy-PeS?(=
zs#RV5a>~c>Ss*^-`mMNGHO@!aLts{oD?T>gWbR3@;Re3A`6lx~-2c?N0in40X7xxs
zt*^gzIgCM#p{&wuPcFhL!tOD^Fo6L
zj~jg2;Pb+(`EQ