diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index dcbdd5d..41d505f 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -30,6 +30,41 @@ mod tests { .map(|h| h.contains("ical-flex")) .unwrap_or(false)); } + + #[test] + fn google_calendar_email_2_renders_ical_summary() { + use mailparse::parse_mail; + let raw_email = include_str!("../testdata/google-calendar-example-2.eml"); + let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail"); + let mut part_addr = vec![]; + let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); + let meta = extract_calendar_metadata_from_mail(&parsed, &body); + // Assert detection as Google Calendar + assert!(meta.is_google_calendar_event); + // Assert metadata extraction (update these values to match the new .eml) + assert_eq!(meta.summary, Some("McClure BLT".to_string())); + // Organizer: from From header, extract email address + assert_eq!( + meta.organizer, + Some("calendar-notification@google.com".to_string()) + ); + // Dates: from subject, Thu Sep 11 to Fri Jan 30, 2026 + let current_year = chrono::Local::now().year(); + assert_eq!(meta.start_date, Some(format!("{}0911", current_year))); + assert_eq!(meta.end_date, Some("20260131".to_string())); + // Debug: print the rendered HTML for inspection + if let Some(ref html) = meta.body_html { + println!("Rendered HTML: {}", html); + } else { + println!("No body_html rendered"); + } + // Assert ical summary is rendered and prepended (look for 'ical-flex' class) + assert!(meta + .body_html + .as_ref() + .map(|h| h.contains("ical-flex")) + .unwrap_or(false)); + } } #[derive(Debug, PartialEq)] pub struct ExtractedCalendarMetadata { @@ -128,23 +163,22 @@ pub fn extract_calendar_metadata_from_mail( if let Some(s) = summary_guess { summary = Some(s); } - // Try to extract start/end dates from subject, e.g. "@ Tue Jun 24 - Mon Jun 30, 2025" + // Try to extract start/end dates from subject + // 1. Fallback: handle missing year, use current year for start date if let Some(at_idx) = subject.find('@') { let after_at = &subject[at_idx + 1..]; - // Look for a date range like "Tue Jun 24 - Mon Jun 30, 2025" - let date_re = regex::Regex::new( - r"(\w{3}) (\w{3}) (\d{1,2}) - (\w{3}) (\w{3}) (\d{1,2}), (\d{4})", + // Try format: 'Tue Jun 24 - Mon Jun 30, 2025' + let dash_re = regex::Regex::new( + r"\w{3} (\w{3}) (\d{1,2}) - \w{3} (\w{3}) (\d{1,2}), (\d{4})", ) .ok(); - if let Some(re) = &date_re { + if let Some(re) = &dash_re { if let Some(caps) = re.captures(after_at) { - // e.g. Tue Jun 24 - Mon Jun 30, 2025 - let start_month = &caps[2]; - let start_day = &caps[3]; - let end_month = &caps[5]; - let end_day = &caps[6]; - let year = &caps[7]; - // Try to parse months as numbers + let start_month = &caps[1]; + let start_day = &caps[2]; + let end_month = &caps[3]; + let end_day = &caps[4]; + let year = &caps[5]; let month_map = [ ("Jan", "01"), ("Feb", "02"), @@ -177,7 +211,6 @@ pub fn extract_calendar_metadata_from_mail( ); let end_date_str = format!("{}{}{}", year, end_month_num, format!("{:0>2}", end_day)); - // Increment end date by one day to match iCalendar exclusive end date let end_date_exclusive = chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d") .ok() @@ -188,6 +221,146 @@ pub fn extract_calendar_metadata_from_mail( end_date = Some(end_date_exclusive); } } + let after_at = &subject[at_idx + 1..]; + // 1. Try regex with year in end date first + let fallback_re = regex::Regex::new( + r"from \w{3} (\w{3}) (\d{1,2}) to \w{3} (\w{3}) (\d{1,2}), (\d{4})", + ) + .ok(); + let mut matched = false; + if let Some(re) = &fallback_re { + if let Some(caps) = re.captures(after_at) { + let start_month = &caps[1]; + let start_day = &caps[2]; + let end_month = &caps[3]; + let end_day = &caps[4]; + let year = &caps[5]; + let current_year = chrono::Local::now().year(); + let month_map = [ + ("Jan", "01"), + ("Feb", "02"), + ("Mar", "03"), + ("Apr", "04"), + ("May", "05"), + ("Jun", "06"), + ("Jul", "07"), + ("Aug", "08"), + ("Sep", "09"), + ("Oct", "10"), + ("Nov", "11"), + ("Dec", "12"), + ]; + let start_month_num = month_map + .iter() + .find(|(m, _)| *m == start_month) + .map(|(_, n)| *n) + .unwrap_or("01"); + let end_month_num = month_map + .iter() + .find(|(m, _)| *m == end_month) + .map(|(_, n)| *n) + .unwrap_or("01"); + // Use current year for start date, year from subject for end date + let start_date_str = format!( + "{}{}{}", + current_year, + start_month_num, + format!("{:0>2}", start_day) + ); + let end_date_str = + format!("{}{}{}", year, end_month_num, format!("{:0>2}", end_day)); + let end_date_exclusive = + chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d") + .ok() + .and_then(|d| d.succ_opt()) + .map(|d| d.format("%Y%m%d").to_string()) + .unwrap_or(end_date_str); + start_date = Some(start_date_str); + end_date = Some(end_date_exclusive); + matched = true; + } + } + // 2. If not matched, fallback to missing-year regex + if !matched { + let fallback_no_year_re = regex::Regex::new( + r"from \w{3} (\w{3}) (\d{1,2}) to \w{3} (\w{3}) (\d{1,2})", + ) + .ok(); + if let Some(re) = &fallback_no_year_re { + if let Some(caps) = re.captures(after_at) { + let start_month = &caps[1]; + let start_day = &caps[2]; + let end_month = &caps[3]; + let end_day = &caps[4]; + let current_year = chrono::Local::now().year(); + let month_map = [ + ("Jan", "01"), + ("Feb", "02"), + ("Mar", "03"), + ("Apr", "04"), + ("May", "05"), + ("Jun", "06"), + ("Jul", "07"), + ("Aug", "08"), + ("Sep", "09"), + ("Oct", "10"), + ("Nov", "11"), + ("Dec", "12"), + ]; + let start_month_num = month_map + .iter() + .find(|(m, _)| *m == start_month) + .map(|(_, n)| *n) + .unwrap_or("01"); + let end_month_num = month_map + .iter() + .find(|(m, _)| *m == end_month) + .map(|(_, n)| *n) + .unwrap_or("01"); + let start_date_str = format!( + "{}{}{}", + current_year, + start_month_num, + format!("{:0>2}", start_day) + ); + let end_date_str = format!( + "{}{}{}", + current_year, + end_month_num, + format!("{:0>2}", end_day) + ); + let end_date_exclusive = + chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d") + .ok() + .and_then(|d| d.succ_opt()) + .map(|d| d.format("%Y%m%d").to_string()) + .unwrap_or(end_date_str); + start_date = Some(start_date_str); + end_date = Some(end_date_exclusive); + // Set organizer from From header if not already set + if organizer.is_none() { + if let Some(from) = m.headers.get_first_value("From") { + let email = from + .split('<') + .nth(1) + .and_then(|s| s.split('>').next()) + .map(|s| s.trim().to_string()) + .or_else(|| Some(from.trim().to_string())); + organizer = email; + } + } + // If we matched this, skip the other regexes + return ExtractedCalendarMetadata { + is_google_calendar_event: is_google, + summary, + organizer, + start_date, + end_date, + body_html, + }; + } + } + } } } // Try to extract organizer from From header @@ -300,6 +473,7 @@ pub fn extract_calendar_metadata_from_mail( caption, description_paragraphs: &description_paragraphs, today: Some(chrono::Local::now().date_naive()), + recurrence_display: String::new(), }; if let Ok(rendered) = template.render() { body_html = Some(rendered); @@ -1464,6 +1638,7 @@ pub struct IcalSummaryTemplate<'a> { pub caption: String, pub description_paragraphs: &'a [String], pub today: Option, + pub recurrence_display: String, } // Add this helper function to parse the DMARC XML and summarize it. @@ -1831,6 +2006,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result { caption, description_paragraphs: description_paragraphs_val, today: Some(chrono::Local::now().date_naive()), + recurrence_display: String::new(), }; summary_parts.push(template.render()?); } diff --git a/server/templates/ical_summary.html b/server/templates/ical_summary.html index 3c5287d..d156749 100644 --- a/server/templates/ical_summary.html +++ b/server/templates/ical_summary.html @@ -43,6 +43,11 @@ }}
Start: {{ local_fmt_start }}
End: {{ local_fmt_end }}
+ {% if !recurrence_display.is_empty() %} +
+ Repeats: {{ recurrence_display }} +
+ {% endif %} {% if !organizer_cn.is_empty() %}
Organizer: {{ organizer_cn }}
{% elif !organizer.is_empty() %} @@ -105,4 +110,4 @@

