Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Improve Google Calendar descriptions #12

Merged
merged 6 commits into from
Jan 13, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -29,4 +29,3 @@ serde_json = "1.0.74"

[dev-dependencies]
insta = "1.10.0"
indoc = "1.0.3"
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: src/input/web.rs
assertion_line: 352
assertion_line: 314
expression: event

---
@@ -64,4 +64,5 @@ attendees:
- name: Alexander (AJ) Mody
count: 2
comment: Planning to stay Friday Night only.
timestamp: "1970-01-01T00:00:00Z"

Large diffs are not rendered by default.

129 changes: 30 additions & 99 deletions src/input/web.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
use crate::model::{Attendee, Comment, Event};

use chrono::NaiveDate;
use chrono::{DateTime, NaiveDate, Utc};
use futures::{stream, StreamExt, TryStreamExt};
use select::document::Document;
use select::node::Data;
use select::node::Node;
use select::predicate::{And, Attr, Class, Name};
use tap::prelude::*;
use tracing::info;
@@ -112,7 +110,8 @@ impl<'a> Web<'a> {
) -> Result<Event, Box<dyn std::error::Error>> {
info!(%event.id, %event, url=%event.url, "Fetching event");
let event_page = Page::from_url(&self.client, &event.url).await?;
let event = Event::try_from((event, event_page))?;
let timestamp = Utc::now();
let event = Event::try_from((event, event_page, timestamp))?;
Ok(event)
}
}
@@ -139,11 +138,11 @@ impl Page {
}
}

