Add PO Box support and centralize config via API

- Add PO Box field to bulletin contact section from API config
- Replace local config files with API-based config fetching
- Add cover image upload support to bulletin-input tool
- Remove dead config.rs and config.toml files
- Unify contact info source to prevent config segregation
This commit is contained in:
Benjamin Slingo 2025-08-26 18:09:31 -04:00
parent 76366aaf96
commit 4cfd1bad84
9 changed files with 134 additions and 50 deletions

View file

@ -4,7 +4,7 @@ mod template_renderer;
use anyhow::{anyhow, Result};
use base64::prelude::*;
use bulletin_shared::{Config, NewApiClient};
use bulletin_shared::NewApiClient;
use chrono::{Datelike, Local, NaiveDate, Weekday};
use clap::Parser;
use std::path::PathBuf;
@ -12,9 +12,6 @@ use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long, default_value = "shared/config.toml")]
config: PathBuf,
#[arg(short, long)]
date: Option<NaiveDate>,
@ -56,8 +53,6 @@ async fn main() -> Result<()> {
let args = Args::parse();
let config = Config::from_file(&args.config)?;
let target_date = args.date.unwrap_or_else(|| get_upcoming_saturday(None));
println!("--- Starting bulletin generation process for date: {} ---", target_date);
@ -123,6 +118,18 @@ async fn main() -> Result<()> {
eprintln!("Failed to fetch events: {}", e);
Vec::new()
});
println!("Fetching config from API...");
let api_config = client.get_config().await.unwrap_or_else(|e| {
eprintln!("Failed to fetch config from API, using defaults: {}", e);
bulletin_shared::models::ApiConfig {
church_name: "Rockville-Tolland Seventh-Day Adventist Church".to_string(),
church_address: "9 Hartford Tpke Tolland CT 06084".to_string(),
po_box: "".to_string(),
contact_phone: "860-875-0450".to_string(),
contact_email: "admin@rockvilletollandsda.church".to_string(),
}
});
println!("Parsing Sabbath School text...");
let sabbath_school_items = html_parser::parse_sabbath_school(&bulletin.sabbath_school_section);
@ -138,16 +145,17 @@ async fn main() -> Result<()> {
let context = template_renderer::TemplateContext {
bulletin_date: target_date.format("%B %d, %Y").to_string(),
bulletin_theme_title: html_parser::strip_html_tags(&bulletin.sermon_title),
church_name: config.church_name.clone(),
church_name: api_config.church_name.clone(),
cover_image_path: cover_image_path.as_ref().map(|p| p.to_string_lossy().to_string()),
sabbath_school_items,
divine_worship_items,
announcements,
sunset_times: new_bulletin.sunset.unwrap_or_else(|| "TBA".to_string()),
contact_info: template_renderer::ContactInfo {
phone: config.contact_phone.clone().unwrap_or_else(|| "860-875-0450".to_string()),
website: config.contact_website.clone().unwrap_or_else(|| "rockvilletollandsda.church".to_string()),
address: config.contact_address.clone().unwrap_or_else(|| "9 Hartford Tpke Tolland CT 06084".to_string()),
phone: api_config.contact_phone.clone(),
website: "rockvilletollandsda.church".to_string(), // Could be added to API config if needed
address: api_config.church_address.clone(),
po_box: if api_config.po_box.is_empty() { None } else { Some(api_config.po_box.clone()) },
},
};

View file

@ -25,6 +25,7 @@ pub struct ContactInfo {
pub phone: String,
pub website: String,
pub address: String,
pub po_box: Option<String>,
}
#[derive(Debug, Serialize)]

View file

@ -102,6 +102,7 @@
<p>Phone: {{ contact_info.phone | default(value='860-875-0450') }}</p>
<p>Website: <a href="https://{{ contact_info.website | default(value='rockvilletollandsda.church') }}">{{ contact_info.website | default(value='rockvilletollandsda.church') }}</a></p>
<p>Address: {{ contact_info.address | default(value='9 Hartford Tpke Tolland CT 06084') }}</p>
{% if contact_info.po_box %}<p>{{ contact_info.po_box }}</p>{% endif %}
</div>
<h4>Sunset Times</h4>
<p>{{ sunset_times | default(value='Not available') }}</p>

View file

@ -1,18 +1,14 @@
mod sermon_parser;
use anyhow::{anyhow, Result};
use bulletin_shared::{Config, Bulletin, NewApiClient};
use bulletin_shared::{Bulletin, NewApiClient};
use chrono::{Datelike, Local, NaiveDate, Weekday};
use clap::Parser;
use dialoguer::{Input, theme::ColorfulTheme};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long, default_value = "shared/config.toml")]
config: PathBuf,
#[arg(short, long)]
date: Option<NaiveDate>,
@ -160,8 +156,6 @@ async fn main() -> Result<()> {
let args = Args::parse();
let _config = Config::from_file(&args.config)?;
let target_saturday = get_upcoming_saturday(args.date);
println!("--- Creating bulletin for Saturday: {} ---", target_saturday);
@ -341,11 +335,39 @@ async fn main() -> Result<()> {
updated: None,
};
println!("\n--- Optional Cover Image ---");
println!("Enter cover image file path (press Enter to skip):");
let mut image_path = String::new();
std::io::stdin().read_line(&mut image_path)?;
let image_path = image_path.trim();
let image_file_path = if !image_path.is_empty() && std::path::Path::new(image_path).exists() {
Some(std::path::PathBuf::from(image_path))
} else if !image_path.is_empty() {
println!("Warning: Image file '{}' not found, continuing without cover image", image_path);
None
} else {
None
};
println!("\n--- Creating bulletin in new API ---");
let created_bulletin = client.create_bulletin_from_bulletin(&bulletin).await?;
println!("\nSUCCESS! Created bulletin ID: {}", created_bulletin.id);
// Upload cover image if provided
if let Some(image_path) = image_file_path {
println!("Uploading cover image...");
match client.upload_cover_image(&created_bulletin.id, &image_path).await {
Ok(_) => println!("Cover image uploaded successfully!"),
Err(e) => {
eprintln!("Failed to upload cover image: {}", e);
println!("Bulletin created successfully, but cover image upload failed.");
}
}
}
println!("Done! Check your API - bulletin created successfully!");
Ok(())

View file

@ -1,24 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use anyhow::Result;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub church_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_youtube: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_address: Option<String>,
}
impl Config {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
}

