From c2a32665099e70bda97600691d34f15663507dad Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Fri, 28 Aug 2026 15:28:15 +0800 Subject: [PATCH 1/4] Replace the CloudProvider enum with a source registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap adds model-provider APIs, token plans and local agent usage on top of the public clouds. Each one would have meant editing the same 48 `CloudProvider::` sites across seven files, so the enum has to go before any of that work starts. A source is now a row in `registry::SOURCES` carrying its labels, its default region, what it reports, and a function pointer that builds its client. Callers ask the descriptor what a source can do instead of who it is: the dashboard lists DeepSeek under balances because its reporting is `Snapshot`, not because it is named DeepSeek, and it skips the trend request because `trend_window_days()` is None rather than because of a `matches!` on the variant. Adding a source is a table entry plus a parser. Behavior is otherwise unchanged, with two exceptions: - An account whose stored source id is not registered used to be read back as AWS, which would sign its requests with the wrong scheme and file the resulting costs under the wrong provider. It is now skipped with a warning. - Switching the add-account form to a source with no region used to leave the previous source's region in the field, so a DeepSeek account could be saved carrying "us-east-1". The field is now cleared. Also drops `NewAccountForm`, which was dead and existed only to hold a `CloudProvider`, and adds a CI step for `cargo test` — three tests already existed and were never run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 + src/cloud/aliyun.rs | 4 +- src/cloud/aws.rs | 4 +- src/cloud/deepseek.rs | 4 +- src/cloud/mod.rs | 101 ++++++++++------ src/cloud/registry.rs | 250 +++++++++++++++++++++++++++++++++++++++ src/db.rs | 48 ++++---- src/ui/accounts.rs | 247 +++++++++----------------------------- src/ui/dashboard.rs | 211 ++++++++------------------------- 9 files changed, 456 insertions(+), 415 deletions(-) create mode 100644 src/cloud/registry.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 793445b..cae3f39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: run: cargo clippy -- -D warnings - name: Check compilation run: cargo check + - name: Tests + run: cargo test security: runs-on: ubuntu-latest diff --git a/src/cloud/aliyun.rs b/src/cloud/aliyun.rs index 49ade06..3897c54 100644 --- a/src/cloud/aliyun.rs +++ b/src/cloud/aliyun.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use sha1::Sha1; use std::collections::BTreeMap; -use super::{CloudProvider, CloudService, CostData, CostSummary, ServiceCost}; +use super::{CloudService, CostData, CostSummary, ServiceCost, SourceId}; type HmacSha1 = Hmac; @@ -287,7 +287,7 @@ impl CloudService for AliyunCloudService { Ok(CostSummary { account_id: self.account_id.clone(), account_name: self.account_name.clone(), - provider: CloudProvider::Aliyun, + source_id: SourceId::from("Aliyun"), current_month_cost, last_month_cost, currency: "CNY".to_string(), diff --git a/src/cloud/aws.rs b/src/cloud/aws.rs index 16e311f..59cff4f 100644 --- a/src/cloud/aws.rs +++ b/src/cloud/aws.rs @@ -6,7 +6,7 @@ use hmac::{Hmac, Mac}; use serde::Deserialize; use sha2::{Digest, Sha256}; -use super::{CloudProvider, CloudService, CostData, CostSummary}; +use super::{CloudService, CostData, CostSummary, SourceId}; type HmacSha256 = Hmac; @@ -664,7 +664,7 @@ impl CloudService for AwsCloudService { Ok(CostSummary { account_id: self.account_id.clone(), account_name: self.account_name.clone(), - provider: CloudProvider::AWS, + source_id: SourceId::from("AWS"), current_month_cost, last_month_cost, currency, diff --git a/src/cloud/deepseek.rs b/src/cloud/deepseek.rs index ef1858d..ce1965e 100644 --- a/src/cloud/deepseek.rs +++ b/src/cloud/deepseek.rs @@ -3,7 +3,7 @@ use anyhow::{anyhow, Result}; use serde::Deserialize; -use super::{CloudProvider, CloudService, CostData, CostSummary, CostTrend, ServiceCost}; +use super::{CloudService, CostData, CostSummary, CostTrend, ServiceCost, SourceId}; /// DeepSeek balance info #[derive(Debug, Deserialize)] @@ -125,7 +125,7 @@ impl CloudService for DeepSeekService { Ok(CostSummary { account_id: self.account_id.clone(), account_name: self.account_name.clone(), - provider: CloudProvider::DeepSeek, + source_id: SourceId::from("DeepSeek"), current_month_cost: total, last_month_cost: 0.0, currency: balance_info.currency.clone(), diff --git a/src/cloud/mod.rs b/src/cloud/mod.rs index 64fe9d4..a31fa38 100644 --- a/src/cloud/mod.rs +++ b/src/cloud/mod.rs @@ -1,48 +1,30 @@ -//! Cloud provider module +//! Billing sources: accounts, the data they report, and the client trait. pub mod aliyun; pub mod aws; pub mod deepseek; +pub mod registry; use anyhow::Result; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -/// Cloud provider type -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[allow(clippy::upper_case_acronyms)] -pub enum CloudProvider { - #[default] - AWS, - Aliyun, - Azure, - GCP, - DeepSeek, -} +pub use registry::{SourceDescriptor, SourceId}; -impl CloudProvider { - /// Get full name of cloud provider - #[allow(dead_code)] - pub fn display_name(&self) -> &'static str { - match self { - CloudProvider::AWS => "Amazon Web Services", - CloudProvider::Aliyun => "Alibaba Cloud", - CloudProvider::Azure => "Microsoft Azure", - CloudProvider::GCP => "Google Cloud Platform", - CloudProvider::DeepSeek => "DeepSeek", - } - } +/// Shown in place of a source's name when its id is not in the registry. +/// Accounts like that are filtered out on load, so this is a backstop. +const UNKNOWN_SOURCE: &str = "Unknown"; - /// Get short name of cloud provider - pub fn short_name(&self) -> &'static str { - match self { - CloudProvider::AWS => "AWS", - CloudProvider::Aliyun => "Aliyun", - CloudProvider::Azure => "Azure", - CloudProvider::GCP => "GCP", - CloudProvider::DeepSeek => "DeepSeek", - } - } +/// The credentials and identity a [`CloudService`] is built from. +/// +/// Bundled into one struct so [`SourceDescriptor::build`] can be a plain +/// function pointer. +pub struct SourceContext { + pub account_id: String, + pub account_name: String, + pub access_key_id: String, + pub secret_access_key: String, + pub region: Option, } /// Cloud account information @@ -52,8 +34,8 @@ pub struct CloudAccount { pub id: String, /// Account name (user-defined) pub name: String, - /// Cloud provider - pub provider: CloudProvider, + /// Billing source this account belongs to; see [`registry`]. + pub source_id: SourceId, /// Access Key ID (encrypted storage) pub access_key_id: String, /// Secret Access Key (encrypted storage) @@ -68,6 +50,31 @@ pub struct CloudAccount { pub enabled: bool, } +impl CloudAccount { + /// The descriptor for this account's source, or `None` if the stored id + /// is not registered in this build. + pub fn descriptor(&self) -> Option<&'static SourceDescriptor> { + self.source_id.descriptor() + } + + /// Short label for the source, for badges and log lines. + pub fn short_name(&self) -> &'static str { + self.descriptor().map_or(UNKNOWN_SOURCE, |s| s.short_name) + } + + /// Credentials in the shape [`SourceDescriptor::build`] expects, with the + /// source's default region filled in when the account stored none. + pub fn context(&self, descriptor: &SourceDescriptor) -> SourceContext { + SourceContext { + account_id: self.id.clone(), + account_name: self.name.clone(), + access_key_id: self.access_key_id.clone(), + secret_access_key: self.secret_access_key.clone(), + region: descriptor.region_or_default(self.region.clone()), + } + } +} + /// Cost data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CostData { @@ -90,8 +97,8 @@ pub struct CostSummary { pub account_id: String, /// Account name pub account_name: String, - /// Cloud provider - pub provider: CloudProvider, + /// Billing source this summary came from + pub source_id: SourceId, /// Current month cost pub current_month_cost: f64, /// Last month cost @@ -106,6 +113,24 @@ pub struct CostSummary { pub last_month_details: Vec, } +impl CostSummary { + fn descriptor(&self) -> Option<&'static SourceDescriptor> { + self.source_id.descriptor() + } + + /// Short label for the source, for badges. + pub fn short_name(&self) -> &'static str { + self.descriptor().map_or(UNKNOWN_SOURCE, |s| s.short_name) + } + + /// Whether this is a point-in-time balance rather than a period cost. + /// The dashboard lists the two kinds in separate sections and labels + /// their amounts differently. + pub fn is_snapshot(&self) -> bool { + self.descriptor().is_some_and(SourceDescriptor::is_snapshot) + } +} + /// Service cost detail #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceCost { diff --git a/src/cloud/registry.rs b/src/cloud/registry.rs new file mode 100644 index 0000000..e6c7449 --- /dev/null +++ b/src/cloud/registry.rs @@ -0,0 +1,250 @@ +//! Billing source registry. +//! +//! A source is a row in [`SOURCES`], not an enum variant. Adding one means +//! adding a [`SourceDescriptor`] and a parser — nothing else in the codebase +//! learns its name. That matters because the roadmap adds model-provider +//! APIs, token plans and local agent usage on top of the public clouds, and +//! the previous `CloudProvider` enum had to be matched in 48 places. +//! +//! Callers ask the descriptor what a source can do rather than who it is: +//! DeepSeek renders as a balance because its [`Reporting`] is +//! [`Reporting::Snapshot`], not because it is called DeepSeek. + +use serde::{Deserialize, Serialize}; + +use super::{aliyun::AliyunCloudService, aws::AwsCloudService, deepseek::DeepSeekService}; +use super::{CloudService, SourceContext}; + +/// What a source reports, and therefore how it can be displayed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reporting { + /// Cost accrued over a period. `trend_window_days` is how far back a + /// daily trend is worth requesting — Alibaba Cloud needs one API call + /// per day, so it gets a shorter window than AWS. + Periodic { trend_window_days: i64 }, + /// A point-in-time balance. There is no period cost and no history to + /// chart. + Snapshot, +} + +/// Identifier of a billing source. +/// +/// Persisted verbatim in the `cloud_accounts` table, so these strings are +/// part of the on-disk format and must not be renamed without a migration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SourceId(String); + +impl SourceId { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The descriptor for this id, or `None` if no source is registered + /// under it — an account written by a newer build, or by a build that + /// still had the Azure and GCP enum variants. + pub fn descriptor(&self) -> Option<&'static SourceDescriptor> { + get(&self.0) + } +} + +impl From<&str> for SourceId { + fn from(id: &str) -> Self { + Self(id.to_string()) + } +} + +impl From for SourceId { + fn from(id: String) -> Self { + Self(id) + } +} + +/// Everything the application needs to know about a billing source. +pub struct SourceDescriptor { + /// Stable identifier; see [`SourceId`]. + pub id: &'static str, + pub display_name: &'static str, + pub short_name: &'static str, + /// Label for the public half of the credential. + pub access_key_label: &'static str, + /// Label for the secret half, or `None` when the source authenticates + /// with a single key. + pub secret_key_label: Option<&'static str>, + /// Region applied when the user leaves the field blank, or `None` when + /// the source has no notion of a region. + pub default_region: Option<&'static str>, + pub reporting: Reporting, + /// Builds the client. A function pointer keeps construction in this + /// table instead of a `match` in every caller. + pub build: fn(SourceContext) -> Box, +} + +impl SourceDescriptor { + pub fn source_id(&self) -> SourceId { + SourceId::from(self.id) + } + + /// Whether the credential form should require a secret key. + pub fn needs_secret_key(&self) -> bool { + self.secret_key_label.is_some() + } + + pub fn secret_key_placeholder(&self) -> &'static str { + self.secret_key_label + .unwrap_or("(Not required, leave empty)") + } + + pub fn region_placeholder(&self) -> String { + match self.default_region { + Some(region) => format!("Region (optional, default {})", region), + None => "(Not required)".to_string(), + } + } + + /// Region to use for an account that stored none. + pub fn region_or_default(&self, region: Option) -> Option { + region.or_else(|| self.default_region.map(str::to_string)) + } + + /// Days of daily trend worth requesting, or `None` for a source that has + /// no history to chart. + pub fn trend_window_days(&self) -> Option { + match self.reporting { + Reporting::Periodic { trend_window_days } => Some(trend_window_days), + Reporting::Snapshot => None, + } + } + + /// Whether this source reports a balance rather than a period cost. + pub fn is_snapshot(&self) -> bool { + matches!(self.reporting, Reporting::Snapshot) + } +} + +static SOURCES: &[SourceDescriptor] = &[ + SourceDescriptor { + id: "AWS", + display_name: "Amazon Web Services", + short_name: "AWS", + access_key_label: "Access Key ID", + secret_key_label: Some("Secret Access Key"), + default_region: Some("us-east-1"), + reporting: Reporting::Periodic { + trend_window_days: 30, + }, + build: |ctx| { + Box::new(AwsCloudService::new( + ctx.account_id, + ctx.account_name, + ctx.access_key_id, + ctx.secret_access_key, + ctx.region, + )) + }, + }, + SourceDescriptor { + id: "Aliyun", + display_name: "Alibaba Cloud", + short_name: "Aliyun", + access_key_label: "AccessKey ID", + secret_key_label: Some("AccessKey Secret"), + default_region: Some("cn-hangzhou"), + // One API call per day, so a shorter window than AWS. + reporting: Reporting::Periodic { + trend_window_days: 7, + }, + build: |ctx| { + Box::new(AliyunCloudService::new( + ctx.account_id, + ctx.account_name, + ctx.access_key_id, + ctx.secret_access_key, + ctx.region, + )) + }, + }, + SourceDescriptor { + id: "DeepSeek", + display_name: "DeepSeek", + short_name: "DeepSeek", + access_key_label: "API Key", + secret_key_label: None, + default_region: None, + reporting: Reporting::Snapshot, + build: |ctx| { + Box::new(DeepSeekService::new( + ctx.account_id, + ctx.account_name, + ctx.access_key_id, + ctx.secret_access_key, + ctx.region, + )) + }, + }, +]; + +/// Every registered source, in the order they are offered in the UI. +pub fn all() -> &'static [SourceDescriptor] { + SOURCES +} + +/// The descriptor registered under `id`, if any. +pub fn get(id: &str) -> Option<&'static SourceDescriptor> { + SOURCES.iter().find(|source| source.id == id) +} + +/// The source offered first when adding an account. +pub fn default_source() -> &'static SourceDescriptor { + &SOURCES[0] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Ids are persisted in `cloud_accounts`, so a duplicate would make one of + /// the two sources unreachable and silently reroute existing accounts. + #[test] + fn ids_are_unique() { + let mut seen = Vec::new(); + for source in all() { + assert!( + !seen.contains(&source.id), + "duplicate source id {}", + source.id + ); + seen.push(source.id); + } + } + + /// The round trip an account takes: descriptor -> stored id -> descriptor. + #[test] + fn every_descriptor_resolves_from_its_own_id() { + for source in all() { + let resolved = source + .source_id() + .descriptor() + .unwrap_or_else(|| panic!("{} does not resolve", source.id)); + assert_eq!(resolved.id, source.id); + } + } + + /// A source with no region must not offer one, or the credential form + /// would ask for a value that is silently discarded. + #[test] + fn region_placeholder_matches_default_region() { + for source in all() { + match source.default_region { + Some(region) => { + assert!( + source.region_placeholder().contains(region), + "{}", + source.id + ) + } + None => assert_eq!(source.region_placeholder(), "(Not required)"), + } + } + } +} diff --git a/src/db.rs b/src/db.rs index 8a351b0..c84ba14 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,8 +6,8 @@ use duckdb::{params, Connection}; use std::sync::{Arc, Mutex}; use crate::cloud::{ - BudgetInfo, BudgetStatus, CloudAccount, CloudProvider, CostData, CostSummary, CostTrend, - DailyCost, ServiceCost, + BudgetInfo, BudgetStatus, CloudAccount, CostData, CostSummary, CostTrend, DailyCost, + ServiceCost, SourceId, }; use crate::config::get_database_path; use crate::crypto::get_crypto_manager; @@ -31,6 +31,8 @@ pub fn init_database() -> Result<()> { CREATE TABLE IF NOT EXISTS cloud_accounts ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, + -- Holds a registry SourceId. Column name predates the registry; + -- PR2 rebuilds this schema, so it is not worth a migration now. provider VARCHAR NOT NULL, access_key_id VARCHAR NOT NULL, secret_access_key VARCHAR NOT NULL, @@ -155,7 +157,7 @@ pub fn save_account(account: &CloudAccount) -> Result<()> { params![ account.id, account.name, - format!("{:?}", account.provider), + account.source_id.as_str(), "", "", account.region, @@ -180,15 +182,7 @@ pub fn get_all_accounts() -> Result> { let accounts = stmt .query_map([], |row| { - let provider_str: String = row.get(2)?; - let provider = match provider_str.as_str() { - "AWS" => CloudProvider::AWS, - "Aliyun" => CloudProvider::Aliyun, - "Azure" => CloudProvider::Azure, - "GCP" => CloudProvider::GCP, - "DeepSeek" => CloudProvider::DeepSeek, - _ => CloudProvider::AWS, - }; + let source_id = SourceId::from(row.get::<_, String>(2)?); let encrypted_ak: String = row.get(3)?; let encrypted_sk: String = row.get(4)?; @@ -199,7 +193,7 @@ pub fn get_all_accounts() -> Result> { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, - provider, + source_id, encrypted_ak, encrypted_sk, row.get::<_, Option>(5)?, @@ -214,7 +208,7 @@ pub fn get_all_accounts() -> Result> { for ( id, name, - provider, + source_id, encrypted_ak, encrypted_sk, region, @@ -223,6 +217,20 @@ pub fn get_all_accounts() -> Result> { enabled, ) in accounts { + // An id with no descriptor comes from a build that knew a source this + // one does not. Skip the row rather than guessing: silently reading it + // as some other provider would sign requests with the wrong scheme and + // file the resulting costs under the wrong source. + if source_id.descriptor().is_none() { + tracing::warn!( + "Skipping account {} ({}): no billing source registered under '{}'", + name, + id, + source_id.as_str() + ); + continue; + } + // Try to load secrets from OS keyring first (migration path). If not present, fall back to // decrypting existing values from DB and migrate them into keyring. let (access_key_id, secret_access_key) = match secret_store::get_account_secrets(&id)? { @@ -257,7 +265,7 @@ pub fn get_all_accounts() -> Result> { result.push(CloudAccount { id, name, - provider, + source_id, access_key_id, secret_access_key, region, @@ -359,7 +367,7 @@ pub fn get_all_cost_summaries() -> Result> { summaries.push(CostSummary { account_id: account.id, account_name: account.name, - provider: account.provider, + source_id: account.source_id, current_month_cost: 0.0, last_month_cost: 0.0, currency: "USD".to_string(), @@ -375,11 +383,11 @@ pub fn get_all_cost_summaries() -> Result> { // ==================== Cache Functions ==================== /// Check if cost summary cache is valid -/// account_name and provider are passed by the caller to avoid deadlock when acquiring lock while holding database lock +/// account_name and source_id are passed by the caller to avoid deadlock when acquiring lock while holding database lock pub fn get_cached_cost_summary_with_account( account_id: &str, account_name: &str, - provider: &CloudProvider, + source_id: &SourceId, ) -> Result> { let db = get_connection()?; let conn = db.as_ref().unwrap(); @@ -444,7 +452,7 @@ pub fn get_cached_cost_summary_with_account( Ok(Some(CostSummary { account_id: account_id.to_string(), account_name: account_name.to_string(), - provider: *provider, + source_id: source_id.clone(), current_month_cost: current, last_month_cost: last, currency, @@ -759,7 +767,7 @@ pub fn get_budget_status(account_id: &str) -> Result> { // Get cached cost summary let cost_summary = - get_cached_cost_summary_with_account(account_id, &account.name, &account.provider)?; + get_cached_cost_summary_with_account(account_id, &account.name, &account.source_id)?; let current_cost = cost_summary.map(|cs| cs.current_month_cost).unwrap_or(0.0); diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 4c04a79..c29bc46 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -10,7 +10,8 @@ use gpui_component::{ }; use uuid::Uuid; -use crate::cloud::{CloudAccount, CloudProvider}; +use crate::cloud::registry::{self, SourceDescriptor}; +use crate::cloud::CloudAccount; use crate::db; /// Account Management View @@ -19,8 +20,6 @@ pub struct AccountsView { accounts: Vec, /// Whether to show add dialog show_add_dialog: bool, - /// New account form - new_account_form: NewAccountForm, /// Error message error: Option, /// Success message @@ -30,19 +29,8 @@ pub struct AccountsView { ak_input: Entity, sk_input: Entity, region_input: Entity, - /// Currently selected cloud provider - selected_provider: CloudProvider, -} - -/// New account form data (internal use) -#[derive(Default, Clone)] -#[allow(dead_code)] -struct NewAccountForm { - name: String, - provider: CloudProvider, - access_key_id: String, - secret_access_key: String, - region: String, + /// Billing source selected in the add dialog + selected_source: &'static SourceDescriptor, } impl AccountsView { @@ -59,14 +47,13 @@ impl AccountsView { let mut view = Self { accounts: Vec::new(), show_add_dialog: false, - new_account_form: NewAccountForm::default(), error: None, success: None, name_input, ak_input, sk_input, region_input, - selected_provider: CloudProvider::AWS, + selected_source: registry::default_source(), }; view.load_accounts(); @@ -87,60 +74,33 @@ impl AccountsView { fn show_add_dialog(&mut self, cx: &mut Context) { self.show_add_dialog = true; - self.new_account_form = NewAccountForm::default(); - self.selected_provider = CloudProvider::AWS; + self.selected_source = registry::default_source(); self.error = None; self.success = None; cx.notify(); } - fn set_provider( + fn set_source( &mut self, - provider: CloudProvider, + source: &'static SourceDescriptor, window: &mut Window, cx: &mut Context, ) { - self.selected_provider = provider; - // Update input placeholders based on cloud provider - match provider { - CloudProvider::AWS => { - self.ak_input.update(cx, |state, cx| { - state.set_placeholder("Access Key ID", window, cx); - }); - self.sk_input.update(cx, |state, cx| { - state.set_placeholder("Secret Access Key", window, cx); - }); - self.region_input.update(cx, |state, cx| { - state.set_placeholder("Region (optional, default us-east-1)", window, cx); - state.set_value("us-east-1", window, cx); - }); - } - CloudProvider::Aliyun => { - self.ak_input.update(cx, |state, cx| { - state.set_placeholder("AccessKey ID", window, cx); - }); - self.sk_input.update(cx, |state, cx| { - state.set_placeholder("AccessKey Secret", window, cx); - }); - self.region_input.update(cx, |state, cx| { - state.set_placeholder("Region (optional, default cn-hangzhou)", window, cx); - state.set_value("cn-hangzhou", window, cx); - }); - } - CloudProvider::DeepSeek => { - self.ak_input.update(cx, |state, cx| { - state.set_placeholder("API Key", window, cx); - }); - self.sk_input.update(cx, |state, cx| { - state.set_placeholder("(Not required, leave empty)", window, cx); - }); - self.region_input.update(cx, |state, cx| { - state.set_placeholder("(Not required)", window, cx); - }); - } + self.selected_source = source; + + // Every label comes from the descriptor, so a new source needs no + // change here. + self.ak_input.update(cx, |state, cx| { + state.set_placeholder(source.access_key_label, window, cx); + }); + self.sk_input.update(cx, |state, cx| { + state.set_placeholder(source.secret_key_placeholder(), window, cx); + }); + self.region_input.update(cx, |state, cx| { + state.set_placeholder(source.region_placeholder(), window, cx); + state.set_value(source.default_region.unwrap_or_default(), window, cx); + }); - _ => {} - } cx.notify(); } @@ -167,8 +127,7 @@ impl AccountsView { cx.notify(); return; } - // DeepSeek doesn't require secret key - if sk.is_empty() && !matches!(self.selected_provider, CloudProvider::DeepSeek) { + if sk.is_empty() && self.selected_source.needs_secret_key() { self.error = Some("Please enter Secret Access Key".to_string()); cx.notify(); return; @@ -177,7 +136,7 @@ impl AccountsView { let account = CloudAccount { id: Uuid::new_v4().to_string(), name, - provider: self.selected_provider, + source_id: self.selected_source.source_id(), access_key_id: ak, secret_access_key: sk, region: if region.is_empty() { @@ -218,77 +177,31 @@ impl AccountsView { } fn validate_account(&mut self, account: &CloudAccount, cx: &mut Context) { + let Some(descriptor) = account.descriptor() else { + self.error = Some(format!( + "Account {} uses an unknown billing source", + account.name + )); + self.success = None; + cx.notify(); + return; + }; + let account_name = account.name.clone(); - let access_key_id = account.access_key_id.clone(); - let secret_access_key = account.secret_access_key.clone(); - let account_id = account.id.clone(); - let provider = account.provider; - - // Set default region based on cloud provider - let region = account.region.clone().unwrap_or_else(|| match provider { - CloudProvider::AWS => "us-east-1".to_string(), - CloudProvider::Aliyun => "cn-hangzhou".to_string(), - CloudProvider::DeepSeek => String::new(), - _ => "us-east-1".to_string(), - }); + let context = account.context(descriptor); // Show validating status self.success = Some(format!("Validating account {}...", account_name)); self.error = None; cx.notify(); - let account_name_clone = account_name.clone(); - // Use standard thread to handle sync HTTP requests let (tx, rx) = std::sync::mpsc::channel::>(); std::thread::spawn(move || { - use crate::cloud::CloudService; - - let result: Result = match provider { - CloudProvider::AWS => { - let service = crate::cloud::aws::AwsCloudService::new( - account_id, - account_name, - access_key_id, - secret_access_key, - Some(region), - ); - match service.validate_credentials() { - Ok(valid) => Ok(valid), - Err(e) => Err(e.to_string()), - } - } - CloudProvider::Aliyun => { - let service = crate::cloud::aliyun::AliyunCloudService::new( - account_id, - account_name, - access_key_id, - secret_access_key, - Some(region), - ); - match service.validate_credentials() { - Ok(valid) => Ok(valid), - Err(e) => Err(e.to_string()), - } - } - CloudProvider::DeepSeek => { - let service = crate::cloud::deepseek::DeepSeekService::new( - account_id, - account_name, - access_key_id, - secret_access_key, - Some(region), - ); - match service.validate_credentials() { - Ok(valid) => Ok(valid), - Err(e) => Err(e.to_string()), - } - } - - _ => Err("Unsupported cloud provider".to_string()), - }; - + let result = (descriptor.build)(context) + .validate_credentials() + .map_err(|e| e.to_string()); let _ = tx.send(result); }); @@ -305,17 +218,13 @@ impl AccountsView { this.update(cx, |this, cx| { match validation_result { Ok(true) => { - this.success = Some(format!( - "Account {} validated successfully!", - account_name_clone - )); + this.success = + Some(format!("Account {} validated successfully!", account_name)); this.error = None; } Ok(false) => { - this.error = Some(format!( - "Account {} credentials invalid", - account_name_clone - )); + this.error = + Some(format!("Account {} credentials invalid", account_name)); this.success = None; } Err(e) => { @@ -332,80 +241,35 @@ impl AccountsView { .detach(); } - fn render_provider_selector(&self, cx: &Context) -> impl IntoElement { - let is_aws_selected = matches!(self.selected_provider, CloudProvider::AWS); - let is_aliyun_selected = matches!(self.selected_provider, CloudProvider::Aliyun); - let is_deepseek_selected = matches!(self.selected_provider, CloudProvider::DeepSeek); - + fn render_source_selector(&self, cx: &Context) -> impl IntoElement { div() .h_flex() .gap_2() - .child( - div() - .px_4() - .py_2() - .rounded_md() - .cursor_pointer() - .when(is_aws_selected, |el| { - el.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) - }) - .when(!is_aws_selected, |el| { - el.bg(cx.theme().muted) - .text_color(cx.theme().muted_foreground) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, window, cx| { - this.set_provider(CloudProvider::AWS, window, cx); - }), - ) - .child("AWS"), - ) - .child( - div() - .px_4() - .py_2() - .rounded_md() - .cursor_pointer() - .when(is_aliyun_selected, |el| { - el.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) - }) - .when(!is_aliyun_selected, |el| { - el.bg(cx.theme().muted) - .text_color(cx.theme().muted_foreground) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, window, cx| { - this.set_provider(CloudProvider::Aliyun, window, cx); - }), - ) - .child("Aliyun"), - ) - .child( + .children(registry::all().iter().map(|source| { + let is_selected = source.id == self.selected_source.id; + div() + .id(SharedString::from(source.id)) .px_4() .py_2() .rounded_md() .cursor_pointer() - .when(is_deepseek_selected, |el| { + .when(is_selected, |el| { el.bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) }) - .when(!is_deepseek_selected, |el| { + .when(!is_selected, |el| { el.bg(cx.theme().muted) .text_color(cx.theme().muted_foreground) }) .on_mouse_down( MouseButton::Left, - cx.listener(|this, _, window, cx| { - this.set_provider(CloudProvider::DeepSeek, window, cx); + cx.listener(move |this, _, window, cx| { + this.set_source(source, window, cx); }), ) - .child("DeepSeek"), - ) + .child(source.short_name) + })) } fn render_header(&self, cx: &Context) -> impl IntoElement { @@ -450,6 +314,7 @@ impl AccountsView { fn render_account_row(&self, account: &CloudAccount, cx: &Context) -> impl IntoElement { let account_id = account.id.clone(); let account_for_validate = account.clone(); + let short_name = account.short_name(); div() .w_full() @@ -476,7 +341,7 @@ impl AccountsView { .bg(cx.theme().accent.opacity(0.1)) .text_color(cx.theme().accent) .text_center() - .child(account.provider.short_name()), + .child(short_name), ) .child( div() @@ -585,7 +450,7 @@ impl AccountsView { .v_flex() .gap_1() .child(div().text_sm().child("Cloud Provider")) - .child(self.render_provider_selector(cx)), + .child(self.render_source_selector(cx)), ) .child( div() diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index f74e709..031e156 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -73,7 +73,7 @@ impl DashboardView { match crate::db::get_cached_cost_summary_with_account( &account.id, &account.name, - &account.provider, + &account.source_id, ) { Ok(Some(cached)) => { summaries.push(cached); @@ -83,93 +83,27 @@ impl DashboardView { Err(_) => {} } - match account.provider { - crate::cloud::CloudProvider::AWS => { - let service = crate::cloud::aws::AwsCloudService::new( - account.id.clone(), - account.name.clone(), - account.access_key_id.clone(), - account.secret_access_key.clone(), - account.region.clone(), - ); - - use crate::cloud::CloudService; - match service.get_cost_summary() { - Ok(summary) => { - // Save to cache - if let Err(e) = crate::db::save_cost_summary_cache(&summary) - { - tracing::warn!("Failed to save cost cache: {}", e); - } - summaries.push(summary); - } - Err(e) => { - tracing::error!( - "Failed to get cost for {}: {}", - account.name, - e - ); - } - } - } - crate::cloud::CloudProvider::Aliyun => { - let service = crate::cloud::aliyun::AliyunCloudService::new( - account.id.clone(), - account.name.clone(), - account.access_key_id.clone(), - account.secret_access_key.clone(), - account.region.clone(), - ); + let Some(descriptor) = account.descriptor() else { + continue; + }; + let service = (descriptor.build)(account.context(descriptor)); - use crate::cloud::CloudService; - match service.get_cost_summary() { - Ok(summary) => { - // Save to cache - if let Err(e) = crate::db::save_cost_summary_cache(&summary) - { - tracing::warn!("Failed to save cost cache: {}", e); - } - summaries.push(summary); - } - Err(e) => { - tracing::error!( - "Failed to get Aliyun {} cost: {}", - account.name, - e - ); - } + match service.get_cost_summary() { + Ok(summary) => { + // Save to cache + if let Err(e) = crate::db::save_cost_summary_cache(&summary) { + tracing::warn!("Failed to save cost cache: {}", e); } + summaries.push(summary); } - crate::cloud::CloudProvider::DeepSeek => { - let service = crate::cloud::deepseek::DeepSeekService::new( - account.id.clone(), - account.name.clone(), - account.access_key_id.clone(), - account.secret_access_key.clone(), - account.region.clone(), + Err(e) => { + tracing::error!( + "Failed to get {} cost for {}: {}", + descriptor.short_name, + account.name, + e ); - - use crate::cloud::CloudService; - match service.get_cost_summary() { - Ok(summary) => { - // Save to cache - if let Err(e) = crate::db::save_cost_summary_cache(&summary) - { - tracing::warn!("Failed to save cost cache: {}", e); - } - summaries.push(summary); - } - Err(e) => { - tracing::error!( - "Failed to get DeepSeek {} balance: {}", - account.name, - e - ); - } - } } - - _ => {} } } let _ = tx.send(Ok(summaries)); @@ -319,13 +253,10 @@ impl DashboardView { .mt_4() .child("Cost Details by Account"), ) - // Account cards for cost-type providers + // Sources that report a period cost .child({ - let cost_summaries: Vec<&CostSummary> = self - .summaries - .iter() - .filter(|s| !matches!(s.provider, crate::cloud::CloudProvider::DeepSeek)) - .collect(); + let cost_summaries: Vec<&CostSummary> = + self.summaries.iter().filter(|s| !s.is_snapshot()).collect(); div().w_full().v_flex().gap_4().children( cost_summaries @@ -338,13 +269,10 @@ impl DashboardView { }), ) }) - // Balance section for providers that report balances (DeepSeek) + // Sources that report a point-in-time balance instead .child({ - let balance_summaries: Vec<&CostSummary> = self - .summaries - .iter() - .filter(|s| matches!(s.provider, crate::cloud::CloudProvider::DeepSeek)) - .collect(); + let balance_summaries: Vec<&CostSummary> = + self.summaries.iter().filter(|s| s.is_snapshot()).collect(); if balance_summaries.is_empty() { div() @@ -483,7 +411,7 @@ impl DashboardView { .rounded_md() .bg(cx.theme().accent.opacity(0.1)) .text_color(cx.theme().accent) - .child(summary.provider.short_name()), + .child(summary.short_name()), ), ) // Cost overview @@ -493,12 +421,11 @@ impl DashboardView { .justify_between() .child({ // Format per-account amount using account currency. - let label = - if matches!(summary.provider, crate::cloud::CloudProvider::DeepSeek) { - "Balance" - } else { - "This Month" - }; + let label = if summary.is_snapshot() { + "Balance" + } else { + "This Month" + }; let symbol = match summary.currency.as_str() { "CNY" => "¥", @@ -681,21 +608,20 @@ impl DashboardView { std::thread::spawn(move || { use chrono::{Datelike, Duration, Utc}; - let now = Utc::now(); - // AWS: 30 days, Aliyun: 7 days (Aliyun requires per-day API calls which is slower) - // DeepSeek: no trend data available - let days = match account.provider { - crate::cloud::CloudProvider::Aliyun => 7, - crate::cloud::CloudProvider::DeepSeek => 0, // No trend data - _ => 30, + let Some(descriptor) = account.descriptor() else { + let _ = tx.send(Err("Unknown billing source".to_string())); + return; }; - // DeepSeek doesn't support trend data - if matches!(account.provider, crate::cloud::CloudProvider::DeepSeek) { - let _ = tx.send(Err("DeepSeek does not provide usage history".to_string())); + let Some(days) = descriptor.trend_window_days() else { + let _ = tx.send(Err(format!( + "{} does not provide usage history", + descriptor.display_name + ))); return; - } + }; + let now = Utc::now(); let start = now - Duration::days(days); let start_date = format!("{}-{:02}-{:02}", start.year(), start.month(), start.day()); let end_date = format!("{}-{:02}-{:02}", now.year(), now.month(), now.day()); @@ -708,55 +634,20 @@ impl DashboardView { return; } - match account.provider { - crate::cloud::CloudProvider::AWS => { - let service = crate::cloud::aws::AwsCloudService::new( - account.id.clone(), - account.name.clone(), - account.access_key_id.clone(), - account.secret_access_key.clone(), - account.region.clone(), - ); - - use crate::cloud::CloudService; - match service.get_cost_trend(&start_date, &end_date) { - Ok(trend) => { - // Save to cache - if let Err(e) = crate::db::save_cost_trend_cache(&trend) { - tracing::warn!("Failed to save trend cache: {}", e); - } - let _ = tx.send(Ok(trend)); - } - Err(e) => { - let _ = tx.send(Err(format!("Failed to get trend data: {}", e))); - } + let service = (descriptor.build)(account.context(descriptor)); + match service.get_cost_trend(&start_date, &end_date) { + Ok(trend) => { + // Save to cache + if let Err(e) = crate::db::save_cost_trend_cache(&trend) { + tracing::warn!("Failed to save trend cache: {}", e); } + let _ = tx.send(Ok(trend)); } - crate::cloud::CloudProvider::Aliyun => { - let service = crate::cloud::aliyun::AliyunCloudService::new( - account.id.clone(), - account.name.clone(), - account.access_key_id.clone(), - account.secret_access_key.clone(), - account.region.clone(), - ); - - use crate::cloud::CloudService; - match service.get_cost_trend(&start_date, &end_date) { - Ok(trend) => { - // Save to cache - if let Err(e) = crate::db::save_cost_trend_cache(&trend) { - tracing::warn!("Failed to save trend cache: {}", e); - } - let _ = tx.send(Ok(trend)); - } - Err(e) => { - let _ = tx.send(Err(format!("Failed to get Aliyun trend data: {}", e))); - } - } - } - _ => { - let _ = tx.send(Err("This cloud provider is not supported".to_string())); + Err(e) => { + let _ = tx.send(Err(format!( + "Failed to get {} trend data: {}", + descriptor.short_name, e + ))); } } }); From e3385ce5e0cb775ec48f4b176bdbe8fe00ae53b8 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Fri, 28 Aug 2026 15:56:58 +0800 Subject: [PATCH 2/4] Revamp the docs site - Add DeepSeek to hero, stats, features, and FAQ to match the README - Add macOS Intel download button and fix .zip asset names - Add install notes (SmartScreen, chmod/xattr) to the download section - Show the app screenshot below the hero - Turn the FAQ into native
accordions and add an IAM permissions entry linking to docs/policies.md - Add OG meta tags, theme-color, and an inline SVG favicon - Rework buttons (2px borders, translateY hover), unify card styling, add scroll-margin-top, skip link, and
landmark --- docs/cloudbridge.png | Bin 0 -> 54572 bytes docs/index.html | 831 +++++++++++++++++++++++++------------------ 2 files changed, 489 insertions(+), 342 deletions(-) create mode 100644 docs/cloudbridge.png diff --git a/docs/cloudbridge.png b/docs/cloudbridge.png new file mode 100644 index 0000000000000000000000000000000000000000..9d6c6a2c9c38f6f31334031128e9e197519e53f0 GIT binary patch literal 54572 zcmeFZbyQUC+crFifkh~i0wPLCgVH66L8?f1cXy*w3JB7ObV*CcP)bTS(yeq3H8Aj= z<9+|0@BP;L-nHK6ukW8{uceMNv-e#4isL-a<2Z6 z*RO|b4o)pXc}BgZO@=<)x&P5#rE&c6``g15isxkJDJ;+CxrUDu_l%~o`X$kG-jH~1 zN^vnhCd1{2zhZdGxxxo5sAm7Oy|*5JSAVEz8T$D4&zeqmmk+^aHi)!ujL87&;R>6<}Kp>zc1^F&nErn>lwEYSO0yR;~b9hzc0O+@WgQceJ%YE zLGkZP?Mwf2X$0c`)FG%;gnfXlt!+`Ae?Rvr{x8dNBSS-a2J8_X8R>!=ouAjoOv_Nv z{?nV@w3ZbkRIgvZzR71jJ>fK!mzC9Pm3ZHzkIHGB)9d7TsXsGL*sr$SY&c)^^su|j z)3pUYv2AA!XOK(av(YU;Di`VhoN4f1m}~GSsM{SQ3-L zFJy%x)fs?xHuvaGDLl$4a%J2@TcYm3^80`-Y4Hb6Vt5DXrTK!d+;HXnRmXn>mys}ct`ts#VPEO9`jn}80?)vtFcE$tA#ssRU)mKF}v zfv=SXu{8KM$#S9Z$|NsuxyFbEOa4k(&1EyZ@f(uJ9%ROl-h zZ&E$kXV0EVN%fx@;zCT!wVIik(YUR0rb|D4_N>*eTX=YG^CtCf+Vk)aA3mV;;yCsE zYNG<|;Vu{2qgPro9BN#5P5GVw-lq0>ax`k6@ss-G^$iX8gk1kNdAf3a-7=*c^MS=@ z_T;3gsp)Z|&b_!!MRoP@mUuK5uBsNvBRrcakNv}J19I61evfZJEA9St3ERy2$VmzV zT(D?V4UEVMf52O5SFDCrkFK!R)J#}Oo3b{5R zis5{Ow*@UU6Sf>HTT5_T->UVy__sYTUbv*vRM{_u9r^Mp=BXMiYa?hIvlVjC6@>om z+(~lr0_k1?4$H5#N~_#nh~728V_mx96YB^*a;Lh)(^SMH2+2|Z)gB}yv`XM)pLTS zF8MxWSko;Jj>1Atc6RnVcN&|_YWSC7+|4k4K0ehuU4j4ya6EYM;OEbuqn2{QyRSq1 z|K0~eNPOL{ZsB)F0&Y0bwozT4-E}1_xl)R+9qRB$0@re4LTT-y}UM94uGQt{{iehm&T!@aZ>4{6t&&wM;{j6A^na6$_535Aj z($sX@BAxi3@qd|h0d<$x>p`xm{Egd!I*&oaO`n4xhG&3XP-ijDKJ^iv> z^KLc2=|XAS!(b{w#Zwe!f68eH|L^ZE`sOGVpxyASJ36&wq@)5z=y1|RPV{yRt{O`` zdh|{@VK?m62pui$_X@e!uOm)OJUnWm75sJ;;(Q4q-Y7caUAFDSfqS9Ypb57^fYpx*o#wQ>cZkf%LO9a5}aNDR+ z8q+Nv;o{=DN&PuGQu<~sH-PAR^-x^uQ1o1V=qq{o{*jR-*Tjm7idu5%f(N|3Gy?X| z>I@dzA`^J}MnV;1F0iR&Z941$7VIxifodz7j+2T)7> zk*{aDjk*r*=!{bdH($`${QZ@a7;de(GgQ~z-5r*9M=ZB+C(A!{tX^!U9bpgPb7G>V zHum8LbD8DDas!(WAwcW{!{0pj?=L2PqMm2A%M4~<4ov;C2!U<-&dM? zrzelJhOnEr3yzAbg}v#h_NU77zG4l9YAoP1px@9$JYkcmO)AQ1<0 zp*R8q5EdRDJTnSt-q9gFEH(JNE&CmS^ox|&rKP2?m`CMv1_f+pAN$L!{VIHPT9TXF zp@LsjHU!-YmRS&tc}~FSOGd0y8rfu-^b?W=9H8anK+&sH*V~zWWvwGi{Rj3 zk0HAAp=oJ%0ES<@2#tywijL1nPj6~$M5%;$bVT3f^C7&>Alm_4O8kpI0ZkNBIhSZv zUY-o|T#~-DpqX8B5V>lfCCnxmVz0w?ttU^OT%yIl$@k4-j0ba4ci3rF(jxh~JS&Td zpWkJvFCDH7Ls-^c>SaQ?T4FJNZ6}o$_`3njP0siRYa%gf7OzkWTE75&|~*ZMh))!^l;SFthX zX|jY<-Qd(dY|guXX-QQlum$ zQL#&8MT+_AS>}afYY=`{h6^C)Q}8Tt+h3OmBUW6*w&Q_Q|w zxTfOEm$a$f5Klh_1=&~l+^A-ti^KN<0J~3!+XI*3wXAmD()IAbSb6SHTAislckUb) z+I_zDQ+__%LJp~OL(5%0m|{`U(WKX|&E}kM`ACH{>ZUeg2%>-RV9>Cbi87j1?YH?I zVAA3AGPXmba3^s0@fcL;u%?|b8Lxq%A^Mmn9N-U0rfF)L9oT3GF}`XuV8s(h#@KkV zFI@^I%MB_ji{qv1azefPkN%Ru3ahDF4+3FSC@)Sh@07bd9;d}zE_+QdJuF&F@=tEpDD-WIp1L9__D=a67G!L$F#d=Y=X=-kso1dSblLK&JVQL!YHMX_2rPmx3E9_P#_`H8%VZo{nQ?_kO`xpQklA49n z`@*mWSy)*i8-sZJ;p0bYz|L;ZJ#JpD98nULL=8o{_aPyPkbjw)nm&H)BUm8pygB`e z?kTXL`Sv`;oLrB@u&~)#sRuGHE{D)Q(SvE+bYJLHAD^>EdcP`cQcp~roexqZ0`<+X zU@`wTpFs5Z+ZO3ddHG_Pgp*V(F!{Vsv`0%!&&F-AuxfpHDFo$l49*^OE4vamlnYD>FI}t2)mz_<%#@u8O6oL zwY8cjeGNZ;NPj46Sp4C8Swlku!nMbg!>d;l zt6+QEHvM*}Q2y%b>hZC=zupUcbx6eQY;7T*nVn^votsn2S6|xvAzoXZme#0z*ci|0 z{zq`?rNAiCslb+{&1}ul4WMv+0vVjA!V*V07aCzc;hzayX) zbdz;i!CL>w;Eqiw`;3o3yUmmR~{gj^*hr9T4BdBMr4sSWXZ zOsuR9B+p63f2}!^ZYZ0Xt;{wB%D;N$x_S=!O-wXR;>wpnOG|U}W0*=lPDD3G0t(N? zqUe3B<4rR2k``TgdDSQdO00+>Sl&vl5nkW0Ca;f)iGkt8iw->7R_k8$VY)Zpb2l2?Pc1Gf3Zxy&WJk>z5PFn+gjJ z?O}NLmsd>^Xz+uFACSgws&^iSeUn3 zL{EAh=)?n9)yl}2KU9!=47DylX(lU)gU^jRKM&3g&*kSh0PWs8D1Fl~|5r4_9j)~^ z;xYe~6!Mu>v-)7hpJqrGiM}Hd*GX@c7j@Vg$74}6V$L;#CapVYg0D967NyJj*ey5sG{n|VG`ldLI!u2S;@!W-%Joxmd3_LWfl?(DKoR2lXW8%w8E4U4AF+? zzF_#Pkm8~uAi$+tz{F2lKhenvZTSchvhJ%KWr^HSrB`ex-o_m513+A)^+cu8m}Ir( zQx0RV+0^uNUvk}Xw()(X-Jg4p6eh_>Mj=RS^zSzi;EByq;!_L7L`ChvVxhlt$N1AC z;7wFS1fxPSH#hg8E}_U)1I@{cky(yD(^A#&@NnqQ2Rc8_9+n%iblNQT(58FZ+t?KL zsY*&pDk?^eXBHL~CMWAa1QX6UZ}uS7K)I~CT0QIjZJ%E?ZsmbzC{$sbP$<-f*XdE$ zs0)l^1OioL&>pn$9KhlD6q%Z;w=$I1hR>{+8^dMrp~2f(QnC$13(tcF8dIwuu;e3O zkrmXPRIMONY<{`=`ua9gfj}VVg_bwM#gcUn@7A7g6=jxj8DGCHLJCbPE*-<6GnA{c zjXV6iGme>=SvdQ{W(Rk-v4%15Cs@${)(s5}8?{I4U0q$!i8O+a<|oI8kZX>%(6HKq zT(sKS+Un=$w-53f%v1%{mDN>g85sS0k8Dn5D*fo zuBdQEE%uVKYvvXdY%DKVt(A?jkhG(BS6E2`11T*u;vSeq5zfNGl_Yk>D91yKop z^^mz@DI@Ml5Sh)ehSLpVVw8aiJ~yGaUpDYxUf$u|s|P@D+FDx|i)nSNS8OJ#;T-=5 zcVQu4r=)ZPjdSr)EH)YDJ(xPhOts~dSxw~&e*}072#Db}`Y0GUW@unw@~5dSk~z5P zencBBK0r1kjHYzzm9_>tI>|*hbBeC$!HY1-T$m`W9UZTiHA>3LAZ1Sf{1hgZtn9~Y zk#NP(hM|@u;H-IGsb30J^B_S^fGI+llAQc-EPtGp*5{d=+%Q2ipEgVb1TKtU69^+d zgdqL(Pf2L}rGwqV`{Tfk7oLzm0G7dg?_L;XcY-jp`jB_j8|}QRk_pS`)3(K8k!Tl4 zDzmb(U~sXMZCqJRjXAX&;8(FZ1NG|lYx{QAa?pXr4EZ2+8F_7Ev-RS#n7_Y&`{IsK zqQ_p27Dlq&>ErwNhnNj7QCF0)Kl4#%f-o{KZ|(a9n%wN{zP?1Sqp_*B#T~5f`k^Eu zGBRD*b1sqGd z0|akN1qycd5geNxF`TJizXqU;P_b)c<$^i%L=+SPdd*ibQ3N6fVyCFN5K%?V*>AN^ zLvnapT3VQ=+8CJ{#P?YXFH24<;}shBrW_nN8g5D5 zhDnKEb8_4^A+n|UQ@ehxsqp}k#a_Mn*0Wqm(?tTWQ2YBCRy2!G44+NPl@iQWh?jjG z62b+m5W^gSRrQu%hsTgoA4nmQr8rJ&qj%>9RF7X~v755onE=q%k`}pt9~E79oKBbm za#mqsZ)Y+-v0_f?h?tu{0JxxTV?ZD&dlw9amPIP4#7cGONJ8-p24)LTw~UY4y-lR0 zgM!YGl9H~Zc8lY&=6;EaqNf|ELk||9B(OA)O|#m#P#KkMN~)`?+g5;OBmhASK76+G z0bxumAzQ%0(Oat%NTrkbjs)Sc_*x&r)TbenbzXH*$c({t>&z`60&+*6YGw3d+<+&d z%hREy-@d)T(V%%^*8}oH+BS=AdB0vOF4&s3^_ukA=psrR|A47iA?_Yh_$n3o_sAg-{_c(Y;LFApYBqAbW5yy%O zSMUH5Tk^AVaxN1Poc1T4l<2kKV;IgLC)lL8DR=x+Qd4~iVQsDNAJpR#Fse;@9$C;n zh8(F#Bao%t_pE`|k6_>55MMLyEVjl8`V!h60k0&DU!CY1qHlAktYDYzJ$$OqyRJ|D-TEl(;+tiO;9pVUu8)guMmjQ!ot%K z>Ko!g)WW0jwOAc|H{a&)H6CqyJhEqCAg!A9FVn}Z{4rB6aS`9W0rcl<*RFLz96CO* z!R&2rZraQ>^Q$EiZZ;q8{E1|>y`~fq6{V=C2y~??UnDn1z#+Q`+i6kN&z?V50my%4 zF%sL_`fOj&q4|KOthl+f(L(R6%6qOAOncpNU{N|?*n34@9v*5T=S>Lj%|Zrp{8Cc9 zA61Y+^5dRIYq^iG{fhFsPA`pRKefl(?Z7aJZ`=SC3|mW*lr0Q8y+jKMe?;^ODXx9X z*O#e5KiRlE0cqEvAW`MK2BH^(bj%ASrRcagB#6&JK|xSb;**Z&%!t6B)fcSvODIlk zd$Dx1QMa*~39})&;ce6DzSn7{IXS2S6^gn|v1*o;5_`tS^UnP;yp_($z$&hLVOF&A z6j#>Dr#%0}JE`UkVTHW+lcrASeD%s#OffD{$v{p;{}ZxGCNRzAmJ{e!x)A7mR~QvY z@F2iqzZ6{PN!#d86(9XG$ws9O>J2Ekp8sK4zw-QLJi1NZ0Q+ zHa6I4oB9Pnc@nq<1hzFRuN6bG(L2HOyYdbiXnyT%}gE3|#KVQg}4eRC0k zt)!xOQ=bHfy?i?Y02|BAaQChOtanIv@XhYF3mIEgJlnHp2zCWwrl5Me4VEk%*nI9~ z>J!L$u9g;c)l4smZ-IbiTY&+!VsWdKBv2xYb}5U!_CQ?9wtU2V=XWQtcWAC{%;7pm zdnX;2;25nFMIx`&q!l1Pa=ofuw=gX|omI8uYjw4IQUUWK5fp2WrI{31d_pBs)BYj! zMyEtio<1!vE34|7p8y&PMW-)$dN4;z0QwBxS3mq&VVcscTPXec{?F8bZ3xXY!I$9% z>&3WiW*Y$t|BBxb=!427k+R;qeTgTiOzP{uApysZ8$0|uq?Ulrm6aog#SiY^2c5x- z!%F*|cQlbsCrm`QaU)O@MXuBt8X7`Npa{@H?Y3I~2Ddp^XL#4JW?ps%@u5xavtB_HI-L+JkF1t^Dn*wFr7gZugv1tk$t zA?RUFQyxy=-<}f>@HpCo(#L1cZx6*h7ZZeBiW(Bzeo&lloMP&z$37h$PhSvSw4oWv zAjnfL6wlYGa9mThNR47IU@c#KRAXG7oo)Iv>@H0IqxaVtdlqs{og4C$8H&IvrqWC2To0iqW_-MLsHG%eNFz}5fQCkqgBmr6uI!pO5I5gVM;~CuYOXIqt)W=@6lSGOLLr8 zNkE4u5tmL78j0zkyndZdO8OY9HWgAG5?KKuWz#LPrO6pI&HcU>3{s)WYc!OldQ++Z zi@C}?u?4Vi>>HGcf=>DFn>8CA-7O8v1z<|p1P#*Gzw`l0;zMM>GE@m8wtK__pmx`S zk$v>&5h=`nSieO@Tnl?ClY=pPty3p^y#8bRYh#(_y(hy^Uk~Gw)oZ#&x@MN%ACY?W zQ_eGGRYgT*DOW{~-0FKyem>8-VfmQK8$!5gvZM^CCAjUG(>6lvD6x-%!sta>$Fh1h_dya4sh8Xqry+I;R&f+u=OrPz?^-O?N{ z4Ro5f6{}Y5an1fDh|t|#T~P~>pld2BC^$eJVtjlY0>y;K{sd^?Fw+0zI$r4h#OftW zG-|1650$Th)ZZEhl?f6gP?3NC{tc*8IR-dxTs%TA9YaV-Nl8pR(9^RC{eF>_ghg4q zBl68_xR|msB%VC3s6_}*o+pQYAmYqa*v#p#|E18t3o1rlTUxH~bc>1t6T>Q?BO@a; z9;jY`B@koH3Y8(Nqw_Jgw44Mp08n6v=^ERMV;>WdRaUwH zoQDYS#Xw3nV!kb>3u_j7S24%opCAOYe|>qG0yX38~i>GRS@*6KLYND2B@syOuhPzq#L^_snmOeZ$YuG$C1 z8LC|%-BZksN@6Z-C^b2>bVErN1OO-vzZPL-VNnO^yhzT~wMsp!lqG!GX%@hdo}4&~B+#f$OrYT!eae*jjwxVfRsv0)Gb zLNg`wgWpxk&v9|Uo6{|{zG=USTI9x($Ug`+?|K{t_Vwbnv)2ab71-kn^H z*6waBct990_c|(vs)8oIARAkLQqnCpwl3Ymafe|I+isxa>r-{75Pl}@{}|5g?(V{( zKs!BJpKdT9JF3Gy8_w6q%T*0f4`RdmV^N$nlq3*M+O7& z00xztm*?>7CAPB1{MbUMx3^dHWGm#|yLY~XR8&;Jr`vdQ0BwMDZ*MgRQ$`+-15|l5 zDw?R<&HOmGX5-@GqN|%yJm&y0xEi>+WQ7oDb)uqEsg4o*J7r^*@*jsyOQD{h3T1K# z1~bteZM0sXW%i!Fc1gU^%-bV_=EXvn_fu%^v9OO3lMoS+rr^HE z)wRz2k3cDL{ysvqSeXS{97w{-bxo@NKtf6^ua7Lq{6 zmME8?7Tt*GXd~&Nu8t0OK(FzFSI{SUjb7OGe@_hv3Ti{m-oAt&M>{h!^AyXz3cmx~ zY-7!8-Vc8ce6tE6vdFeTsVXQ$vRJI912#}M@{B2)-nkFh*8OkP6>)jE{_TfC3ZD z>HUxO8X&8z`OirSt^1vUhby+SKoHD)|7dDZv|m4^y0q?H_aSKIzEAl9Ed z(}N+$F-Gxvkb7sG{ab5;bN)9|q9~<^dqthUcXCR|>97A75wAEyRdi>ca5^Tgj^dI@ z3f2=q`LAKo8z;JtZ>oeywzZ9HA7A16QKZ{VdvrUkyI z7y+_de0&ED1fr|$)Ia}qNs?Wu-OaN)bi;e$f|45frt{7Pu2p-=P4}-yl7s9DjffBj zN6%Dhz392~=Urb@7y#O1Vcca72k`DsXWVd0{I&mF?#>xp+@J5(8L(H~ojvjf%T*1k zPwM;k?}k?{K2ou^-jrgmL4l`i&tn)k?Ri?$zpl%A!hJAnIXO9L)wm8$;gZ_|a{dl~ zjru?P(q^N3iXp)v^Z8G2C5a~efAuUPj2pp!h&dVnneU=OmhUvd7&VEVsJ?*HZx{9ib38!*BM z#5+YWGh$;+K`lBPi9CXu=?OJHLKF`Vk4(hz}J`R zD~KK>6q()Jj<9z;Jw1)}E2L?{`FxGUXY1?W#lg!d>4$9J@MsZ4*75kYrL$OiY0P4xE!7&z=R;wVX*h zy>#(nCEPmr-=KRu#<+?Jy{Z4J(fX6b19Xt6xu+Hv+rb?J)^3AK8|4HqD0s}3jf^s_ z;tWX6OymDo_e+0%>k3QPn*hwF;Mv&nqqLk>WJd^J!o#bW@uLLEZ?S=WGS(MS@tz6K z+l%uu&L$mq#w{msc2DDiCs)*m@I+PSM!h#(=4F0|gR{{0`~TIR`5mTKDlmH=)GKLu z+#n-s-CskD6B7~=l93%iNIcAb2wlCdcj?TuR);v^xb=^Gs311Udb7U1USBWf6>_Ft z>>3%_;+UWuwgn;o)q*M6hwA_(^%vN*YCGVfLe~fhtq->iv`U*{PSJry`Z!Pp&zt7Z z@sjNYK;o{&ceS4V;51uqPB~*4*={ zSg%TG*n&bak@ywxC&B1tgmh${UvI_64>+#8z}!Z#>ZQ7RkTcZSf}GE+gvDh;1bTTe z2TC0pVq#)6L7o2puQTolzm2d-=#o|qtApdM5#e5*D7c@sfq zjed!6{cA)%wG-k(vEMRw%iKd{Us5DN#W(#)gqM|s$+YtADeJaZA_~A%)rWQ=a@U>kt z27$4Wip=8piSc4U9q5T|j@=`(r+GJhSyC*7{NSzNg5 zmm{IKi{!@|l^5D>c=Q#Tcg}PbMe7U)m`R97Y;HmKmIOD)M}Di|v7IBTBzxD8+T-*m zVL}vE1Hsiv7^>xVk9F_MU z)vDJO50!?cvhv%$i(bMfqY@=Wb7b{nlZ=7?Fwo+O*n8#=MpHJ)i6J} zuP>y^d@tpJB}*F-A>l1bPQQ@dULzY;u?>z(I=TQf^3Ll^Hwa-8EAq0-j=7^QWoHvsQ-#$CbfoDCt zf8m$}@!a#~&RXU-Frd7^>+S0USJDJ+>ZEu8lY9n?dc`+(l9kXx2( z-fP6TA9E)6ajXUlg7HyR7rIZtwooLwA>}rHNGpc3LfN6mVT~wBw7t7q3vy5qsHGU2 znDmMeToHjLLSIHkMs_(Go12@1+XTv+?JoiCUD`IrEBl*7AcgO^Qm_5v{S?in8sZ)^ z^Fm8uR8&-RGtdb^%W*#1$B^L{P}sMCpv+FwYEF&tFs#=X6HL2Hb@^$p(esS+?VsBv zf8rbPvR_Co_?GN7efzEX&0>*v$YBZYfCqX#J=YQ=*}s%Uh%dA@T$)yO-h>;I_kt{g^#Pe(`Bc_OyZ z>vO)I^2-4Rr|#S|2;qNyG0<4JKpm-*AAfEc)Gm7WY3|Rv+zUxBDzfeQBGW;zczRy> ze2GYjD;G^Kl#$YqqJ*XRlb$UER8 zh7N>rl(Yxal#RB81hjvn_atAmp;bIblOX#7_!tc@RJ@_>>W|eqKG=d2x-3KZ99Na$ zph=r{5imzgPB(549I$E!gwsevx=!yJ$H$UmaS(r81=7wckbjclGb`_KcW}{lZ-~5y z!rtz#Ts7VO2$n}`GBQ4Q*aXtxFL9Ax#8^9Wk_D##dDo73qCqgj~3Kb+o@emzKVA>5_osYRuMCXfjL*uE%}* zxVYNXvd1UhQ)m3R4wvYl)WyT2IQe!d<+Zjb16>!S7>OXGCSAJFQ-1!*MSKe!;qG6oYfL|tTPw3jqaah;;SJS*=KV{PS0kh#1F z8!=#zD$kyYy7|W^r7@5KXE3s`XDI%xviRrZ4R?wU#G( z>V+>vFBsoNA-V_Ztn&MZT9>N$TOXdPAt0;# z9Yi<_y1?~e*|VhSXLh}Owr>t7s*qOsy3a4XIT?L4);U5`b`sR1_4K0L$>_r2-LX#! ze2|LXWV@NIK1W_)6w$5T_Dxt|EBM07Cb#*r_LUjGv}K(co#|4oEk0RjUNzarZ5cHSPPR7sZj&?>wof?Wx{@bi*+A}aaGkcUeF8<>XwR^mn7vzvhX@OIWe!bK0)y}QTy6! zR6z6+vH7`hJtWyTqN4(G^M8HM0p|(f_Za}qT z3QGf9w=`^DXN`)lZLmTo63U}GV z^p}H=?~SFtj)2JtI%)#H-IG(QU8tJvMr(ZlA2FWBhx9wh$wEDU+kt%l_~__NkF^aB zt)uDh@6cAO3m1e)M+oba(!N-33-!sKJydy?_*O|pg-MU`GGrDN*2x|ETZ~VC&8!h^ zUk@Phec9@Yo{bwBDO!Qw`bfzuo7g!#GNPiPkvBg9rQ+7KI%m!Hw(I%?eC zmTZobI^)#L58h>dB60#zF0NO#ZK>@%{?TuRDH`j})t_@w%O)uSWSM}XcSl&S*BvHi z=J1C)m?<3tg9U~}_iLaZQ&KW>zonXdl|>F-N7xLpH5Wp7o1XrONMvTDt&IX~veD2G zhMySDp<9-(gle%RNboP$&3pR7BEFa%CczT+vkxYHgC@GPFYC^`>*~odsh3F*f$Nb= zV;}}u`|gTa$>6!B(rjW$olC4$qaK}*LgW!VF@qB3ZOVv1lD2?vA|hj#xs=EA=;*2? zajxc(S{PYevHjZCH5!-nfLTcW^$S79kxzRCE4x?T&^C=obc4-gSI)pMa9gXwJ1|T_dJ5rW|UoOHY~tA^t7k=CD@vFNJuu@A+m+BeLnr0&tP83otypD#XBzcBfoE-EQ}@{vm)|;it1r3 zvbG77pWn>S&b}rN+W;3&@%8ldgzq)nZOG!foqI*>^x(E$+!*EI@OL3>4!})BJK^xoznAg1%YvaXn7rVkUXdtm?GFbN8Xs z*YwAiG}Z#Wy%BfcD<|6LT10=+vN}^#%pwIg@K8 z6^ho6>TFXp*AVX_ix=}8)ncD$7MUrOpdus;BY5a;-`1}mKI+9Pt*}1YH9rkAZ{xq9 zt6eHIGG?5nD9Q+o^bqidn+>^%VuMjnJd4+Z4eiF*QzpM)O4A3Z}d>O1{9Guq! z&1jlS?mVm+vqa{>#V*Q`F*L+k)sA3@4R?M&(hlI>ZR9-)6Vtk`7?w=F-;y;i0hRw{ zvom5wMT>ZCl|y3Kbd#Vn%h| zy_BQyj#cb-SWurSjaS60jr>ad*0ziiA9S5^@d9?_(EQD2@5r2yD;+~2ln-Q;aTHsj*CZc zmohy42E!jz_~zr6Ht8k{2pzxWxa)nw$jSMV{vonb_N{HZLI^3jaM;w$ewAc&K`Vh0 z40BP}=RoV~TyEx-0;ZtSF@*DTPd3;G-mmo?$tc2Doa{?jbdnT3zeWVCs)#VT_(;r` z5T@sK@m7Viku*O`=sn(>A}}Mxu!O))`X zipYl5Mj4M5x6#%>eB&t6Xdm!@AvUGEu27jJMd^*z$ZLK5v@K=*@Ai2zd6$hAA{Wf* zj1Y8ka_)&yKPFky)Y07=5U;ugo7(;lZScEG7=veU zJ1uVhmCujE?RTHSK}34v20u&x5K`@Afs%R3-Jr%qdyzwN5FD^=Am=Wyrl1qSoy*9H zEtTF=LrvYmQZ`$18ker+92eBHeBVJRjp`XBK!Q0^yCwF!J8{F%$#qiN7*Wl6fbBS@ z754Iy5;Ciw`)dYqX74u25o8bGezXgV32s4g1dNb<*Zd*kJ43%jMUgDDJw>iU(1}d^ zJ^!4RJ^f}`u1Cf7scO@(b>xPKi#SuFlm4$^TGq(15rv@$Ci(1~oKgeX`1ndLPbx~v zqn`|kua%UT7t%^`DqQ|{Si|A>nF|e??nmbi&VtB@F8K9Jd99hT2B`jGK_z&bQCeNd zilnVp;QK)|1Yl!weaUS$^TU@d|ATtC62m%+DB~1cI^9CeHXe~ty)VSc<>4{dxLYf2 z*vq0)^q8N92G+sd6A&vi3a2~UoeI^&(s5tm_ zTZ*{q>n}DpAQ!!sF&qh%pfrq{AgyP_7v>azVtOJh6Ao%ISIoZ9+`fG~sE(+%;w{Zp zBy85LYeCev0H5jsF(O*&D;H&YcNH!Np1q@E8=6DJBp9T(r&uMH^sf@TMm&$tg)O4} zJ^p(ofd32q`F|cJ0rnW(in@jKF>F;!^3}Gc=^AsYy*t47;fB_`8j+?LpS4X+M)9!#lFJ z2dNtxjRs>*66`?4;{;b0=-*lLnaxd2y9e1Il8=t6(l&yxtM(ML0sej1(bf{Th1jIs zH=^|~5cWK-u>FWkAXmrkMB<6LK<%vEn<7zT{J5K;I8^9~%~~*4;D)Luwi?{|`342W z@&X=?^RoI;Wl>05*DH zDow|pq4|->s8h+I@gJr5mTvIPpY>vPJ`SF1nc@@Kx&R{2u7{Im?MAA{X#a?4%*32? zS81Z)kh`>-0IU0FK`NY3cQB$DT z_CRYH#Ro6RC_Xi8&YD6xS#30%{AUADiuthRukN2eRVr=MxBH>WjNR6ul@6f{>UPGi zt^zZZ^;)i+mrdsRHDA5z8y#K#W&=AE!GO9?*9wk~x|7`kxdfpsi>>YA#QkE}6$^Ge zwQ|b`)Tw(J>6w}EkPiX(K*Yg#<(^&pK0CXBe42T|amg#Ztsh&Y_eAqbI?F1jMt`LE zQfWCDt3h|IX&|c5?z@zym?vAYvvPtW3Z5t}qf;q7qT{vclJ_Pmr=J&C1k^ohT}NsS zw#DwV-}Iv0i~1!~%qvsr@}W52Yp*=>PCFUm(RC2oF`tV= z^C!X1V{ogPY-~*m{Ds)3hs0~dR$9ZJ?coc*Mn^w~hwnf^$O6jSjY)4relM^@eEKwl zE)@>|e;N3cunutWQ`pxS!4@`fDR^l5j2@|cgr;5spD^WQW0x|-QvER1Yf|NvbJYt<_krM-svl6WE=_KTf9N8PHxtdg z^RU3pD1Jx+K3X59#!1>lah*rpfr=HH2{uIixXiv-4qG~*7qK4US=DsIO5$ehlV372 z=oc1X_o$t$tZYaP*f>_^=)mY8EIiR~b23X>_kt%QEe*~jOMTWw@d=!D5>;*O?U0vv zE^OzQltduz_wZJK@kQxhU5Mp2G96@Cbvy!RWl;YBnDfD$xvK`&pc>IAY8TPVKlNT; z(gEu-OvZa}R>Q)so$2u=2XIh3EK|i>TrYGIr8+HnMeZ`!8*Eut2>_yI>fyyciXAkG z&VF{Eimk=w%n+fx$5pU>K7&B4ZsMS9&v|6PKsCZ;kHBzBc+WC#dbjYm9vWdkvm}n8 zec_I~8Nmzl?iQ4zdgN*V{m@^Zf_8|@Yg@04&>l^AwX z$OW=kjTA{-6apL&49erXd=nms(m(TJZgzIkeMiQCsi~}tXD$KJF}=W&29}gI*o*Qz z3LFgTCL)eDee8_$Kwk|AB*ca>XS)ZZEe8#Wn zcz+w7F>pBd=hWc4R*>6rYE?e#Df@*RtkpB-Sz`Qm?s_0$3QRepn?w`oPo^p5<>!Mx z&32rgR;+KFs%yNs*p?|Loq2G6nkZ4L8A0|L>hej6ufk3|x5E=Fe(|%hS%M!Oo?ufv zg0dk50JL9v6{5=F_YL;*33aa86;dm)FTX)--bm~a2jfc@cI$m-RH4l94N(>nDOuSr zX#0|GqUNS233QRQV-P$QBUb+Es}Vh`_q!PnequUSsIe=m=*d@qioT;%y|hDL7tRhb;3iz`s<4eSidT z-(O$?g>Akw0kMgYFv14J&RXv4KrQM z0nu-d??CPQ=o|!G6f%B;uRyrgVIXh&%?V*2L&l1fEH1~pt^X|BtStGuE&f0700?*< z0Qfo?f|<<-K4_+kUZ!=#?rh4Q@SXy0tg2cCzL#K1UZ_#*S^jcF3c~|NMqC3L_oeql;pqoAtPw_*cApz2Y(KnEqHjqqYl!e{!^h zw@iQ6cEETRBseQPzv*Z=A-Ga9OZj~Ye~&A6R0jL*$Th%EkFFxe7vcdjExaZzMc7z> z$*276)a@Lhl@l}yz`GM)_(q}v zd6!=F8`>cGA{k+F)7)1aQbv`i=1k&ql-nvqST%>FF3@YvX;T~{i6e@g*;TAv9^ zC%&5xpRiY{v+bTgg-1wjjFrnNO~Lk_dqs4)IA?W?+3)yRBPWjb*81~qKIA`ocCn7s z7C8~ke(FIu3#l4#nl(`w=KY;BM1QmxT}(#@0+rZm50Ip#`ijNdZz2mk=1OlhF;1fA z)CMz8PGk&s-gep*{l0n7{|1kTFKL3oab?Keqj`AHf68-nY%IhgI`T;uW_Mg~FhnL+ zNW#7Lr1^Js=Z}Je<{T-l^n>$+DusIw&Wi=nd7mdldcq#qKYzR|KiILhZaNFeX4>iR zGTwf0JW_Yd+|+cLE<`A$-Rw@R+S*i|*SDV)no!`pRqPleodG&X=qMt!BT4hwIF)+t z3fq%CJ>@i?smp`I!xswPX(=iKu9p#&6V_|zqcnT-p3@|r^srO+DfmWBp3Y0axM$Gv~=_nr5; z&iU`0>%_PI*Z~W8*7Mx=yyqNqj4@}6byPflKXYHunm2A<=Uww-gSsrWe&8-sGKa@$ zoyFN*20=$?6wc9mKA>UhIry1|+Yw0{dWq}ZfAmcD4h*!Ii&C$boC+xUZOl`gWw%7$ zZ}IB`&$XqaF4Ey(RMF2L-5+=xTs;tW`W zdxB@tJs-9v&tJTF$lbe!Dc^ry-ZE3FQx3L9ZszTu*q-Q{0CjcRe> zS#rkh&s=E44$548ud~=ap?}qn2hXHs@W&38WL^4~vIDIV!kYE{Mh5zhw`ISI@hcd==x@&-(P%p-gO!++(I@u&iC*s9vA=e!6#Bn`2tb8tx?={y6AhAC=>=?5M}58VCOq)ai(- z-fEebhNcVxOVjL!YW&mH=}$D&-nFybZ~e~IZ_)QysrM3*WHnld=1#aTz<0r z*7RrZ4e4ud85+VEZ-9^us|-&%w_0YBr67QMzrI#RMg|Yr4a8b|)kZWs;Swl)XNB#= z!k3A=gs&R_DeCO8sg}zNd6&RhhIv`KA4uoF3x>etehZ@*9IG`I8@{n+RwgP(4+uDu zWQ?Y^ixj(Q{pC(#tnW6KZX&kM-=X<|-mO$EC#BrVfg^nN9cSNP z(TbzSgtS>cx;71_PA8XfGQ5T|`w7z9GFZX=ZGcZM>FayKHBK+baY)oooFlShh>^Qn zv0<}IUOTKr7O1HehC+z&UrXd#aEDS-Q>V8haA4g*f3!dFY#WSt?rL**RR`Q+Sv3B1 zS9r67S*vU&Q#aOwgIVTf1yOK0TvYSDeP8UIE-Oi6z7wh~+Ds=-&^ zEcSlGiY({8C`;w9*znE9E!gKT%(C5b3|1HFnZp=9ITe3xWF;Nr?_V>B-|pV~?EUV@P#A7dky&17 z3jqP$$GLEJq*024q3s9AcJu|eNxVmA^i4iwxk5(;FNK@A*KS}1=Sw0$D?Wse)H@T>zpXQ#C`Gz{z?DPl^ z6IyKSSekw8aQcq(c+o&zH+#!Yt8MbRZIq0R8rc>~jzdAbuDaH&VZGatj#|eVD|*sJ zT`~f+)Quxv$jqLV^=*~U{rzRLygy_fXbmdlsFH3xJ8B)t{@1VS>SMp=;NX*e#|VvH zx)_?_SN-2yU{K{?2K-%g)4+PcH%xAkQBk~9as~(`IE#)t*S}_E_7p&262rc(t+|=a zGS%xNf6KGPizzI>WHewzTy=crN{RR`|@@JPf7X+1QPj2BUR$`H@ z$ygkRB2=EEEI;=`o=LiK7T;RBqwNLXNdC+p)z)Ued#6bE@y6Ex(5|Sx2jnK5rGd1= zABHYJC@k#~-6S+7^y+0I&n(htN=|0%`iKt(NNy7k@Kdgz<1qF)rO(tEK83x%{OITA z5^|y5^+<|h-SuFR^KuzyJeB>9C-k~DA79mrg==ke^W24a$+3Nv|Zd?BoS5tPIvEIhgV5Wo`fQFgi@PF+6crz&sE6!*%tQ zs`@79TXVwAZ?71O*XdPeY1!2W{35M9_$&Ds>4t*&120*E7bO34Y#LPLO+4++Jr6#M z&bj6xEY!xR7^In0!%k?jc$*6F_z_b9-;@e;1JA+1v9>ni(-A4DMi+}W@ZqP z;WcknYL=Y$B0M~a{oEljd|Aq2xDIqA=m4Y_K$fX*Zoq>@6bw=egubYDqDB;=8&nka zj+vTApCq!9ic>BH_&7R^#BX4D2O7EutLN$ih*R!;jY`_+d}R?l6bCaf)aB*MCVQ61 zY!@SBWoFuFeD;*F5u1x@|$Jrej#h{O);5zran20Rf?9q8}>QQXjHy9zmCrFEuOzIzr&Nh(aCvQn^| zQ4gi3&!SIGiQ#-PJx~c-rZz*NQg4Vc9UUBGX6vpH40?HqDFKLuCp;$HQdWt(4n6Wi zPMP=Gh9H~#QG8c_^iiboI8qD`OvB-7^yNk9T(8M2n_F^ra!OOf$ z=x|!bLiWwIO3pyUL}F`@Z4bK_3p97K*>4q1+|}i81kBW!Y#^t(c16j5+Dwwk<}Ty* z_t73?a@Tyiu~~i8PK3d_`uvwdE&&|dSEBFA4QERfzuLkTwtS*Z2InrHi%xG!$PKQg zvdB|piM>9~osona$_2Ab}u8*mb=M4IQz~f;-dUBeQ|3=CBd!nS2uyGa_`u!Fuv> zONr?cHWvgqND;Mf-@_K&&cc$Tn?#758s2bz#0@Rmt0!AGWjS`b^iVZ%1|gG$r;Dc8 zsb-j^-DQ$;93`A3)1Q~C`MU*t?>WTJzf)c_u>rP0VPPB=tH3qzz8KFVDcXtNYP{22 zpXYzwe6+>IF{sQawJGh2=Ft~uyS(SA%Y-fC z5d01ooe$#Q!NLNOEcz2agN2oXE)uJTDi#B8So$Vg(vz*q0yFTM8GhK%+-#cKbSDCS zDaV8^vdAw~>^2k%1}U+AMQlYs7BD5+GK91l(l)S$>b#|1{q%C05HeSxZ}GU1 z$(A8YZ9!fw+sHeKoG(=_Ic%7cik!1zZVdY_8fv4hb-LgwxDb$^k4QaEIGcZ)*4 zVp~27=3g==8AU1{-p+epdnSoDJ>}km8m?}}9j85VT$~6H?(Mei0Q4ep+zq}W!%I)p zp3rvdp_RyX-B~*Pm6X$PMib1*uH}q)wlT}Q_Z>0VHT8xtC5(QHRPo1;b6UbmuqYug za>-AX1Jk!(-Iu%fb*puq*LeNY!RLFCa|EJrxYr}(A?`!u&RbgnP>qFSvm#Wc3G^{vv9Ptb`-^JTkI9He=fbU?Cf} zZ1?W%#clG>I1kr5>Mj=k=(He>U;Ox6gtqV{^AVBPMxutIq4ghrnWk^zL54*rjVxD2 z45-?4s#mXWe-m)}V9Ux}X2J88g^sII@ab@IqmU^BWCm*eN?QTcy~7P9rQhX;($ilr zLV!5$n%8xF?I#upF1I+UWHye>QmgLTuKh&KOhR%$)PNJX?5F7&Bv8ufk~d(d)7ST8 zC6m{SZd1+X(@)6BeI2QH`B(Hy%tig0wyj~k(3kRq>A;S#Frlpe(vXtyRXKt63r@8M z$g6u(j8*Hy6BA|C5@qVn-H8oPl@^mQO3{^_Z?vOcT4XecU(#+?sZD$1iDD{<)2)=O zL?SVr{J8Z56~-D{oepr;!HMM5&(Q<5h8N+0#ME7_G;1fr=)^9epuPVK#gG!e^FAjH z)f+xezWF&}0w3SK>xa+EFpjQwp)Aa-(;N(brG{*_!tkeUXxM2et*4~qN9B(^UPw;$ zD!2`UIeM29_rBKr`o;RQvk#{ctbQIjJCXJBfP zrgn4Myn23&=;(s+_xj6wLs`rpF1#gu^I~0OTYIhC|8ElEMaiI?kdSbJDlH+Dot|DV z=T=c(&M{RIX)c_g;QUO2gCH&-2s$}9AedF$_y~6iAvU)8hv;b%mTU0HgYBki`uk&# zOAQgW82q3wyr@xG*8vH-y%|){oafepdi*~8sweHLSXoI4`qi+V5eluKREAXC@+$(R z@3YqfSm)V`bI>?FyRbj7%~B5JMD3-ug@gWA1-74tN8H7*i~DO~xm3Re8TYMu)Qt-P3Mouy(GK*WX4rSN=pYtT_Z)J(}p(G z=hBpGp3*~(#jgUMJbvb(75!))`I8HH{1LUZuYHDR-#;?~^a?y>o^f5{eG36dow67@ zcsxG!rn_HlY|>JN?ltv8PLIpzo$f@mQ!S&Gq3S;`R5{#$k^Uy8rjlSd0xX6`Rq~gHKh{`sV+&-8O-+ZE zPw*7uJ8;}uRl2@bm8C%M(jdcXUsVL9Ku&Kw>!?r3xeC`os%wdVtJXkoUmzEyfNO4M z?&$JVimG_qhp=4tnlU?M{0q5Ik2zk=Y+JE)Y!< zi-$@p6i%s6_I$g(3dYDilVOnTp83Q<5ruL_-k-eSN3KdNu=_nsM-WWj(yKPdsfS?S}Z*OAoBvPtkQvI-{WM3qiWN zi3b_Mo$b+dM(kR)lAWT!XCJG*(MVe9ka)u7+({k@T7@%7X7=00k>@%O@8H79;+WGT z&;Hw=WlD|WIzdIFgGoLCBvr8l%|K*>BU>r(n+P;R4a!}{n+$uBiidgn$vZFmrBpaU zFXz=lP#!;hf~^s3NC+Rb(L{)`DH6QQ3=sOjmN3hT~yD>XfAm z-iFYA!m{xQTbT+6j zBPunnUOjHOWu!B$y5o@?WdVQ81%V4bG?2+8!7J{7++ry>B=9P6g_SKt?MPnntU%r6 z4XdTG8EZ~AtF~O@<;Ipr$dE}s{6A=B$SdE;C3lgttZN#T>R41p0?pJ7)jfVCY=ZUk zB!z{iVFF~9cb@JEs)hzJdYe%4QViJ@sC%7-4--yxp;7fv5NiuyP>KuT@n> z=S%UtLN`!@{eqQa!o*R7I3`|U;VbHKIGCGX(6IkUUUckW7woL6El+6@v;$VO|%l3eSkGvQE6smR<6v4>MSO{Ponrd0u^aS&0RQizsy;2Sjyr(2W&PbdB(@Xl z_U_vUmHz3R9i;BF1_lO~FJFH1P*GsrHO>Fs^Vqb;gv9(9-Hpb^@5$pN`Qy+oH8uvJ zml(IUW-|@lZapTi!6@QFm?!SY6JYC_Z;!apI`mw11=_+bTQV;1xSRPT{kmKbLI?=Y@7S zVWE#$ZAs^)>4XjuJZkV&#kmhN@+A3_EbF+l2k(*;o}ARHdQA74MnMrD&DC`Y#%)ka zMz!rEeT0s31l~b71emF%uk`5(AOF{97E0R8R$~&uSjZ|0LP$%d6~x1EbCCi(LCE|0 z)-atSt`c%HlohDjFh`@>|4J2ZG5vqtiRh~J>y>aP2ufByA(D1*n4OzzM_B??sn?Cs z3z9R$7=qmlbP-qK>)8{@vgY-v|6F>tiQ~zWdt0*|jvPKfdo2tHBXJnw5YpZ=EiI)o9G2A_1v(;HGSQTEcH?Lu`z-@^-sI7PD#JZ|N z*6p(-?cs@u0?72OtgOgnIWz|6-+`l!RyD-l@Y#JtY259~)5)pypUY9T&*9#^#^f4( zz?1(Bu$lj4Q*HV0rXpQ4=lw77AuHC_zhA#bhqd$1%ZmpO;@|)F<^Er9{$F?F|AMtK z?!X^p2C0l_s0IGXA9P-4!d~*gAs8(1dJe0K|D$*Gk9uPwMk>_ox0l2yjTJw+mB_q2 zJw27RAA5Dl#y z2xJ7N3(a2~!?mBrlxO8&9~6O=o3A zG%b4gz4E~fIDfwETIG9{9Xof*g4+9$x<*P;G7UVQuIWV>%-<+zitfbuo?YMoS0NZ! z!6rE^E^M+T-#tR$DL8s&jx$q8tLwCu)j$MP58082omxY%2{x-vXgmarH;ZSxu zhmZIeUI(wOL%cq6=uliz(nH51w0*W&bphKtR{F)Z$N&x~L&JcPQ=0Wj)J_0&aK-83 zQhNUCm7bc~EMYk{FdbacAF(=WT-U)Q|4BE=tKju08lf;rjqV5+pN0FPt2Ad8tjMEV zScyM(;wp}B|2M?&ikM22zR>L;kpbMEbjRUXJveA$ zVp6qLEG{oE@1HKrN-gC0hgQ57bYKmI_RxKC+m-jp$&D}I*t%0OGcyx#6`Yg~rXTq^ zDEmiK?B&LUgUR0}rwzn3wMH~(5w;r(USl$hJ}8APW1L?bcHIEaI$I+X1t6}_vN`Rn zUQt75+lE%lS< z7bbhqZK`bm=cgQOi;@~9)H4_b0sVA!L-{Kyjm`c>UwZ*_#^>nfI;+ONkw%xx|7wIL zS{kr5MCM=r94@b{N4*@gbYa4zS}1y6KRFY$sSI_!kS?($wK^_|^S-7a?-t}5fv1GZVNuC8f*!yU>_ap{%*OEtE(wqR)A z;kX3jfEo%f#e#Uar2A^f(|C(UW`_faSx+}VslIC&L)pxc-~*diumEWGWPq|nI1$rK z%x7vRrmURCTBfF=$<#cA1ia|}MG-6s2EU>%YZEhVZS7qB=cf2ql%-K&M3aX`q*mdma;C5D&%jF);{2Om_CqJlq}tJgb z$@==^jr7Q72!?e~*;^e0G=EVHYQ~G2Wwtn&)v3nH`@>EjtT?X8jsS9cxty6rcUZE$$H)uD>8YvmTd7M4lE1Vh@z9q( z#Ks;h79I3Fr-cvBM0j-#ixPu5Xj}C1AM9pO@_+j+)4b_vLMhXf zfG?PKqN$?962dQrre*iT+p^jA%9X%o^T5)*E#^YOY0o>B+vmR=3dEmDbc!26J-^HI z#O*4hjx0Ol#|kZ2CVU+L6qlZj&olD)NLp(?yut8sOZKVKK?zJex$)8DNd2bj!^w^8 zf)Tqfz@8X+*{N3N;)o}7*y^>{wm^G5N!>+UIEjQU{lsRt?$B%(P6XfdWlSMUpw)qy zX;RWSYBnTmcp_I)gx z-DKE^GUm|`HqkHYd$k$vzHpZ{z^$d`qi2;ZH24pGl^>R!Ejjz0gf*5Qc$W!Iy!fo3 zXX3M>Iz~?zNo;fK9?E@%7yp1&(sAR-(W}KN4kPN+%(q`}RJSJ~jMIwzITV@9z?ds! zLR&V{YSdn`{u5U8pZfEF&ZSF&R_(b;oGuFII|%85eCF^QKpzn2#xerhO8-$n)6;po z_TB0C<936x7ziP9wiOnmb2s3+FKX4Eu!Y~!#N-#~3cv@<-$?qxRK>3U0`(h4DSRWR zSy@_If*ATjYQ&R|^{*;{W$WA9G{6IZN#A~yx`_ry1^nF`EHlkq(!TfDWa&1ab%=fm zREVv+30&nR^{c0h^>z@n@V0U{Pzo;2IRskc$6@9kn=^R$HoW^6YxcH@&<_Z2J*ghA zW@dQuPNiDl*GbuT<*JFLr$25XFy8%OLVaqm&mq&@En>J%J= z8=YfYeMwna?Srv;wq$ljPk4q|XzdO+&ulqWdEH@8PJNY@#@%Ri0X6#N#gg2?V*7l> zS;Xtb&9~m>96ycs-XW}9-ZT2?4D2S>xiurw`fL6;dHnJ}TAr{hoP3|X#EP>jaC5dz z9Ot7L_@V!Jb_iB%-!6XL$*Xr(moH7tF9j!g%fRYyKe?;(0NQqac7dZYyafes*Sx`l z>l+w=D|7sTEjFIH-1OO_gnJ(K2i#uc8PhjE4GoRo|7Cf8B4{&0K*b%6MC~1}YYFxp z-?GL-^$iM}3ob2`2MZ4ub*k;R*SHNr$LA|k*0Ot;k0(g66W2^4AOKz?7$6Q8$*m5l z?2)}%xL0{MOTv$zKO3Gq?37<{&&OGWfW$7w-tySq$p5avt4*Z?)9LQ|ZfXZe7 zE}vw08z0ZK@@Dwu2uOM93KK{k^?yiK2&S5%hXig-gtPjH=OxeuPwxTJ?)|#EV zv&myk9w6m2I>=wMH+K87+xYU|32Gy^OY8017Fhabvwr<%9RmZ@2I)tv(zU%CKVL#i zERHOMgJ+AvYRli1jrSYs->XwODE0+M;C4^TCCC|9dFF&^7xSNs<-G^^qY}$l@ZD&WQ~^ey-1`?s~I1Cj~3AsJNCM$tExi&kk4<|yr~)^L&I3P zUnNqsE6MNYIOzt2qmV;S#4?74geV^HdbsrFHSxK;Vo{hg9Ya4i``8D0f6!Y2%U~p7 zDWGWc3=n%( zOT%~pPWYa;MIwo;^Ft^EgA{+xboemnhQOa(E)w^hMbNz{Zzn8TS|1&Q)NV!VOuk(>nX>p<|Mr5t_bSwr2dF6&xpRLojc{moSx>iAN&)FW9Z;RLYc;ob7{dop1*#ud1lIT+=Fv+8C zb4yhfF-a{e7soP=@(hJ}jZ65g7?d!pMa|4(lk}V3RsHJBeCE9cw^OMm)x5K*>IMb^ z)2@C+k!C!^BDt1ugQx>rum@o;TPL(W5`%Fumgab9#b>e9mN6KH?`n90!L9apy~w}4 zx#&me_T0>DdBiC*93guif6H{Tn+fX?Qx&$%nZrZ&+V?c{_ABm~Fj31mH?`5-XuFba z%fTkGBhu2S@MDd)%b%e7Ikg)89(p?%EToAu9J0+pD+yr^d5gmfIYv)9d3a3nAYb#W|Xdto4*>Ej_J9df8NM! zf#RTtiv~U$h_#CYH;|urr;9553BcCKbYSU;b4um+I!DkfaX@6B9XpP!52Gc?tEiDy zgiXfM!DIPOZ9)qZYm>=9-e1wW=@dlUjjgQ-QYoc|w&EP1#hp~#H|q#z5mjLms{hO0B7du&|i!u}E~`TBz=9*V_nzqNxVP?Ug= zce^O_fuN>I_=^{}TF?GjjK}uF0sFLWLxn>gUV(Hy-4E+~ep>OW_$y@>n_elog${ZB3lblDakTrbyriEOjv9k()&+xEAaPL~ezaqg zi~geQ#<$P~;&p%59~|=v&2hm^2oiN{-|=MrZm9fc4C@Ju1t6Vpx65fWUDDOmJhSwt z+&FQcKpl%CpuWj$;q$SUBsjdoS1@!$*S`4=oI+aJdpZZVkiG@-3zYNS1=vvL$Ir9> zbC`A_StGIT0rL-!I;xGBn@%GAc0#t{_3?E=!YJ5CK;8d*bs@|L(du;077{PU-CU?|6z7E&l2!5R8&O7`vl|BRI+Z=l#b35wUvz2NGo34CeKpufrYq{{!uce?Q9q z?fn>q%S6Ro{y#(AR2cE4g{~!?I&FW?M9k^HliB~y)evcvz)1$51wi)eoN2>$$0`w$0fJxE5x+lBsL@s-U z>^2gq_|Sfybxg;w)FyCI;x=fnE}VY4on(Wi#Uz@f#54$4VI>heup;`KV(H3gl9h-c_y>oMUYye&UlN@e>FQ zFhGGNdQ=+|$^Q-3vyYDtczq?DFJ!kBzBIfPfz8;>Mzna)uqZ$h34@jIdcCA?FVSg$ z8}kGdG|U`TN)a2cB?#f>11Nn+G^OBqeTD9G?f~iA-kAK4AOFJzXv!*m$j?h$_;uz9 zVH(nj+&|qe;C!%Fr|(-UxF4gxF(7_^ZccI73(w-)2mkqC{B%1rj%F_kg#vWVBy$bv z{!f*Tnsg_S(vUZ_(KVWfbdq(-hx3)|7X@6kCCGV*}ANrQ5IU{kOPXoA>u`7BC;io7FLUFA4NPX=+=GPr4Y;w6cmB85&xva{uN`k?b>2w2@mZ!O zxOI;JZsL8R|MsqbVukOS07oL|TB{&T+wvee7ZVdJk4As_;X{V3Fkm3BUhVFuDcato zK^zG8cil(cdJm)!%!0xA(&I)yo&h~H5!*nzp`AMuyy%n%^O5Es$O|y_5#Nu)AZkse zk-@<#22BG4uAy%XI)#6JlNVOt(f3J7sPjPkbI*rLQ@FW;#_Q$IMvr$zU5_^t_#^iX z;wo>_?h_{!M`8A(g@lZZhv9}v*F3npQBaFXAcMc?4)1FCM))uRdCD_Be_*1c zfXYRktW1eVED2vLm1GW;a4m{0Qb%20eak4Lkt<`-pvhe#!u?XL&m6tgTblH@W-c z2$ExC`{Jtq?vNG65OTbl6a7;N5jZhd8kr&hq-fbt=rcc1sv_n_fQl4q5&=p7_FMS} z=Rxy9R-xo?tSS~RVy2D)s<1+rNLAo41-!KQ<|;gGI>=Oys7=Ko6pAO0PkG4l@^au) z3Pt25J`yeluWXXGWrB`C4{KIVtKx3CmtkSjo05%2|4x^+(@gtjY-F@0zV_akAog4} zX6Y&9Kr)<_aI((DWL5!>q4UfV`=j=qA;$)WgAOIZq&Ja zSqR9+O3u3r!BLi1Fv={Vz5zI|ODt1NCe&#_N8m8f2@o+Y6$KR5Uh&B6k`)2eVor=h z4{C$w&z>!C*#Sub(h}5C4ht0gH(D^xL5enDF9EI~#@i9IY5W=2C{%&8v2E+tv`$Tr zbNyx6PrQJ2P8taKZ805z`D5VY$AX(4JTjZCHy-|FE}A$=NhVrirai<6hYeir)+IN8 zXpD9Asb9T17;^kqleg|=9tGf?a0*G=BcS<#JM5=!eM^hmRBx$RyK^+4Y2FPAY3qg< zv2e#lh>fPA%O6y6tM*;|Q+L^jqOOJCMU0PjOYMA;TG!Z!k(SHJ$w^6sE@AvAK01vU z1)VN$F&(0v)mxRz9H{ygHyN1nP62(6pOIdIv-9(1o#JRK6%-f*iD2FjF!4C2kg~47 z08iCgUJWw5yl3s8*GCa~7H^MRgJ{bE6<%$0lMe5cq8+mB+boBnvDd`ne7@XDO;1aU zHN(t6G4o%7-D{UdhKAr&Tmtu;6g*B~ihJ)UeH)}}=UTFAZEQTSc}rI2=9Gnd>m+-Y_oI~cKGw|;yAE> ze?X4Yt9jh6`R5})PLwI^KXhma!GT!xvs2>Tmhv9RUe-Kg_D`1;#?Xt4rb`JTXDy6R z=qEd2NoWVfj}6*p_5J`&7j2G5;yaULV=v`c5_1nBVEn8jA_T)GU~zITQ@v<}#Fa1` z_>MAe*h7wIXJx&#);kOoV4PNcA(@FnJvv1 zxVW>vhfpQ;i(iHl7+Z=-W|D@wx~Z!F*Bh!z?OAQ(*Y*DD^gqm`H@w;p`iqdd`nEtM z8Hfmpt*Nd0t$d7a9UTIjWy(U>wHv~oKIPpo12zUsdt!SiOu7lP!9`OT1!$m?@uzyN z=A;#%zYN>F)paK6+L#h$7&J2Lio%Is;0ypl00z0-MYAyMHY-C3IK@5udCxdX8q`{@ zuyMpF#77FD*wxh zTO#6`8Ro(CSEM-~p}lD9dDtQ3=mW`n&-kBL^?!H@qH(XhheuvUO_%lH)}0n>_$~a? zN)2n;<}Hd>{=!-a$j_JM@lj9ED!Y_sQ_*cn;@cn||0KZmUbZ$#TiA1D0pS^W5kZ{JEpu22-j)U02re9R2vcE+$R9 zT^yP}v`HiVIJ-H6SXs`ISry=5QdsDve)rV3hAtNFKMWUDqKivGW^hJLg`tG3e-iDd zN~vG`k@tf#A7a8QY6VVNG50o;Z6H2VYn8nPCV~Cj=IJ3n<4t%BaY3E{4q^KVd zdvPkpPih6bjJx5t1?`EIW1$rU_FEVO(80G==E#vUtP^!*%(~~VYiKY%8-UJ5%b2Y$ zn)#cbcKX<|FT2~5m@b-buF^|*%4=?NdL7mU~=CKMUbv(-;b!p zVJim&0h<6TVLbdM^9IXt&W3ebl0XkmI$B|N_-2g6;gcSYFuD*StEYoj@|i-C_7*1y zm_YeFsV08wXIK_ko_hk$tv$tN{ww7|KstMApC}j&6g$(~dnoe3X6GOB@AdfS5!cpO z99xl~M@L_@_H|Mj;meaD4caEvqdsEm+WsW`I_;Y)z-&cy9_dB`wz=5)v zMFdNVUQ|+;z5m*HoNrP7tEFcgKV4zv zc|}KkgQZ(udj#ggQDQCynmCv?Av3L|b^?g0et~&hz?s)@O7o&k+h$3H0O8^ihmvm# z7cI|PGajEL^R&^`v~JF+19{fXz(NE!5p5?+(AG`GT+xcbj2do0qO#KHli?2A*`@CM zg7n!`N?k*v%>NBAkP??D1QtNPI5j{Dutxg3<}5^8kd8u!SMTjNao%9527VI<=Q$n`_wh+sSZhnmNJnF-2NF!9@8X!Y zgpuAZ`pB1G5(%p%yu~}Hk{6G{ux#>P`fT`GxdpSMLn8lXGQ<-8~f0!=?jg25F zko;4dM&({7V<0;auI8n=IY+7m(BryW%kHuckK6oVH08IP!n_$3IahBJ=JvF)wVB9b zBFogZ$a0^J+Y}oz+;r*Zf+LRz2oyL^MmZWj`o2w&c`oS5lj>i;Fx<kYV? z*KsDHT@1^FW>IZ11Of-)Bj*54K^-*JX-by;HCOZnTbF-^a%rrgcjkTegTAZrG^ys) z_RAiF9ll=QQDCuNq~~74q=HFSwXLn7=s1jABqRwM8&WX94XwH71$3{<@8N41-%Qgn z14<57Hsl`gb|KAr@%(vQi`n;Tk(PpEw^AjZ?J8WEckQ~%@=1Wb@bQhrN{$!A4Cm(b zGIP<9hUEf|SHM0&ga%gL=8BtSEe=flUHsPqGSzeTE+9|%MPu^I7RTT4-fg=JI?a_4 zgGc4a7_bl&mgxT;+CYOE;;A(TRhwkeobUleItIW6+@Wa4Z=k=Ig#R3}H>J{nV?voW zK<)skTUj6IlH6i{=jifF1i_i0;X{C4iBR-s#_@u@H}5LFMch7(J)vM*J(0RuR}xh| z$&ZmtE8ZBDjhDjv=;%t0or!1vwB@&$9o9%R=1jTXtR*k~)jE^$VZO}VgInZZjg7#Q zA^<%+CrQ%&gDEVi?mlsJcyzSx1qk571t*aZ`j?jYM?d~7-)o)0nAhaZ6G`e6;9M@R zx*KTX*Z#gMd|!%$l!w$CJipRz_UdyHh*|ef0PQ@6lHv!o06xXdKhP;xNIp$s${7^? z>sUW3MkHmJO#1Q!frEbAn%_k7=K=Hy;U4EBrJoafeXxbEGwh=E`WWyB*ElgsAS*h^ zqSKAw7k~k*2SsT~H#i9NfaJxhMEp5Jv{!AB|H3E=4@=Qq=m->O0B7)y;{5-PeR5R< ze}~;q@E`|kAd=;6WBYyf;Rf=42W0@Szpp9*1S*bpo?tx19Qp?Xb3&PcUq!tBVUyAy zz8u{Z#hb*=$lY*%k>9Ai-~><^J_5?%ekuN7Z+BV~=|&UlxaPOt=K&n3f_qCM`7$|u zR%jJrJ(0^A*jDz^T@`P3{0HLYW+-vyog3)nRiwF=<~+oB>LdYIDboCbId-V7C!Lw` z!yo5~`X7UT{(r$NC+;|2{|8X*A3&AlTCmb5Ksf&YN`-n;9O)&LgpendWALJAr|2n5 z!Iwm}3jP|j!1~TSc=Vm3#H?0oW&7#|4ZV)VZgNh|`TG4YBY(Odc;k9VD#xoe@dNFZ z#BryShj#gm?2L}h*==T?p11oB%ik1Lt!`6eOB&VRj14<`#xLzV*^oZc7`m`FLHyjA z?5Kn_sW&Y*Z=#B4orzLEJv{$NWvSFJ(BMEYJ5z{W`UTvzopsZrLpHyhV<3;=JHM&?7 zPy6!kn8&%KqrA{;mW)e9rLkqX+an;TS}Im^O>zH;-k8n9hZkH9R!h3x$Vd6kM;Y5Nd4ueb-zcxu;w`?DGNoTRaFj z?Uuc>4LQ@F5PRe3(CBCwA~}j~-2T)ydNk2c{vgcbe79?VIz3t9xmme4MGJB${B9Ti z*7I`ZJU+P6(Hz_SJxv;cvR^Q%RmS=EwWYj~HgAzd&VAp}bA%99+J3N#WUzBp&FzZt zuJd&lM7EJw5kLw}(s3C$7Ee?rsifKes%2!4ooV{-Au(#;U3H?xBA*QCA_Krj0Z9myn+*}*544{g2GN> zB6s>JvyQel1E=&$Wphk!D^)gVN;ARU=HOXBV zsFO0y6B~U0wCTH$jimdVw5>_eY9J7Ys2{~>;n>(gbgVqWv+(f&HjEP#Rwnrg(KsLn$RChus2G3{_!8JMmJpsyiZRh0+%2}z$X0o<_L1jI^h^rW zcVp;ITUl|<=-t=H|5?DvfD0zi_6`meFOTgRNCVn{%o!{A*4j_KwJFz&e6JgbDy4|& zVO*rFFy>5JhUo5%_zl@V^#UhQD#L+bdvoWJ#7x^;FFpTG%IXgp-B?OiUDAUubz8@( z-dtX3hvM9(QfrTP1#LD~RlO@Q})8hHDpwre!C4jft={n{VJoVli;H(a~u^ zEFqxmUZpD8AX%)ZeY=#@e?GdiIwY{VOEaY~;N$zKsP3$Ra5IoB(E6XV#v_{W(4OiV zYSSZ_td;9{D$Dvygl9)o=C*~mGKN1NV0i}S}m6K=mL^ql( znbkcmz+`bTi8E~X`$yf?4mwsH-YybYeY%2!6Z=g3c3LM%c{6+}H1k{?Qbxz|2+#yy z%srB&;e_>!R0M2-@#!#GtD7p8@|CHZ_x7I2-j<8e=~#E6k`AU3G$+Y<>z8!(L#Re} zd!O9MNd6+Yw`>x(6P+}f`wA%q=^vXW^rj-hGF1AC<0GuZA0+H zKr>m4h%k?Tespjo@3}g`;Err;&@5$i@E%uxHtcalFeVG$ulu=jDYB*}JNUI&`fy^y^?5q;YKW&7ihQj^GAw-9m*eMj-7_Z|IP(hem*73ow7OT>Fl`!p0)fp6}LLl zR~26a@QZkbj}7(vsij^70_gc|Go09KjFvfKLAn~E3wt1p%~umzydh7X?VK+YGBw~G zAp?Vd@2y(w6Cel62G(ZU;}?}OI5*wR#-)^=l_(7iDUFT(&m}UNTpP_nd`=Brq>mfA zo|4#5cs}9h6p^ES!RYkchUKV4@3eXy?0-wcqp|55_2eeye3(2E!0Y0Qkf zCGCa$)}CqD+Rrr@_8NQp%@MPcN$+m0InWfFglAw9vwj1hTKeX!9bV6f*mo3(o%K00 zbMtb?edz6QrP&=RynVi69Z6Y4+q#hT&H4?3-}dI6tM&7CwB!@#6ZcXzykCETbS-V$ zi6Yj$UbF}C3r;Lpu+-HbaPm~-8vr|vh7OB}r!@9l>jmXaJ|M{~4HhW}fygpkHbH?Y! zNSiOdyb_y-a7EO{5sAM zA~Qo<;IXvb*Bu(Vwx#)*DlzH;d-`O6@ea<5@tTS{((p6A()=M~|{bLLY1qDWLbei)miI=c^iepOmD zWbn$f{#iUNYZUh>E^*t}lZoFNyb~uJ^2B}zKU!JdnkvoyNcaMdx1!i=NYZmdde<(1qI>}6G-n?Ix6y}#{eXzv(qOc7 zWox>b1|~{lpkn*-TL35K?^B#7yTQVi6%Yb11x_#~*o-a=o2vN?I4MIBU)&~UCokLB zER?F)*0%Kbe=t$S+>zy#kPzz*4{yfo+gCmfy>mBS_{t)4irTp$>q=rQos?s5{@ZjB zT?e)Ky%Em5t|Z}!EYzkt`JyOzafw@krL>R26jLX)VYUm9Z0sdC{;6vo2TNMP3l z*4|!;?u;COkGu$h&MLvH8y<3ens1zMD5#z$x2|1R6|b-7Ks_?=wYQt!vgQ@F*fihK;k3jdKR8P7)Ug{=6h1|WK6{JZMmno-^6CLbu5l?3<(~yq z$_ICszYYXvPyZZ`d-42kK)@wk4tY90K8#Ih6%IN&6yD31xn0_Wlh0RM7(Dy*YoG75 zGh5SjVPq6UQor!vD5vIwW&N07>w_ur99AZ*Py3xhl3U!{$U4-HC3=51o!Q~{Ir_Qr zPU|@2e@#=ue|$f}$RY7~dc`3$d1RtnXU;Oqa@xT~*&+5+$KuMXvrD?_y6URxs?z!5 zK2=ltWxFe!dUNyMW$n*0pFLfFtj3t+#j(bub9*|JW}_k95Re|AN+CvX%Q6qQ>HvU+ zd@!Ywm>7q+1^^2jLbTCV^`s?Yz`%F=Npt}z4N664LS$vGUY%K&6DPZ@v7N>5{rgK- zwwaoKnHlbV5nrnbvB+jD=9=Uo5(5EZ~`jofOjjg?luGzc3^@>*J& zAET}b?^5%Hqra(XO1FZwq^Y-lB8gi#(mB3l45(QokIrf?^oU0?J3osSmi1h&_3`mf^?9fBWhdIGi{lSQJB!OaO-v`c!buz7Rc z^oKWlTkTZDZTCtq8fxZOVF z#W=QsO*3QzRWSquS5R60IFf8AJP2g$D=VAt-rLb(1<#X{E<_Ix4O}FrFV*|?%gwYp zMa1XFO3Dm;6yq#QQphK}shH}DB$|Ftl;RAbR<%%j#>{E+9F5;qc>LvSDf3Hjt5vYe zd0RoM%7Df*H6JEPxiNF8UwX<|{U0oioj&V2)W4Tq228R&42AFx?5-mfWOy$3htd8Z zRC;3>EznT)x%1aoRXuKZmPIIZ&sbN!Q7PPWX{dc$NzZc7I*4_|zL&HJwW5!{CA&PDIyA|b+4^)&7bi}9s_N`!fn34CswQPN zEaRccN92{B8Nxc^_JJtk+)-E8%@}IF=p8<{LUoaqNy>V&|A86Vd6KbqKnkDTHRg~6 z!k$FSoyEVbm+3fY5CeKef*C?GGo{xK*)oR}#?;w$s z!GW?qj28a6M^ekTWka{o%*RDX_fVGI8hGm)`qL}jL^-sO7R#=kjT4Vaq)~9Fz1a0G z^h@46gKhK5;@z{iH>X!QQz?(zBKZ$&;5za2qwLffXUZaz{6T((cny|Y=c20wP5ZB? z$UNsXz#-Zclu{t=2F@%k=Ms$PFFYD34-VYDZy?30VAb)upKoLcrGBWMMa?SPu{MV~ z-FC94(KnMV<&~r*wXCJKXXp26k@NAE_I(4-f1L2Y0g>OCL(k{ za)anw!*g_9*w1@Q)9-y}F)p$pdHq779d|HCdGX_SPWCy=t7zj7)#Rds+nS9E#A7U?SyF=&R(zASa+0yeb+MGXpa$bm>3lWhy@@k+$YFx7RodegKk?8q} ziSbQ(>N{^$GAz%}r^U-Y2(|KwY%C8O-zgz;H-&LS+qj5Z+&%v6gUdA_xTPKm0T)8+ zSLF3r&bzV)^%A?`mJh4-B7S+ zwmG(38elx2_A|qCdSKo|+sy1&+M%l&e&u=VulN19&X7%}+Vu1vUs~wmnmBHd$*=3A zrG zb4{VuAvS|&(-%a&_!p{fn>|dIYShnM%$5q7_>@3NG2AtqUYoQf7{4xGRv_mM8>w?I z($$z)9M`}TdWkWWXRq`F85$_yQ$us8dAkEn0f~#M%Mc~xj~3xpz&7ly>J;pFa4Zx| zKCnuQSv97bASpe2Mo&x21%>-Ny*EfxBCzeH2k7_QT%76&k=0O}{TQ+k+Cgs|#>}6! zx7GfN^XSL%17$zH$WeNJ7j=l3xvJ}HT;*tdA?YLN=D5&Qnf)l!eB(%iL-O;a_nXWb zeJ_T({%`GFc{r8(+NMccQfF%r;z(s^U?=lbY%1HK7Ey=>5kiI?mN7$W+i901<4Tgu zGA6RveWna6WXn8TmRMw-*LT0#e|*<w-+$aNPVBnWCoOWV`0dd{|{{ zJJ+$@fv!dLGa!5jud4q)cwjYBQd3PcFP1+2!KH43u3V@Vv=wq-4|k{2JW9YT_pI3M z%0*5>BVy{~xa9iab|~DEzQ@w^E*fp!b{ajOvPwHq3%qH(W8qH>gg)nF6*bFyM$1iien8{&A>7H=mFuqSOs?SpWm<3C* z&*!z^xWNYSb=p+Uq-|ikW6^$6kL{_sv{juasy(4w;aWzeU*-0x2?Ty)!=13~Y6|L^ zpq3TA6R=pgXAe>`ipc~O5Zm514F|LiKHz$R?zdG=47kE*fuQWR)Y7{t>D(TR#>eul z^yUWT?t564{o*a%i^UJNJ3~YleoT3}XO#F@3lFncX=y4k?vH_xstbXv%`Xr9*u^52f5%_h>;sZ~c!YV20yup}p&d=)I1xE+wP*fZe( zmm@tRgI0`kNpg_XSRo&<3RfelgbU8Bi*@V0ySE4x9S7k=z_Sd?>vMw=RyO-o;7+qo zA3r`45K?p*!j4tXNKaHc{J8!}k6FR+{1=K!$d#^pP+@J)9lT?A{%m(i+~_acRl>1x z8<%7?EO*w`WnQblab##@f*$1{_&dLx-7u4#WGe7S`sT&iAlr=5mMW=NR(j`h#6~*3 z-i+$V{~8(bN6tz=Yg3U%5cg(=;(7r-`=xMI@4IK=g#KnaeUV z1gKo(Sxb`|8-adgXqgBH5IR&tll_pJ9T}#kq`$Fo^Cyalh_1}lxfX{CiZ1Ku6CObC zeS4H$hZy8wDTk1bc^YWZs%3kMfO)dZJmhIqF ze}-7VZznjFGm+{{s2D3&I^AnH9w(g4Fdv;yEpDFfWV4zn8Yqg*7Z~9alZPc-J{9Hg z(UhdGnzU3$XXhp%sCFG`wH#yY6a z3KZJCT=iB=Oa;7Wvd7zse@1{7B^MBfZHslWW0#k3K&?Nu+}gZ) zy=U|_+?&sDzq$BIzE3&Orxo!G#t9v7-k_&}- ztyshkUu)jAQ~hM#9xh?!@lg(9`#1l5f@2Vl_)Zp$VJ5}y>xM}(6nY`7-6NQ&kd&kE zys>9E1>vDGB4Vqo!i=%E>I=h?eC?!BoxQ_G5lhwzXh2iSI+3c0w!Z|W8BNGn3S`|& zq}SEXLLd54#~Wr}POa_{7fnsQU%Vo7y#)=%A|sp~PE>!Ps>$EzK1No)7FTUHOvua4 z5R#H3?+~Wjar0VbtK;X8P9I{Wzho}e)^VA(=)sMxnU_pk+2vuQ#QhvC<$Q-Y?k`@? z->u9H|B$^xx#dlT`{H5io#;MV$5bq^_jVo7=^cS(%xSy*2MzlQkVJIPASX;aY*F`q z|2BnShWbXyqH*<^o|0ubMB1E5gJ7h**hIlth?WlWS#I|E+kKqyd;e2vCm%8>G}W3; zH*OK^xBFW6(HjfFrW>Q1n0SPE_!P<6al(yRMtY@BaInj#uTH6O%fmr_3$9#C%jTVO zN2f+Q|A0qv$Yt_A%WWz>qUHSzaAsEKwJV2jz-iEbp`-91u4ixIlx?`BkfoBrQ7lq_ zyS1}+=>!Qr4;(VXN;uOviP z9WKDBZ)q!Ws2d5oxj33Hs^A3X4}qKrNx(+cI%l~zSn_MBPvOG$40t^+qU_&u8=d)~ zQup|cBNl}u82J}3GA&LC`N-N0E2L!|F?H>5q~+w@#@6_;1WZl3)CQTCjy zY3l8;1B*{I@>u0ljQN=^I6)&VjmwW~4##&G8c6D~COdY)dn*>82KYO@J^@?Q1Xg^6-f&<-T%Fex4ZQO*zR^J#S(@ zKYa3f^kSP=+t^Q(j7Y<<^!>%lk`8bae=>hFIjo+-b=nCx0CpKa(no00ye zJ8lyUF2Cqf#&^~iRep_gZm;*Oh@G7c*!qGZw+vTeps01i?58)$io0{>sL@*M5fHDc zn}ZS2B6A#vtPzytLL<<~=g7bSuLdD>mI7uLM2EOLNL55x2sUyCK$QW?^gDE-P&H zH8nvG9dF!lR=<&Ff_vd{Z!fm5dLc`>8N2f<6-V`ABBkOIuT~t&4^S(QUu$hQTJ<_A zGQ=d;tK;WO)sMHTzf9)L&Fx{WblsrGbex%{c^mpN_Xr1U#WUdGJUgWOAd)B!NkDJh zW7Uq;dP3JX1E1K_i9uv!=Du&S=C|9oPu9K8Pke2M-N$BW`$~m4w09VP#z;q?!dd0w zxw5h1Cl}0@DyeD{?^t6&Yq=Dcix>{9`}vKZX`LUOrBAHJnq0X&hDLH4t7Vt%^z*99 z5s7I-7-3_ajd9b3@Q$W0iy=xBs`TAtBeR06nJm9$jf6}h-8Y&_OzxI@X=JF_xTD=Y zDtG|CwO$Yi={h5IVyrfcgk=km-YY=m`G{iOr(c(n&qO+lAzk0}F(5K>JZ3C<#4+@6 zL(AEkwX^>Ab}GyOB<%RERy}0;M@F{C3g_l_9EgFV8gBZ%#DT&_y89$&nCgAbcP}Fl zYvq6XVIUe!Lr4tbSoHWAvzox})rD&C@o8zp)_NSKCQ^;Cr;;<28T_#%pEWSrQdX^i zp+v;v8iICH$A-pgE^);qTc@AmMv!H1V%a2KI&9?8>#JjrTlg)FRLvru(+IcEmm$q| zjGNfHK_7RC5`RaTPAEf+jBvU$EMqe=K z1BS`4;^oQZs;=W6aB;^9dSkCkhWv<`{eo)uUbglLD{(U0diwXI3NMk{Ke%kXH2+gI zHt*jdkQ{z!+0wrBAjP2bT+e|&L(bN|8lKOAp)nOg8k&OPA({! zBTBTK`wMeu$Zc_yX>t|$8FGF!(<}EQTXi7Ux+(0ph^S_H63n5EhwlS#KUl3z*rwQ}d>3Nh1f>1)5}&rUO+ zRH_y7oH*{}$!I=2nqltj)XLT@Q2t?_X6Ji1X5%3k@zm8UzylYA?zudF#}1f7MU<6N zDkkf3^NEVbc;jtHr?}{vOpohT)DN4=aHC=x=&C*5BrZoV?aBead} zsV{PL#PL_oL-H57_(E}etq(74OWCFTi2r0^?tK0Czb4;7-20zQnf%=?0_&TaWE%8Q zPIWbRaum4}&Tj1Aly8oFI!Rme*T>Klh;bi`2z(P6`Q5K}foVkQNE9bsgbg5cuNmjr z{=*51o$&v*58K%6m&)6ohs<((3>%-AFLd&RRLCFwR4?i%;)W;(QE^|${N<+Ph!S4I zaU$zl9N{G9rGUiWF>xWu)PtkP0qKYE%M*exVp%OFj!Mc%^}=+a;L zK(craIs2EH;b)0cNyMo##Hk=qC_z$8ZN5Jg6qJk4>1$5&LSUj4ga{{Q0ey$3I0Y|` z`crt2qxa7@?>;@|V3YV*Xmb)&2bp~LHhWgytVw|)T257xOaLBN?KE)@+> zpn0 z_mqISvHdUQ`TlOu5`Xxgr3$~@;#-gWi?Yz)hSs;C^=)YVW2DBnIr8tGBhF&ZUmcCopd@aNvyzZ;)yZXkjNXLM!U%PpuFw{bDe7nK7HuyFUzRiXIOZK2Q>GG>j z!@E|xAVW{U5T~2XS%~3LmuK1_z76DACiv(sE>oz@5f`c{^tyR9Xa|tv5<(0v*>drHRAG;NT9F7yw#j zg0><;T*y{yOkG0;{}ia5Y4Y2`!bOD03QW_#yfjP+(@yB=>G2uQZE+fe9wH>ZIDFQn z_-|je?5XS0%-Ov_8oM4biJ)SGDMeqqz?4a8vRb{ZF;gu~@UIsNoc0xdg8q%aPqJak zh1WNP6LL^?b6<<%$Ehh7ZD{o`uQcMp_~qT5kq(1KT*34&lx|u)&G&j3a!c&@t(=QYbPm_@d1tSXTK8Ouh4-h&{ zRNywLxwjV_cJc8^glV@F+(9=Iy5Eo!5=ZnhXFeCb5_0ce!XqT{Wbu5YWCIB>qN`M2 z_XkbQ%vd^rm?)j#0#2QFf)k>ZPbsU9lUZXd^whba9k{F6krw@o=4?YHWo6IiK6cw& zV?zoD2S;{Y6iH%7FtD-d1`>q$&!TJKXpNmuZR}E_|gDRGytCFy+cQ`1XUCi z&L7;t-{e6iD@G-|7a6C?9yvmi2z%^F2iuFJjmA{CB=?8o>H?m+Caud)Bmsgyu7KY{ z2JaCG-99!ZxsB7+Uh9lRSXM6WUQrb3{eY8*h)7FgA@lB+@!{zOFh#qHjyu0PS97>7 zR;BHA#AwF@8a@11qty}l*wZS{my7}ywQ_QHt=sbNtO`0`j7Yn~7DGa!L?XNzW#g)h z?eJZrqjsGk3JFJLWWq>fq@lw-^IrX?pj0dQJ0$)lDFvp7s|W7!Y(rxdnCglX)W#|+ z=K|BbDy{76`XNb4;wiC27}vKcdD`U0QRvy=c=F`QC#sLnTQi|uy9%5Jw67d@Ody>S z_d*1ln66H`KhwGE7(R<`M~}A7f}G_-uSQbO#K{UI3{=A)C0)lfzCj$B?twByH1MBAQ`c)sx_w1Fx z4MUGehr_F9ishl)c~jIx66)~`!;qi1Kn<++@Z;3xx|*7r_Rs#n^C8Qj;yz)Kmso*L z_S?Vu#pf*3wtT?sFuR}iHwypfNL0CaOj>}7Q@B+o75dKy4piO?Y}=fjogEq)>NEev zwbNZR!62jA|G!+GKuS6-hT?V4<}K34PK zYF)DgiYyjGsGd_&S$?JxVG#0*^r-~w+MQkwu+37IAtsAV@>Y9*yMabK{1T()*0Ka|dIUT+>NO7G3SpiDQ#Pmn=9$nH`1s&^wa|OO2yX z&Z8WmQhs+OeR9Cb-N`BI3myZ?9Z%1+t)?Rcf7U~_sFWt@C5lZh8FpfX47fNZ3cI=e z_3ynX%}h_a>V3fmtWIN8RxAk&&8{44eQgFvpb*oY_be{>vDqdUs3wc%dvZPIj<^~s zJb>F6ZXw>Qa9n~Zn z!nfiU+j7P(05SOZVE1d~Dk>_v09$xOiaVW2l_pLZKWDux9FTGFyH!)A#H}sS>XVi0;y9 z6o=`(t7oPPLN6F#efJ{?M^#ljhcB)E^_&Wk!hF;a3i4BIRMdem?X*}(gAf+91SK$6Zj3LOG^!l+8_iM2IY6aEhYdLo~@4Yz}9d zO8DC;fEB>oEe7fix@GsGN<{jBdDygR6EGfsI#*|j@*Gr5LAYcTsCgqX2(i5t{Bd$} e5@(7hu-5GHY}VE+X%!@1?v$qPkLl_c{r(f|;=}m> literal 0 HcmV?d00001 diff --git a/docs/index.html b/docs/index.html index 356ac47..aa680d7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,15 +3,21 @@ - - + + + + + + + + CloudBridge - Multi-Cloud Cost Management - + - + + + -
From 9739d728ded3dd8918ff06a565775402fe895934 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Fri, 28 Aug 2026 16:11:25 +0800 Subject: [PATCH 3/4] Remove the GitHub Copilot credit from the README footer --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 6d86edc..dc8e203 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file **[⬆ Back to Top](#cloudbridge)** -Made with Github Copilot - From e412a71bbd34b3052b5ed492ee4d87193aeb27bc Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Fri, 28 Aug 2026 16:11:25 +0800 Subject: [PATCH 4/4] Remove the Dependabot configuration --- .github/dependabot.yml | 51 ------------------------------------------ 1 file changed, 51 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 19c37bc..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,51 +0,0 @@ -version: 2 -updates: - # Enable Cargo (Rust) dependency updates - - package-ecosystem: "cargo" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "09:00" - timezone: "UTC" - # Group all GPUI-related packages together - groups: - gpui: - patterns: - - "gpui*" - update-types: - - "minor" - - "patch" - # Allow up to 5 open pull requests for cargo dependencies - open-pull-requests-limit: 5 - # Add labels to pull requests - labels: - - "dependencies" - - "rust" - # Commit message prefix - commit-message: - prefix: "chore" - include: "scope" - # Ignore major version updates for stable dependencies - ignore: - # Keep gpui at 0.2.x for now (breaking changes expected in 1.0) - - dependency-name: "gpui" - update-types: ["version-update:semver-major"] - # Rebase strategy for pull requests - rebase-strategy: "auto" - - # Enable GitHub Actions dependency updates - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "09:00" - timezone: "UTC" - open-pull-requests-limit: 5 - labels: - - "dependencies" - - "github-actions" - commit-message: - prefix: "chore" - include: "scope"