{{ p }}

{% endfor %} -{% endif %} \ No newline at end of file +{% endif %} diff --git a/server/testdata/google-calendar-example-2.eml b/server/testdata/google-calendar-example-2.eml new file mode 100644 index 0000000..c4ff14d --- /dev/null +++ b/server/testdata/google-calendar-example-2.eml @@ -0,0 +1,167 @@ +Return-Path: +Delivered-To: bill@xinu.tv +Received: from phx.xinu.tv [74.207.253.222] + by nixos-01.h.xinu.tv with IMAP (fetchmail-6.5.1) + for (single-drop); Mon, 25 Aug 2025 14:29:47 -0700 (PDT) +Received: from phx.xinu.tv + by phx.xinu.tv with LMTP + id TPD3E8vVrGjawyMAJR8clQ + (envelope-from ) + for ; Mon, 25 Aug 2025 14:29:47 -0700 +X-Original-To: gmail@xinu.tv +Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=2a00:1450:4864:20::12e; helo=mail-lf1-x12e.google.com; envelope-from=couchmoney+caf_=gmail=xinu.tv@gmail.com; receiver=xinu.tv +Authentication-Results: phx.xinu.tv; + dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20230601 header.b=4sz9KOqm +Received: from mail-lf1-x12e.google.com (mail-lf1-x12e.google.com [IPv6:2a00:1450:4864:20::12e]) + by phx.xinu.tv (Postfix) with ESMTPS id 2F9058B007 + for ; Mon, 25 Aug 2025 14:29:45 -0700 (PDT) +Received: by mail-lf1-x12e.google.com with SMTP id 2adb3069b0e04-55f4969c95aso994593e87.0 + for ; Mon, 25 Aug 2025 14:29:45 -0700 (PDT) +ARC-Seal: i=2; a=rsa-sha256; t=1756157384; cv=pass; + d=google.com; s=arc-20240605; + b=Y2CP7y9twLnWB5v8iyzZCw0vp33wQBS0qzltdtzX2NIWFhHu6MEp2XH8cONssaGrEN + kyjXajT7uaEpn6G8H6/NB9v9Vo2yk5Lq2f+RhODMYoocYs9YY9NJI4ZxMph0UeMO6RkQ + m+HH0iIeC2Mzgj1Bzq4qFEwb397YIijoxx+1RxyA2D3cwSuZtERSvFOEkHqv9ziWxBcD + u3tvySEuzjyQFU6bxfkax6sZljSRGzfj0iZJAl/Fw5tUgrhndQ55O5RDe4NfPNj0cw/3 + XDELzsnepBgnW8Jpqpnh7iK6XMFSf4sPQmyiMCMDNVYtmm6hYFNo3/dOpgaPn/ImRr8j + d9lw== +ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; + h=to:from:subject:date:message-id:auto-submitted:reply-to + :mime-version:dkim-signature:delivered-to; + bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=; + fh=xB02AmI2fnPF5rMnM90IwqQ6Il76V+xMgSnSW+E42fE=; + b=H7Ze4a8zoCYB77xcnUnFTogJ/utYS/USzTL/7eS3nA6OPbD+zWRiiVmbSfQcNK7d25 + LapXyYnRJKgc8sqqQ6XO26STA8xx/9G620pdTytChIzKsmm/T5cdlf1M8DJ+NlwkzzSG + 6Xe5I0MuXSKzBDMmcBcMlY9+mp61eZNo/cGT34MfZvLDS7JCs5uQYy2gRyajCKzRddEP + NBfMgnP1Ag9B5KkpJr4QfA2IWoNlj/qom/bRcdcdjwQ3gwDeiG8rdrEwBt9juwqk8d95 + C0LnVKfrXAZgolmJpljyIFb1IMMyBUIQhK+7cXFhV1AD6Laz0df9gmPWp5mGZz9qlYaY + BqJA==; + darn=xinu.tv +ARC-Authentication-Results: i=2; mx.google.com; + dkim=pass header.i=@google.com header.s=20230601 header.b=4sz9KOqm; + spf=pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=3OdSsaAoSCuANOEQNNKPIUUGCVVNGUEJQQNU.QTIEQWEJOQPGaIOCKN.EQO@calendar-server.bounces.google.com; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=google.com; + dara=pass header.i=@gmail.com +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=1e100.net; s=20230601; t=1756157384; x=1756762184; + h=to:from:subject:date:message-id:auto-submitted:reply-to + :mime-version:dkim-signature:delivered-to:x-forwarded-for + :x-forwarded-to:x-gm-message-state:from:to:cc:subject:date + :message-id:reply-to; + bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=; + b=m95okwnmqNvW4GhCfY8yZvCu5NxuhHCL2+A54SlIrRudednXK05YGzjZ5LOuCAaY1g + htpRv2cGHBj2mEnHh+3GIX5vQCmXw2ptzOGzfYe9TwavuKPkkKPiSD5wA1fk8quqHDOD + 4XDM7dsn3xewJ+6GQyc6NPBQq53hmpAojbLXnmNtAIyfAvuxtHP1G+GSO+ZIApgg56K6 + TaYrwqnRx66P8B2Ze111LCdnmOOLzweJ1muYyavPdCtTG5BbJgqzaI67bQhuUNZDhVbP + FdtT4Q7WzNt30JHCVIAkkHejD9Fh/mYSmETXpD+ISvZJ47DNnLP4RXjmmAWcHJkKsh+q + v3QQ== +X-Forwarded-Encrypted: i=2; AJvYcCUeIjyIxPoWuMqg9l5aomQv7Z9wLYkwDIS1FYz7bNmHs1Cs0CSHG8Y5B0iU/nlo9xRenTW/Xw==@xinu.tv +X-Gm-Message-State: AOJu0Yznjr5TC7UpZJk74jrsJzMBwx6/39s9e5ufIA5/FmHZ6I1bEdTc + vqpeeLdzSZTI2uZiR7zzKHiwmNJHt/LncR9kDR5f0I6b3MZuXpAgr0aKYdXw7B+b+h7D7uMM3Tm + JF9ccf09JxIzRzeRI9Vb52PUs4SIeiIU9J80QY53UqN/Rx8XMF+ncRSX5d4V4pQ== +X-Received: by 2002:a05:6512:110e:b0:55f:3bab:f204 with SMTP id 2adb3069b0e04-55f3babf35emr3087055e87.31.1756156987711; + Mon, 25 Aug 2025 14:23:07 -0700 (PDT) +X-Forwarded-To: gmail@xinu.tv +X-Forwarded-For: couchmoney@gmail.com gmail@xinu.tv +Delivered-To: couchmoney@gmail.com +Received: by 2002:a05:6504:6116:b0:2b8:eb6f:82ec with SMTP id i22csp44357ltt; + Mon, 25 Aug 2025 14:23:06 -0700 (PDT) +X-Received: by 2002:a05:6e02:164e:b0:3ed:94a6:2edb with SMTP id e9e14a558f8ab-3ed94a63097mr41416195ab.21.1756156986122; + Mon, 25 Aug 2025 14:23:06 -0700 (PDT) +ARC-Seal: i=1; a=rsa-sha256; t=1756156986; cv=none; + d=google.com; s=arc-20240605; + b=Nu0W/67J2nYqDAXf27QdfmUyuA6TGJwusKLaHRaE05YdEu/FWLfUk2ATV+g3iUQ19b + wh7awaA5kemxwiBqAy5kjjlXqlDrkK0Ow2fANdc6lRKvlRNJRYUnojMkP8w/v4Nv8YQj + Wci0HMhL4ni/yeqXeoaj1yKtwJU5MvRMxZZC7TinlCHKF5+MqgD8VNax8OTDOqxYvSDi + aIlyUBTial0AiP/K+3bsoIWEc2RoyBBBNIe88C4s1fcv17GCGn5RkN3lYtr+nwvp5wNE + fKxPCYMtXkNyv8jgjmgxKLcYBDK0B4Zo+ghMWXZneDWo3qotDVkr0GBC3J2N7BcZpjCA + XEDA== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; + h=to:from:subject:date:message-id:auto-submitted:reply-to + :mime-version:dkim-signature; + bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=; + fh=mbzrMIWIgWMC0ni1xEx+ViW4J0RLAdLdPT2cX81nTlk=; + b=NvhrlkKGEVx63UMsx510U8ePUo7OgRQBWxZ4BIpQWg6Fk0jJPaZgRoEpUdZ747et1P + rWTx/yVaEUHBqWtt0I4ktiD8Hr4cVqAwKvtiN32JpkGCsVBjYBWqxEalWIOg6abn8xLE + 7x9j4GqD/cQhd3DiS6UtADsJ67MjjzLpGkskvxo67vKRGCfSLCKdbna2LO5TtoZ7fKO7 + i+dhDol6IIgA2Sg+PZlzq6gbZTaFbglUNI7uOwz0fNWjhHH4ZfmPEycYxJ9bTuPISrqS + BkXxGQFkvlg42NHWt5L8aPzrx8OMoYfTniIqU19GeEFEVUbmzYCg/twZ0f5nxugHWDbD + PMvQ==; + dara=google.com +ARC-Authentication-Results: i=1; mx.google.com; + dkim=pass header.i=@google.com header.s=20230601 header.b=4sz9KOqm; + spf=pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=3OdSsaAoSCuANOEQNNKPIUUGCVVNGUEJQQNU.QTIEQWEJOQPGaIOCKN.EQO@calendar-server.bounces.google.com; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=google.com; + dara=pass header.i=@gmail.com +Received: from mail-sor-f73.google.com (mail-sor-f73.google.com. [209.85.220.73]) + by mx.google.com with SMTPS id ca18e2360f4ac-886c8fc41ebsor461233039f.7.2025.08.25.14.23.05 + for + (Google Transport Security); + Mon, 25 Aug 2025 14:23:06 -0700 (PDT) +Received-SPF: pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) client-ip=209.85.220.73; +Authentication-Results: mx.google.com; + dkim=pass header.i=@google.com header.s=20230601 header.b=4sz9KOqm; + spf=pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=3OdSsaAoSCuANOEQNNKPIUUGCVVNGUEJQQNU.QTIEQWEJOQPGaIOCKN.EQO@calendar-server.bounces.google.com; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=google.com; + dara=pass header.i=@gmail.com +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=google.com; s=20230601; t=1756156985; x=1756761785; dara=google.com; + h=to:from:subject:date:message-id:auto-submitted:reply-to + :mime-version:from:to:cc:subject:date:message-id:reply-to; + bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=; + b=4sz9KOqmGGwObcaR0iSSMVeeMvZHqMzvY4cw++RddJd0V48WoyPPI5q1oMeGiVZ6fm + eEWVr8xH9/T1JUqUZXJHY6CPixN9nTpLvZlpikG1KOFv5+I5DNVX/O5i6M5C/yIPRVGv + ja0ygA7WTL48IkHV7+PTPwHmhF8zv1/BeNdko4BSywfql64J6NMM5RnOAejTIf5AR/IL + CW7H2IcmiOGBHfgMApQljg3wB+WgUel7RXZfMnHCbSlmynJ6bDJ4tq7uU16GLpnI6qAe + s9w8cOpFPiQk8uKEqdc682XxKlwqYdh07RWO/EdlZ8WeSoxMfU6YZL7c1s6xxK2c9sT7 + 8Xxg== +X-Google-Smtp-Source: AGHT+IFJwttd47Uo06h0EKkogFtVf4poWcHfmodh4dZqSviwYROSgnnyI2ZJSibXGnOUHiLIfAwFn6KP9CzXMoyncWSb +MIME-Version: 1.0 +X-Received: by 2002:a05:6602:14c9:b0:884:47f0:b89f with SMTP id + ca18e2360f4ac-886bd0f2960mr1726062839f.3.1756156985586; Mon, 25 Aug 2025 + 14:23:05 -0700 (PDT) +Reply-To: tconvertino@gmail.com +Auto-Submitted: auto-generated +Message-ID: +Date: Mon, 25 Aug 2025 21:23:05 +0000 +Subject: New event: McClure BLT @ Monthly from 7:30am to 8:30am on the second + Thursday from Thu Sep 11 to Fri Jan 30, 2026 (PDT) (tconvertino@gmail.com) +From: "lmcollings@seattleschools.org (Google Calendar)" +To: couchmoney@gmail.com +Content-Type: multipart/alternative; boundary="0000000000004bc1be063d372904" + +--0000000000004bc1be063d372904 +Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes +Content-Transfer-Encoding: base64 + +TWNDbHVyZSBCTFQNCk1vbnRobHkgZnJvbSA3OjMwYW0gdG8gODozMGFtIG9uIHRoZSBzZWNvbmQg +VGh1cnNkYXkgZnJvbSBUaHVyc2RheSBTZXAgMTEgIA0KdG8gRnJpZGF5IEphbiAzMCwgMjAyNg0K +UGFjaWZpYyBUaW1lIC0gTG9zIEFuZ2VsZXMNCg0KTG9jYXRpb24NCk1jQ2x1cmUgTGlicmFyeQkN +Cmh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vbWFwcy9zZWFyY2gvTWNDbHVyZStMaWJyYXJ5P2hsPWVu +DQoNCg0KDQpCTFQgd2lsbCBtZWV0IG9uIHRoZSAybmQgVGh1cnNkYXkgb2YgZXZlcnkgbW9udGgg +dW50aWwgSmFudWFyeSB3aGVuIHdlICANCmJlZ2luIGxvb2tpbmcgYXQgYnVkZ2V0LiBBZGRpdGlv +bmFsIG1lZXRpbmdzIG1heSBhbHNvIGJlIHNjaGVkdWxlZCBlYXJsaWVyICANCmlmIG5lZWRlZC4N +ClRoYW5rcywNCk1jQ2x1cmUgQkxUDQoNCg0KDQpPcmdhbml6ZXINCmxtY29sbGluZ3NAc2VhdHRs +ZXNjaG9vbHMub3JnDQpsbWNvbGxpbmdzQHNlYXR0bGVzY2hvb2xzLm9yZw0KDQpHdWVzdHMNCmxt +Y29sbGluZ3NAc2VhdHRsZXNjaG9vbHMub3JnIC0gb3JnYW5pemVyDQp0Y29udmVydGlub0BnbWFp +bC5jb20gLSBjcmVhdG9yDQptYW5kcy5hbmRydXNAZ21haWwuY29tDQphbXNjaHVtZXJAc2VhdHRs +ZXNjaG9vbHMub3JnDQphcGplbm5pbmdzQHNlYXR0bGVzY2hvbHMub3JnDQpsbWJsYXVAc2VhdHRs +ZXNjaG9vbHMub3JnDQptbmxhbmRpc0BzZWF0dGxlc2Nob29scy5vcmcNCnRtYnVyY2hhcmR0QHNl +YXR0bGVzY2hvb2xzLm9yZw0KbWNjbHVyZWFsbHN0YWZmQHNlYXR0bGVzY2hvbHMub3JnIC0gb3B0 +aW9uYWwNClZpZXcgYWxsIGd1ZXN0IGluZm8gIA0KaHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29t +L2NhbGVuZGFyL3I/ZWlkPVh6WXdjVE13WXpGbk5qQnZNekJsTVdrMk1HODBZV016WnpZd2NtbzRa +M0JzT0RoeWFqSmpNV2c0TkhNelpHZzVae1l3Y3pNd1l6Rm5OakJ2TXpCak1XYzNORG96T0dkb2Fq +WXhNR3RoWjNFeE5qUnhhemhuY0djMk5HOHpNR014WnpZd2J6TXdZekZuTmpCdk16QmpNV2MyTUc4 +ek1tTXhaell3YnpNd1l6Rm5PR2R4TTJGalNXODNOSUF6YVdReGJUY3hNbXBqWkRGck5qVXhNamhq +TVcwM01USnFNbWRvYnpnMGN6TTJaSEJwTmprek1DQjBZMjl1ZG1WeWRHbHViMEJ0JmVzPTENCg0K +fn4vL35+DQpJbnZpdGF0aW9uIGZyb20gR29vZ2xlIENhbGVuZGFyOiBodHRwczovL2NhbGVuZGFy +Lmdvb2dsZS5jb20vY2FsZW5kYXIvDQoNCllvdSBhcmUgcmVjZWl2aW5nIHRoaXMgZW1haWwgYmVj +YXVzZSB5b3UgYXJlIHN1YnNjcmliZWQgdG8gY2FsZW5kYXIgIA0Kbm90aWZpY2F0aW9ucy4gVG8g +c3RvcCByZWNlaXZpbmcgdGhlc2UgZW1haWxzLCBnbyB0byAgDQpodHRwczovL2NhbGVuZGFyLmdv +b2dsZS5jb20vY2FsZW5kYXIvci9zZXR0aW5ncywgc2VsZWN0IHRoaXMgY2FsZW5kYXIsIGFuZCANCmNoYW5nZSAiT3RoZXIgbm90aWZpY2F0aW9ucyIuDQoNCkZvcndhcmRpbmcgdGhpcyBpbnZp +dGF0aW9uIGNvdWxkIGFsbG93IGFueSByZWNpcGllbnQgdG8gc2VuZCBhIHJlc3BvbnNlIHRvICAN +CnRoZSBvcmdhbml6ZXIsIGJlIGFkZGVkIHRvIHRoZSBndWVzdCBsaXN0LCBpbnZpdGUgb3RoZXJz +IHJlZ2FyZGxlc3Mgb2YgIA0KdGhlaXIgb3duIGludml0YXRpb24gc3RhdHVzLCBvciBtb2RpZnkg +eW91ciBSU1ZQLg0KDQpMZWFybiBtb3JlIGh0dHBzOi8vc3VwcG9ydC5nb29nbGUuY29tL2NhbGVu +ZGFyL2Fuc3dlci8zNzEzNSNmb3J3YXJkaW5nDQo= +--0000000000004bc1be063d372904--