View file

@ -1,7 +1,5 @@
pub mod config;
pub mod models;
pub mod new_api;
pub use config::Config;
pub use models::{Bulletin, Event, Personnel, NewApiBulletin, NewApiResponse, NewApiEvent, NewApiEventsResponse, ConferenceDataResponse, ConferenceData, ScheduleResponse, ScheduleData, PersonnelData, LoginRequest, LoginResponse, LoginData};
pub use new_api::NewApiClient;

View file

@ -208,4 +208,20 @@ pub struct LoginResponse {
#[derive(Debug, Deserialize)]
pub struct LoginData {
pub token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiConfig {
pub church_name: String,
pub church_address: String,
pub po_box: String,
pub contact_phone: String,
pub contact_email: String,
}
#[derive(Debug, Deserialize)]
pub struct ApiConfigResponse {
pub success: bool,
pub data: Option<ApiConfig>,
pub message: Option<String>,
}

View file

@ -1,4 +1,4 @@
use crate::models::{NewApiResponse, NewApiBulletin, Bulletin, NewApiEventsResponse, NewApiEvent, Event, EventType, ConferenceDataResponse, ConferenceData, ScheduleResponse, PersonnelData, LoginRequest, LoginResponse};
use crate::models::{NewApiResponse, NewApiBulletin, Bulletin, NewApiEventsResponse, NewApiEvent, Event, EventType, ConferenceDataResponse, ConferenceData, ScheduleResponse, PersonnelData, LoginRequest, LoginResponse, ApiConfig, ApiConfigResponse};
use anyhow::{anyhow, Result};
use reqwest::Client;
use chrono::{NaiveDate, DateTime, Utc};
@ -179,6 +179,52 @@ impl NewApiClient {
Ok(response_text)
}
pub async fn upload_cover_image(&self, bulletin_id: &str, image_path: &std::path::Path) -> Result<String> {
let url = format!("{}/api/upload/bulletins/{}/cover", self.base_url, bulletin_id);
let file = tokio::fs::read(image_path).await?;
let file_name = image_path.file_name()
.ok_or_else(|| anyhow!("Invalid file path"))?
.to_string_lossy()
.to_string();
// Determine MIME type based on file extension
let mime_type = match image_path.extension().and_then(|ext| ext.to_str()) {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
_ => "image/jpeg", // default
};
let part = reqwest::multipart::Part::bytes(file)
.file_name(file_name)
.mime_str(mime_type)?;
println!("Debug: Uploading cover image to bulletin_id: '{}'", bulletin_id);
println!("Debug: Upload URL: {}", url);
let form = reqwest::multipart::Form::new()
.part("file", part);
let mut request = self.client.post(&url).multipart(form);
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request.send().await?;
let status = response.status();
let response_text = response.text().await?;
if !status.is_success() {
return Err(anyhow!("Failed to upload cover image (HTTP {}): {}", status, response_text));
}
Ok(response_text)
}
pub async fn get_conference_data(&self, date: NaiveDate) -> Result<ConferenceData> {
let date_str = date.format("%Y-%m-%d").to_string();
let url = format!("{}/api/conference-data?date={}", self.base_url, date_str);
@ -224,6 +270,28 @@ impl NewApiClient {
Ok(api_response.data.personnel)
}
pub async fn get_config(&self) -> Result<ApiConfig> {
let url = format!("{}/api/config", self.base_url);
let response = self.client
.get(&url)
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
return Err(anyhow!("Failed to get config: {}", error_text));
}
let api_response: ApiConfigResponse = response.json().await?;
if !api_response.success {
return Err(anyhow!("API returned error: {:?}", api_response.message));
}
api_response.data.ok_or_else(|| anyhow!("No config data received"))
}
}
pub fn convert_to_bulletin(new_bulletin: NewApiBulletin) -> Result<Bulletin> {

View file

@ -1,6 +0,0 @@
# Church configuration for bulletin generation
church_name = "Rockville-Tolland Seventh-Day Adventist Church"
# contact_phone = "555-123-4567"
# contact_website = "yourchurchwebsite.org"
# contact_youtube = "youtube.com/yourchurchchannel"
# contact_address = "123 Church St, Your City, ST 12345"