diff --git a/.github/workflows/generate-blog-drafts.yml b/.github/workflows/generate-blog-drafts.yml new file mode 100644 index 0000000000..b5777193c5 --- /dev/null +++ b/.github/workflows/generate-blog-drafts.yml @@ -0,0 +1,68 @@ +jobs: + generate-drafts: + name: Generate blog drafts + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.push-to-protected }} + + - name: Install cURL headers + run: | + sudo apt-get update + sudo apt-get install libcurl4-openssl-dev + + - name: Setup R + uses: r-lib/actions/setup-r@v2 + with: + r-version: 'release' + + - name: Install R packages + run: | + install.packages(c("airtabler", "tidyverse", "glue", "lubridate", "httr", "readr"), + repos = "https://cloud.r-project.org") + shell: Rscript {0} + + - name: Run proposal script + env: + AIRTABLE_API_KEY: ${{ secrets.AIRTABLE_API_KEY }} + AIRTABLE_BASE_ID: ${{ secrets.AIRTABLE_BASE_ID }} + run: Rscript scripts/blog_proposal.R + + - name: Run events script + env: + AIRTABLE_API_KEY: ${{ secrets.AIRTABLE_API_KEY }} + AIRTABLE_BASE_ID: ${{ secrets.AIRTABLE_BASE_ID }} + run: Rscript scripts/blog_events.R + + - name: Check for new files + id: check_files + run: | + if [ -n "$(git status --porcelain content/blog)" ]; then + echo "new_files=true" >> $GITHUB_OUTPUT + else + echo "new_files=false" >> $GITHUB_OUTPUT + fi + + - name: Configure Git + if: steps.check_files.outputs.new_files == 'true' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Open PR with new drafts + if: steps.check_files.outputs.new_files == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="blog-drafts-$(date +%Y-%m-%d-%H%M)" + git checkout -b $BRANCH + git add content/blog + git commit -m "chore: add blog drafts from Airtable $(date +%Y-%m-%d)" + git push origin $BRANCH + gh pr create \ + --title "Blog drafts $(date +%Y-%m-%d)" \ + --body "Auto-generated blog post drafts from Airtable. Please review before merging." \ + --base main \ No newline at end of file diff --git a/scripts/blog_events.R b/scripts/blog_events.R new file mode 100644 index 0000000000..9d53e3de61 --- /dev/null +++ b/scripts/blog_events.R @@ -0,0 +1,107 @@ +library(airtabler) +library(tidyverse) +library(glue) +library(readr) + +source("scripts/blog_functions.R") + +# Get data and draft blogposts---- +process_events <- function(base_id) { + + # Connect and get data + base <- connect_to_airtable(base_id, tables = "Event Reports") + events_data <- base$`Event Reports`$select() + + # Filter ready-to-draft events + events_to_draft <- filter_ready_to_draft(events_data, title_col = "Title") + + if (nrow(events_to_draft) == 0) { + message("No new event reports to draft.") + return(0) + } + + message("Found ", nrow(events_to_draft), " new event report(s) to draft.") + + # Process each event + for (i in 1:nrow(events_to_draft)) { + report <- events_to_draft[i, ] + + tryCatch({ + # Validate required fields + if (!validate_required_fields(report, c("title", "createdTime"))) { + next + } + + # Create slug and paths + slug <- create_slug(report$title) + post_date <- ymd_hms(report$createdTime) + year <- year(post_date) + month_day_slug <- paste0(format(post_date, "%m-%d"), "-", slug) + + # Create folder + folder_path <- create_post_folder(year, month_day_slug) + + # Download images if they exist + image_markdown <- "" + if (!is.na(report$media) && report$media != "") { + message("Downloading images for event: ", report$title) + downloaded_images <- download_images(report$media, folder_path) + + # Create markdown for images + if (!is.null(downloaded_images) && length(downloaded_images) > 0) { + image_markdown <- create_image_markdown(downloaded_images) + } + } + # Markdown front-matter ---- + + front_matter <- glue( + '--- + title: "Event Report: {report$Title}" + author: "{report$Author}" + date: "{format(post_date, "%Y-%m-%d")}" + tags: [{report$Keywords}] + --- + ' + ) + + post_body <- glue( + 'On {report$Date}, {report$Chapter} hosted the event "{report$Title}". + The session was led by {report$Speakers} and was attended by {report$Participants} participants. + + ## Event Summary + + {report$Summary} + + ## Attendee Feedback + + > {report$`Quotes or Reactions (optional)`} + + ## Resources + + {report$Resources} + + A big thank you to the speakers and everyone who attended! + + {image_markdown}' + ) + + # Write file + content <- paste0(front_matter, post_body) + filepath <- write_markdown_file(content, folder_path) + message("✓ Successfully created: ", filepath) + + }, error = function(e) { + warning("Error processing event ", i, ": ", e$message) + }) + } + + return(nrow(events_to_draft)) +} + +base_id <- Sys.getenv("AIRTABLE_BASE_ID") +process_events(base_id) + +# After successfully creating the markdown file: +# Update the Airtable record to: +# - Status = "Post drafted" +# - Add PR URL \ No newline at end of file diff --git a/scripts/blog_functions.R b/scripts/blog_functions.R new file mode 100644 index 0000000000..547e8d86d3 --- /dev/null +++ b/scripts/blog_functions.R @@ -0,0 +1,85 @@ +library(tidyverse) +library(glue) +library(httr) +library(airtabler) + +# Connect to Airtable base +connect_to_airtable <- function(base_id, tables) { + message("Connecting to Airtable...") + base <- airtable(base = base_id, tables = tables) + return(base) +} + +# Filter data to get records ready for drafting +# Removes the dont-delete row and filters for In progress + Accept status +filter_ready_to_draft <- function(data, title_col = "Title") { + data %>% + filter( + .data[[title_col]] != "dont-delete", + Status == "In progress", + Decision == "Accept" + ) +} + +# Create a URL-friendly slug from text +create_slug <- function(text) { + text %>% + str_to_lower() %>% + str_replace_all("[^a-z0-9\\s-]", "") %>% + str_replace_all("\\s+", "-") +} + +# Create blog post folder structure (content/blog/YYYY/mm-dd-slug/) +create_post_folder <- function(year, month_day_slug) { + folder_path <- file.path("content", "blog", as.character(year), month_day_slug) + dir.create(folder_path, recursive = TRUE, showWarnings = FALSE) + return(folder_path) +} + +# Escape special characters for YAML frontmatter +escape_yaml <- function(text) { + if (is.null(text) || is.na(text) || text == "") return('""') + # Escape quotes and wrap in quotes if text contains special YAML characters + if (str_detect(text, '[":\\n]')) { + text <- str_replace_all(text, '"', '\\\\"') + text <- paste0('"', text, '"') + } + return(text) +} + +# Download images from Airtable attachment field and save to folder +# Returns vector of local filenames (or empty vector if none) +download_images <- function(image_urls, folder_path) { + + # Handle NULL, NA, empty, or non-list input + if (is.null(image_urls) || length(image_urls) == 0) return(character(0)) + + # Airtable returns attachments as a list of lists, each with $url and $filename + # Coerce to list if it somehow comes in as a single item + if (!is.list(image_urls)) return(character(0)) + + downloaded <- c() + + for (attachment in image_urls) { + tryCatch({ + url <- attachment$url + filename <- attachment$filename + + if (is.null(url) || is.null(filename)) next + + local_path <- file.path(folder_path, filename) + response <- httr::GET(url, httr::write_disk(local_path, overwrite = TRUE)) + + if (httr::status_code(response) == 200) { + downloaded <- c(downloaded, filename) + message(" ↓ Downloaded: ", filename) + } else { + warning(" ✗ Failed to download: ", filename, " (HTTP ", httr::status_code(response), ")") + } + }, error = function(e) { + warning(" ✗ Error downloading image: ", e$message) + }) + } + + return(downloaded) +} \ No newline at end of file diff --git a/scripts/blog_proposal.R b/scripts/blog_proposal.R new file mode 100644 index 0000000000..ee9b102d0a --- /dev/null +++ b/scripts/blog_proposal.R @@ -0,0 +1,76 @@ +library(airtabler) +library(tidyverse) +library(glue) +library(lubridate) + +source("scripts/blog_functions.R") + +# Get data and create draft blogpost ---- +process_proposals <- function(base_id) { + + # Connect and get data + base <- connect_to_airtable(base_id, tables = "Proposals") + proposals_data <- base$Proposals$select() + + # Filter ready-to-draft proposals + proposals_to_draft <- filter_ready_to_draft(proposals_data, title_col = "Title") + + if (nrow(proposals_to_draft) == 0) { + message("No new proposals to draft.") + return(0) + } + + message("Found ", nrow(proposals_to_draft), " new proposal(s) to draft.") + + # Process each proposal + for (i in 1:nrow(proposals_to_draft)) { + proposal <- proposals_to_draft[i, ] + + tryCatch({ + # Validate required fields + if (!validate_required_fields(proposal, c("Title", "Post date"))) { + next + } + + # Create slug and paths + slug <- create_slug(proposal$Title) + post_date_obj <- ymd(proposal$`Post date`) + year <- year(post_date_obj) + month_day_slug <- paste0(format(post_date_obj, "%m-%d"), "-", slug) + + # Create folder + folder_path <- create_post_folder(year, month_day_slug) + + # Build content + front_matter <- glue( + '--- +title: {escape_yaml(proposal$Title)} +author: {escape_yaml(proposal$Author)} +date: "{proposal$`Post date`}" +slug: {slug} +--- + +' + ) + + content <- paste0(front_matter, proposal$Description) + + # Write file + filepath <- write_markdown_file(content, folder_path) + message("✓ Successfully created: ", filepath) + + }, error = function(e) { + warning("Error processing proposal ", i, ": ", e$message) + }) + } + + return(nrow(proposals_to_draft)) +} + +base_id <- Sys.getenv("AIRTABLE_BASE_ID") +process_proposals(base_id) + +# After successfully creating the markdown file: +# Update the Airtable record to: +# - Status = "Post drafted" +# - Add PR URL (once you know it) \ No newline at end of file