impl TryFrom<(Event, Page)> for Event {
impl TryFrom<(Event, Page, DateTime<Utc>)> for Event {
type Error = Box<dyn std::error::Error>;

fn try_from(event_page_pair: (Event, Page)) -> Result<Self, Self::Error> {
let (event_item, page) = event_page_pair;
fn try_from(event_page_timestamp: (Event, Page, DateTime<Utc>)) -> Result<Self, Self::Error> {
let (event_item, page, timestamp) = event_page_timestamp;

let id = event_item.id;
let title = event_item.title;
@@ -155,7 +154,7 @@ impl TryFrom<(Event, Page)> for Event {

let document = Document::from(page.as_ref());

let comments = document
let comments: Vec<Comment> = document
.find(Class("kmt-wrap"))
.map(|node| {
let author = node
@@ -186,15 +185,19 @@ impl TryFrom<(Event, Page)> for Event {
Comment { author, date, text }
})
.collect();
let comments = Some(comments);
let comments = if comments.is_empty() {
None
} else {
Some(comments)
};

let attendee_names = document
.find(Class("attendee_name"))
.map(|node| node.text());
let attendee_comments = document
.find(Class("number_of_tickets"))
.map(|node| node.text());
let attendees = attendee_names
let attendees: Vec<Attendee> = attendee_names
.zip(attendee_comments)
.map(|(name, comment)| {
let count = comment.split_once(' ').unwrap().0[1..].parse().unwrap();
@@ -208,7 +211,13 @@ impl TryFrom<(Event, Page)> for Event {
}
})
.collect();
let attendees = Some(attendees);
let attendees = if attendees.is_empty() {
None
} else {
Some(attendees)
};

let timestamp = Some(timestamp);

let event = Event {
id,
@@ -220,6 +229,7 @@ impl TryFrom<(Event, Page)> for Event {
description,
comments,
attendees,
timestamp,
};

Ok(event)
@@ -236,12 +246,11 @@ impl TryFrom<(&str, Page)> for EventList {
fn try_from(url_page: (&str, Page)) -> Result<Self, Self::Error> {
let (base_url, page) = url_page;

let mut events: Vec<Event> = serde_json::from_str(page.as_ref())?;

for event in &mut events {
event.url = [base_url, &event.url].join("");
event.description = parse_description(&event.description);
}
let events = serde_json::from_str::<Vec<Event>>(page.as_ref())?.tap_mut(|events| {
events
.iter_mut()
.for_each(|event| event.url = [base_url, &event.url].join(""))
});

Ok(Self(events))
}
@@ -262,52 +271,11 @@ impl FromIterator<Event> for EventList {
}
}

fn parse_description(s: &str) -> String {
let document = Document::from(s);
let mut buffer = String::with_capacity(s.len());
let node = document.find(Name("body")).next().unwrap();
parse_node_text(&node, &mut buffer);
buffer
}

fn parse_node_text(node: &Node, buffer: &mut String) {
for child in node.children() {
match child.data() {
Data::Text(_) => {
let text = child.as_text().unwrap();
match text {
// Ignore newline-only text elements
"\n" => (),
_ => buffer.push_str(text),
}
}
Data::Element(_, _) => {
// Handles case where we transition from a non-newline element to a newline element
// I.e. Inserts a newline between a non-newline element and a newline element
maybe_newline(&child, buffer);
parse_node_text(&child, buffer);
// Insert a newline at the end of a newline element
maybe_newline(&child, buffer);
}
Data::Comment(_) => (),
}
}
}

fn maybe_newline(node: &Node, buffer: &mut String) {
let buffer_ends_with_newline = buffer.chars().last().unwrap_or_default() == '\n';
let is_newline_element = matches!(node.name(), Some("p" | "div"));
let insert_newline = !buffer.is_empty() && !buffer_ends_with_newline && is_newline_element;
if insert_newline {
buffer.push('\n');
}
}

#[cfg(test)]
mod test {
use super::*;

use indoc::indoc;
use chrono::TimeZone;

use std::path::{Path, PathBuf};

@@ -339,8 +307,10 @@ mod test {
description: "a description".into(),
comments: None,
attendees: None,
timestamp: None,
};
let event = Event::try_from((event_item, page)).unwrap();
let timestamp = Utc.timestamp(0, 0);
let event = Event::try_from((event_item, page, timestamp)).unwrap();
insta::assert_yaml_snapshot!(event);
}

@@ -352,43 +322,4 @@ mod test {
let events = EventList::try_from((base_url, page)).unwrap();
insta::assert_yaml_snapshot!(events);
}

#[test]
fn parse_description_blank() {
let input = "";
let expected = "";
assert_eq!(parse_description(input), expected);
}

#[test]
fn parse_description_text() {
let input = "Trip Leader: Mike Sauter";
let expected = "Trip Leader: Mike Sauter";
assert_eq!(parse_description(input), expected);
}

#[test]
fn parse_description_basic_html() {
let input = "<p>Trip Leaders: Chao & C. Irving</p>\r\n<p>2 days of hard climbing in the Needles.  You should be a competent 5.9 climber to attend this outing as there are no easy routes here.  No kidding!</p>";
let expected = indoc! {"
Trip Leaders: Chao & C. Irving
2 days of hard climbing in the Needles.  You should be a competent 5.9 climber to attend this outing as there are no easy routes here.  No kidding!
"};
assert_eq!(parse_description(input), expected);
}

#[test]
fn parse_description_div() {
let input = "<font face=\"Arial, Verdana\"><span style=\"font-size: 13.3333px;\">Camping Fri and Sat nights at Joshua Tree, Ryan Campground.</span></font><div><font face=\"Arial, Verdana\"><div style=\"font-size: 13.3333px;\">Fri and Sat nights : Four campsites:</div><div style=\"font-size: 13.3333px;\"><span style=\"white-space:pre\">\t</span>#3 (2 parking spaces)</div><div style=\"font-size: 13.3333px;\"><span style=\"white-space:pre\">\t</span>#4 (2 parking spaces)</div><div style=\"font-size: 13.3333px;\"><span style=\"white-space:pre\">\t</span>#6 (2 parking spaces)</div><div style=\"font-size: 13.3333px;\"><span style=\"white-space: pre;\">\t</span>#7 (2 parking spaces)</div><div style=\"style\"><span style=\"font-size: 13.3333px;\">Trip Leader: Rob Donnelly</span></div></font></div>";
let expected = indoc! {"
Camping Fri and Sat nights at Joshua Tree, Ryan Campground.
Fri and Sat nights : Four campsites:
\t#3 (2 parking spaces)
\t#4 (2 parking spaces)
\t#6 (2 parking spaces)
\t#7 (2 parking spaces)
Trip Leader: Rob Donnelly
"};
assert_eq!(parse_description(input), expected);
}
}
5 changes: 4 additions & 1 deletion src/model.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::{DateTime, FixedOffset, Local, NaiveDate};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, Utc};
use serde::{Deserialize, Serialize, Serializer};

use std::fmt;
@@ -22,6 +22,9 @@ pub struct Event {
// Not present in SCMA JSON
#[serde(default)]
pub attendees: Option<Vec<Attendee>>,
/// The date and time the event page was downloaded.
#[serde(default)]
pub timestamp: Option<DateTime<Utc>>,
}

impl fmt::Display for Event {
56 changes: 37 additions & 19 deletions src/output/gcal.rs
Original file line number Diff line number Diff line change
@@ -169,31 +169,49 @@ fn event_end(event: &Event) -> EventDateTime {

fn event_description(event: &Event) -> Result<String, Box<dyn ::std::error::Error>> {
let mut buffer = String::with_capacity(DESCRIPTION_BUFFER_SIZE);
write!(buffer, "{}\n\n", event.url)?;
write!(buffer, "{}", event.url)?;
write!(buffer, "<h3>Description</h3>")?;
write!(buffer, "{}", event.description)?;

if let Some(attendees) = event.attendees.as_ref() {
write!(buffer, "<h3>Attendees</h3><ul>")?;
for attendee in attendees {
write!(
buffer,
"<li>{} ({}) {}</li>",
attendee.name, attendee.count, attendee.comment
)?;
write!(buffer, "<h3>Attendees</h3>")?;
match event.attendees.as_ref() {
Some(attendees) => {
write!(buffer, "<ol>")?;
for attendee in attendees {
write!(
buffer,
"<li>{} ({}) {}</li>",
attendee.name, attendee.count, attendee.comment
)?;
}
write!(buffer, "</ol>")?;
}
None => {
write!(buffer, "None")?;
}
write!(buffer, "</ul>")?;
}

if let Some(comments) = event.comments.as_ref() {
write!(buffer, "<h3>Comments</h3><ul>")?;
for comment in comments {
write!(
buffer,
"<li>{} ({}) {}</li>",
comment.author, comment.date, comment.text
)?;
write!(buffer, "<h3>Comments</h3>")?;
match event.comments.as_ref() {
Some(comments) => {
write!(buffer, "<ul>")?;
for comment in comments {
write!(
buffer,
"<li>{} ({}) {}</li>",
comment.author, comment.date, comment.text
)?;
}
write!(buffer, "</ul>")?;
}
write!(buffer, "</ul>")?;
None => {
write!(buffer, "None")?;
}
}

if let Some(timestamp) = event.timestamp {
let pacific = chrono::FixedOffset::west(8 * 60 * 60);
write!(buffer, "\n\nLast synced at {} by <a href='https://github.com/rfdonnelly/scma-gcal-sync'>scma-gcal-sync</a>.", timestamp.with_timezone(&pacific).to_rfc3339_opts(chrono::SecondsFormat::Secs, false))?;
}

Ok(buffer)