Initial commit with major improvements

- Fixed Sabbath School parser to handle API data with extra line breaks
- Cleaned up project structure and removed nested directories
- Organized output to single directory structure
- Removed YouTube link from contact section for cleaner layout
- Improved parser robustness for multi-line content between labels
- Added proper .gitignore for Rust project
This commit is contained in:
Benjamin Slingo 2025-08-21 20:17:54 -04:00
commit 1eb1fc9909
30 changed files with 38370 additions and 0 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
# Bulletin API Authentication Token
# Get this from your admin panel or API provider
BULLETIN_API_TOKEN=Alright8-Reapply-Shrewdly-Platter-Important-Keenness-Banking-Streak-Tactile

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Rust
target/
Cargo.lock
# IDE
.vscode/
.idea/
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Environment
.env
.env.local
.env.production
# Output files (optional - you might want to keep some)
output/*.pdf
# Logs
*.log

28
Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[workspace]
members = [
"bulletin-generator",
"bulletin-input",
"bulletin-shared",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
[workspace.dependencies]
tokio = { version = "1.42", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2.0"
anyhow = "1.0"
csv = "1.3"
clap = { version = "4.5", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.0", features = ["v4"] }
dotenvy = "0.15"

109
README.md Normal file
View file

@ -0,0 +1,109 @@
# Bulletin Tools - Rust Implementation
This is a Rust rewrite of the Python church bulletin generation system. The system consists of two main binaries that work with a REST API to create and generate church bulletins.
## Project Structure
- **`bulletin-shared/`** - Shared library containing common functionality
- Configuration management
- REST API client implementation
- Data models (Bulletin, Event, Personnel)
- **`bulletin-input/`** - Binary for creating bulletin data entries
- Reads CSV schedules and conference charts
- Prompts for sermon details
- Creates bulletin records via REST API
- **`bulletin-generator/`** - Binary for generating PDF bulletins
- Fetches bulletin data from REST API
- Renders HTML templates
- Generates PDF using headless Chrome
- Uploads PDF back via REST API
## Data Files
The `data/` directory contains:
- `Quarterly schedule2021 - 2025.csv` - Personnel assignments
- `2025 Offering and Sunset Times Chart.txt` - Conference offering schedule and sunset times
- `KJV.json` - King James Version Bible text for scripture lookups
## Configuration
The shared configuration is in `shared/config.toml`:
```toml
church_name = "Your Church Name"
# Optional contact information for templates
# contact_phone = "555-123-4567"
# contact_website = "yourchurchwebsite.org"
# contact_youtube = "youtube.com/yourchurchchannel"
# contact_address = "123 Church St, Your City, ST 12345"
```
The system uses the REST API at `https://api.rockvilletollandsda.church` and requires an auth token via the `BULLETIN_API_TOKEN` environment variable or `--auth-token` parameter.
## Usage
### Building
```bash
cargo build --release
```
### Bulletin Input
```bash
# Use upcoming Saturday (default)
cargo run --bin bulletin-input
# Specify a date
cargo run --bin bulletin-input -- --date 2025-06-21
# Use different location for sunset times
cargo run --bin bulletin-input -- --location "springfield"
```
### Bulletin Generation
```bash
# Generate for upcoming Saturday
cargo run --bin bulletin-generator
# Specify a date
cargo run --bin bulletin-generator -- --date 2025-06-21
# Don't upload to PocketBase (local only)
cargo run --bin bulletin-generator -- --no-upload
# Custom output directory
cargo run --bin bulletin-generator -- --output-dir custom_output
```
## Templates
The bulletin templates are located in `bulletin-generator/templates/`:
- `bulletin_template.html` - Jinja2-style template for the bulletin layout
- `style.css` - CSS styling for the PDF output
## Dependencies
Key Rust dependencies:
- **tokio** - Async runtime
- **reqwest** - HTTP client for PocketBase API
- **serde** - Serialization/deserialization
- **chrono** - Date/time handling
- **tera** - Template engine
- **headless_chrome** - PDF generation
- **csv** - CSV parsing
- **regex** - Text parsing
## Migration from Python
This Rust implementation maintains full compatibility with the original Python system:
- Uses the new REST API backend
- Processes the same data files
- Generates identical bulletin layouts
- Follows the same workflow
The main benefits of the Rust version:
- Faster execution
- Single binary deployment
- Better error handling
- Type safety
- Reduced runtime dependencies

31
SERMON_FORMAT.md Normal file
View file

@ -0,0 +1,31 @@
# Bulletin Input Format
The bulletin-input program expects sermon details in the following format:
```
Sermon: [Title]
Scripture: [Reference]
Opening Song: [Number and Title]
Closing Song: [Number and Title]
```
## Example:
```
Sermon: Don't Hit Send
Scripture: Proverbs 17: 9-10
Opening Song: 311 I would be like Jesus
Closing Song: 305 Give me Jesus
```
## Important Notes:
- Use "Sermon:" NOT "Sermon Title:"
- Use "Scripture:" NOT "Scripture Reading:"
- The parser is sensitive to these exact labels
- Each field should be on its own line
- End input with two empty lines when running interactively
## Usage:
```bash
cd bulletin-input
echo -e "Sermon: Don't Hit Send\nScripture: Proverbs 17: 9-10\nOpening Song: 311 I would be like Jesus\nClosing Song: 305 Give me Jesus\n\n" | ./target/debug/bulletin-input --config shared/config.toml
```

20
autoprintbulletin.sh Normal file
View file

@ -0,0 +1,20 @@
#!/bin/bash
BULLETIN_DIR="/opt/rtsda/church-api/uploads/bulletins"
PRINTER="Brother_MFC_L8900CDW"
# Find the most recent PDF file by modification time
LATEST_BULLETIN=$(ls -t "$BULLETIN_DIR"/*.pdf 2>/dev/null | head -n1)
if [ -n "$LATEST_BULLETIN" ]; then
echo "Printing: $LATEST_BULLETIN"
lp -d "$PRINTER" \
-o 20 \
-o PageSize=Letter \
-o Duplex=DuplexTumble \
-o ColorModel=RGB \
"$LATEST_BULLETIN"
echo "Print job sent successfully"
else
echo "No bulletin found in $BULLETIN_DIR"
fi

View file

@ -0,0 +1,28 @@
[package]
name = "bulletin-generator"
version.workspace = true
edition.workspace = true
[[bin]]
name = "bulletin-generator"
path = "src/main.rs"
[dependencies]
bulletin-shared = { path = "../bulletin-shared" }
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
chrono.workspace = true
anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tera = "1.20"
printpdf = "0.7"
headless_chrome = "1.0"
html-escape = "0.2"
regex = "1.11"
urlencoding = "2.1"
dotenvy.workspace = true
base64 = "0.22"

755
bulletin-generator/main.py Normal file
View file

@ -0,0 +1,755 @@
# Main script for bulletin generation
import requests # For HTTP requests to PocketBase
import toml # For reading config.toml
import jinja2 # For HTML templating
from weasyprint import HTML, CSS # For PDF generation
import os
import datetime # For handling dates
import argparse
import re # Added for HTML stripping
import html # For unescaping HTML entities like &nbsp;
# --- Configuration ---
CONFIG_PATH = "config.toml" # NOW LOCAL TO SCRIPT DIRECTORY
TEMP_IMAGE_DIR = "temp_images"
OUTPUT_DIR = "output" # For local PDF saving
TEMPLATES_DIR = "templates" # Directory for Jinja2 templates, relative to main.py
def strip_html_tags(text):
"""Removes HTML tags from a string and handles common entities like &nbsp;."""
if not text:
return ""
# 1. Remove HTML tags
text_no_tags = re.sub(r'<[^>]+>', '', text)
# 2. Unescape HTML entities (e.g., &nbsp; -> \xa0, &amp; -> &)
text_unescaped = html.unescape(text_no_tags)
# 3. Replace non-breaking space character (\xa0) with a regular space
text_final = text_unescaped.replace('\xa0', ' ')
return text_final
def load_config():
"""Loads configuration from config.toml."""
try:
# Construct the absolute path to config.toml relative to main.py
# os.path.abspath ensures the path is correct regardless of where the script is run from,
# as long as config.toml maintains its relative position to the script.
script_dir = os.path.dirname(os.path.abspath(__file__))
config_file_path = os.path.join(script_dir, CONFIG_PATH)
with open(config_file_path, 'r') as f:
config_data = toml.load(f)
print(f"Successfully loaded configuration from {config_file_path}")
return config_data
except FileNotFoundError:
print(f"ERROR: Configuration file not found at {config_file_path}")
return None
except toml.TomlDecodeError as e:
print(f"ERROR: Could not decode TOML from {config_file_path}: {e}")
return None
except Exception as e:
print(f"ERROR: An unexpected error occurred while loading config: {e}")
return None
def get_pocketbase_client(config):
"""
Extracts PocketBase connection details from the loaded configuration.
Returns a dictionary with 'base_url', 'admin_email', 'admin_password',
or None if essential keys are missing.
"""
if not config:
print("ERROR: Configuration data is not available for PocketBase client setup.")
return None
required_keys = [
"pocketbase_url",
"pocketbase_admin_email",
"pocketbase_admin_password",
"bulletin_collection_name",
"events_collection_name"
]
pb_details = {}
for key in required_keys:
if key not in config:
print(f"ERROR: Missing '{key}' in configuration for PocketBase client.")
return None
pb_details[key] = config[key]
# Ensure the URL does not end with a slash to simplify joining later
if pb_details["pocketbase_url"].endswith('/'):
pb_details["pocketbase_url"] = pb_details["pocketbase_url"].rstrip('/')
print(f"PocketBase client configured for URL: {pb_details['pocketbase_url']}")
return pb_details
def fetch_bulletin_data(pb_config, bulletin_date_str):
"""
Fetches the bulletin record for the given date string (e.g., "YYYY-MM-DD").
Assumes public read access for the bulletin collection.
Returns the bulletin record item if found, otherwise None.
"""
if not pb_config:
print("ERROR: PocketBase configuration is not available for fetching bulletin data.")
return None
base_url = pb_config['pocketbase_url']
collection_name = pb_config['bulletin_collection_name']
api_url = f"{base_url}/api/collections/{collection_name}/records"
# Parse the input date string to create a date range for the filter
try:
target_date = datetime.datetime.strptime(bulletin_date_str, "%Y-%m-%d").date()
except ValueError:
print(f"ERROR: Invalid bulletin_date_str format: '{bulletin_date_str}'. Please use YYYY-MM-DD.")
return None
start_datetime_str = target_date.strftime("%Y-%m-%d 00:00:00")
# Next day for the upper bound (exclusive)
next_day_date = target_date + datetime.timedelta(days=1)
end_datetime_str = next_day_date.strftime("%Y-%m-%d 00:00:00") # Exclusive end
params = {
'filter': f"(date >= '{start_datetime_str}' && date < '{end_datetime_str}')"
# Add 'is_active=true' if you have such a field: f"(date>='{start_datetime_str}' && date<'{end_datetime_str}' && is_active=true)"
}
try:
print(f"Fetching bulletin data from: {api_url} with params: {params}")
response = requests.get(api_url, params=params)
response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
data = response.json()
records = data.get('items', [])
if len(records) == 1:
print(f"Successfully fetched bulletin record for date: {bulletin_date_str}")
return records[0] # Return the single record found
elif len(records) == 0:
print(f"No bulletin record found for date: {bulletin_date_str}")
return None
else:
print(f"WARNING: Multiple bulletin records ({len(records)}) found for date: {bulletin_date_str}. Returning the first one.")
return records[0] # Or handle as an error, depending on expected data integrity
except requests.exceptions.RequestException as e:
print(f"ERROR: Request failed while fetching bulletin data: {e}")
return None
except Exception as e:
print(f"ERROR: An unexpected error occurred while fetching bulletin data: {e}")
return None
def download_cover_image(pb_config, collection_id, record_id, image_field_name, bulletin_record, save_dir):
"""
Downloads a file (e.g., cover image) from a PocketBase record.
Assumes the field 'image_field_name' in 'bulletin_record' contains the filename.
Returns the full path to the downloaded image, or None on error.
"""
if not pb_config:
print("ERROR: PocketBase configuration is not available for downloading image.")
return None
if not bulletin_record:
print("ERROR: Bulletin record data is not available for downloading image.")
return None
base_url = pb_config['pocketbase_url']
image_filename = bulletin_record.get(image_field_name)
if not image_filename:
print(f"ERROR: Image filename not found in bulletin record under field '{image_field_name}'.")
return None
# Ensure the save directory exists
os.makedirs(save_dir, exist_ok=True)
# Construct the file download URL
# Format: /api/files/COLLECTION_ID_OR_NAME/RECORD_ID/FILENAME
file_url = f"{base_url}/api/files/{collection_id}/{record_id}/{image_filename}"
local_image_path = os.path.join(save_dir, image_filename)
try:
print(f"Downloading cover image from: {file_url}")
response = requests.get(file_url, stream=True) # stream=True for potentially larger files
response.raise_for_status()
with open(local_image_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Successfully downloaded cover image to: {local_image_path}")
return local_image_path
except requests.exceptions.RequestException as e:
print(f"ERROR: Request failed while downloading image '{image_filename}': {e}")
if os.path.exists(local_image_path):
os.remove(local_image_path) # Clean up partial download
return None
except Exception as e:
print(f"ERROR: An unexpected error occurred while downloading image '{image_filename}': {e}")
if os.path.exists(local_image_path):
os.remove(local_image_path) # Clean up partial download
return None
def fetch_events_data(pb_config, bulletin_date_obj):
"""
Fetches all events and filters them based on end_time >= bulletin_date_obj.
Assumes public read access for the events collection.
'bulletin_date_obj' is a datetime.date object.
Returns a list of event items, or an empty list on error/no events.
"""
if not pb_config:
print("ERROR: PocketBase configuration is not available for fetching events.")
return []
base_url = pb_config['pocketbase_url']
collection_name = pb_config.get('events_collection_name') # Get from config
if not collection_name:
print("ERROR: 'events_collection_name' not found in PocketBase configuration.")
return []
api_url = f"{base_url}/api/collections/{collection_name}/records"
# Format the bulletin_date_obj to "YYYY-MM-DD 00:00:00" for the filter
filter_start_date_str = bulletin_date_obj.strftime("%Y-%m-%d 00:00:00")
params = {
# Filter for events where end_time is greater than or equal to the start of the bulletin day.
# Assumes 'end_time' is a datetime field in PocketBase.
'filter': f"(end_time >= '{filter_start_date_str}')",
'sort': '+start_time' # Optional: sort events by their start time
}
try:
print(f"Fetching events data from: {api_url} with params: {params}")
response = requests.get(api_url, params=params)
response.raise_for_status()
data = response.json()
events = data.get('items', [])
print(f"Successfully fetched {len(events)} events.")
# Format start_time and strip HTML from relevant fields
for event in events:
if 'title' in event and event['title']:
event['title'] = strip_html_tags(event['title'])
if 'description' in event and event['description']:
event['description'] = strip_html_tags(event['description'])
if 'start_time' in event and event['start_time']:
try:
# Assuming start_time is like "2024-03-15 10:00:00.000Z"
dt_obj = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00'))
event['start_time_formatted'] = dt_obj.strftime("%A, %B %d, %Y at %I:%M %p") # Readable format
except ValueError:
event['start_time_formatted'] = event['start_time'] # Fallback to raw string
else:
event['start_time_formatted'] = "Date/Time TBD"
return events
except requests.exceptions.RequestException as e:
print(f"ERROR: Request failed while fetching events data: {e}")
return []
except Exception as e:
print(f"ERROR: An unexpected error occurred while fetching events data: {e}")
return []
def parse_sabbath_school(ss_text):
"""
Parses the plain text (after stripping HTML) from the sabbath_school field.
Expected format is "Label:" on one line, value on the next.
Returns a list of dictionaries (e.g., [{'label': 'Song Service', 'details': 'Lisa Wroniak', 'time': '9:30 AM'}, ...])
"""
sabbath_school_event_times = ["9:15 AM", "9:30 AM", "10:30 AM", "10:35 AM", "10:45 AM"]
clean_ss_text = strip_html_tags(ss_text) # Strip HTML first
if not clean_ss_text or not clean_ss_text.strip():
return []
parsed_items = []
lines = clean_ss_text.splitlines()
i = 0
while i < len(lines):
current_line = lines[i].strip() # Ensure line is stripped for checks
if not current_line: # Skip empty lines that might result from splitlines or data
i += 1
continue
if current_line.endswith(':'):
label = current_line[:-1].strip() # Remove colon and strip
details = ""
# Check if there is a next line, it's not empty, and it's not another label
if i + 1 < len(lines) and lines[i+1].strip() and not lines[i+1].strip().endswith(':'):
details = lines[i+1].strip() # Assign stripped details line
i += 1 # Crucial: Move past the details line as it has been consumed
item_time = ""
if len(parsed_items) < len(sabbath_school_event_times):
item_time = sabbath_school_event_times[len(parsed_items)]
else:
item_time = "Time TBD" # Fallback if more items than times
parsed_items.append({'label': label, 'details': details, 'time': item_time})
# If the line doesn't end with ':', it might be a details line already consumed by a previous label,
# or it's a line that doesn't fit the "Label:" pattern (e.g., an orphaned detail or remark).
# In such cases, we just move to the next line.
i += 1
return parsed_items
def _process_dw_item(label_str, detail_lines):
"""Helper to process a single divine worship item."""
item = {
'label': label_str,
'type': 'Default', # Will be overridden
'title': None,
'speaker': None,
'details': "\n".join(detail_lines).strip() # Default behavior for details
}
normalized_label = label_str.lower()
print(f"DEBUG DW LABEL: '{label_str}', NORMALIZED: '{normalized_label}'")
if "sermon" in normalized_label:
item['type'] = "Sermon"
if len(detail_lines) >= 1:
item['title'] = detail_lines[0].strip()
if len(detail_lines) >= 2:
item['speaker'] = detail_lines[1].strip()
# Consolidate remaining details or clear if captured by title/speaker
item['details'] = "\n".join(detail_lines[2:]).strip() if len(detail_lines) > 2 else None
elif "scripture reading" in normalized_label:
item['type'] = "Scripture Reading"
if len(detail_lines) >= 1:
item['title'] = detail_lines[0].strip() # Scripture reference
if len(detail_lines) >= 2:
item['speaker'] = detail_lines[1].strip() # Reader
item['details'] = "\n".join(detail_lines[2:]).strip() if len(detail_lines) > 2 else None
elif "hymn" in normalized_label or "song" in normalized_label:
item['type'] = "Hymn"
item['title'] = " ".join(line.strip() for line in detail_lines)
item['details'] = None
elif "call to worship" in normalized_label:
item['type'] = "Call to Worship"
item['title'] = " ".join(line.strip() for line in detail_lines)
item['details'] = None
elif "prayer" in normalized_label: # Catches "Prayer & Praises"
item['type'] = "Prayer"
if len(detail_lines) == 1:
item['speaker'] = detail_lines[0].strip()
item['details'] = None
# else, default details behavior if more lines
elif "offering" in normalized_label:
item['type'] = "Offering"
if len(detail_lines) >= 1:
item['title'] = detail_lines[0].strip() # What the offering is for
if len(detail_lines) >= 2:
item['speaker'] = detail_lines[1].strip() # Person involved
item['details'] = "\n".join(detail_lines[2:]).strip() if len(detail_lines) > 2 else None
elif "children's story" in normalized_label or "childrens story" in normalized_label:
item['type'] = "Childrens Story"
if len(detail_lines) == 1:
item['speaker'] = detail_lines[0].strip()
item['details'] = None
elif "special music" in normalized_label:
item['type'] = "Special Music"
if len(detail_lines) == 1 and detail_lines[0].strip().lower() == 'tba':
item['title'] = 'TBA'
item['speaker'] = None
elif len(detail_lines) == 1:
item['speaker'] = detail_lines[0].strip()
item['details'] = None # Usually speaker/TBA is enough
elif "announcements" in normalized_label: # From DW example
item['type'] = "Announcement DW"
if len(detail_lines) == 1:
item['speaker'] = detail_lines[0].strip()
item['details'] = None
# If type is still Default or details are just joined lines
if item['type'] == 'Default':
item['details'] = "\n".join(line.strip() for line in detail_lines).strip()
item['title'] = None
item['speaker'] = None
if not item['details']: # Ensure empty string becomes None
item['details'] = None
return item
def parse_divine_worship(dw_text):
"""
Parses the Divine Worship text (after stripping HTML) into a structured list of items.
Handles labels and multi-line details, identifying specific types like Sermon, Hymn, etc.
"""
clean_dw_text = strip_html_tags(dw_text) # Strip HTML first
if not clean_dw_text or not clean_dw_text.strip():
return []
items = []
current_lines = clean_dw_text.splitlines() # Use cleaned text
current_label = None
detail_lines = []
i = 0
while i < len(current_lines):
current_line = current_lines[i].strip() # Ensure the line is stripped before checking
if not current_line: # Skip empty lines
i += 1
continue
if current_line.endswith(':'):
if current_label: # If there was a previous label, process it
item_dict = _process_dw_item(current_label, detail_lines)
items.append(item_dict)
current_label = current_line[:-1].strip() # Assign new label (already stripped, but strip colon part)
detail_lines = [] # Reset for new label
else:
detail_lines.append(current_lines[i]) # Append the original (unstripped) line for details
i += 1
if current_label: # Process the last accumulated item
item_dict = _process_dw_item(current_label, detail_lines)
items.append(item_dict)
return items
def render_html_template(template_file_name, context_data):
"""
Renders the Jinja2 HTML template with the given context.
'template_file_name' is the name of the template file in the TEMPLATES_DIR.
Returns the rendered HTML as a string, or None on error.
"""
try:
# Get the directory containing the current script (main.py)
script_dir = os.path.dirname(os.path.abspath(__file__))
# Combine with the TEMPLATES_DIR to get the absolute path to the templates folder
templates_abs_path = os.path.join(script_dir, TEMPLATES_DIR)
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(templates_abs_path),
autoescape=jinja2.select_autoescape(['html', 'xml'])
)
template = env.get_template(template_file_name)
rendered_html = template.render(context_data)
print(f"Successfully rendered HTML template: {template_file_name}")
return rendered_html
except jinja2.exceptions.TemplateNotFound:
print(f"ERROR: Jinja2 template not found: {template_file_name} in {templates_abs_path}")
return None
except Exception as e:
print(f"ERROR: An unexpected error occurred during HTML template rendering: {e}")
return None
def generate_pdf_from_html(html_string, output_pdf_path):
"""
Converts HTML content to PDF using WeasyPrint.
html_string: The HTML content as a string.
output_pdf_path: The full path where the PDF will be saved.
The CSS is expected to be linked correctly in the HTML and resolvable
relative to the base_url (templates directory).
Returns True on success, False on error.
"""
if not html_string:
print("ERROR: No HTML content provided for PDF generation.")
return False
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
# base_url for resolving relative paths in HTML (like style.css link, or images if they were relative)
# Our cover image path is absolute, so this mainly helps find style.css.
base_url_for_html = os.path.join(script_dir, TEMPLATES_DIR)
# Explicitly load the CSS to ensure it's found and applied.
css_file_path = os.path.join(base_url_for_html, "style.css")
if not os.path.exists(css_file_path):
print(f"ERROR: CSS file not found at {css_file_path}")
return False
css_stylesheet = CSS(css_file_path)
# Ensure output directory exists for the PDF
os.makedirs(os.path.dirname(output_pdf_path), exist_ok=True)
html_doc = HTML(string=html_string, base_url=base_url_for_html)
html_doc.write_pdf(output_pdf_path, stylesheets=[css_stylesheet])
print(f"Successfully generated PDF: {output_pdf_path}")
return True
except FileNotFoundError as e: # For CSS file usually
print(f"ERROR: File not found during PDF generation: {e}")
return False
except Exception as e:
print(f"ERROR: An unexpected error occurred during PDF generation: {e}")
return False
def upload_pdf_to_pocketbase(pb_config, bulletin_collection_id, bulletin_record_id, pdf_path, bulletin_record_data):
"""
Uploads the generated PDF to the 'pdf' field of the specified bulletin record.
Requires admin authentication.
Returns True on success, False on error.
"""
if not pb_config:
print("ERROR: PocketBase configuration is not available for PDF upload.")
return False
base_url = pb_config['pocketbase_url']
admin_email = pb_config['pocketbase_admin_email']
admin_password = pb_config['pocketbase_admin_password']
auth_token = None
# 1. Authenticate as Admin to get a token
# The collection for admins is typically named '_superusers'
admin_collection_name = "_superusers"
auth_url = f"{base_url}/api/collections/{admin_collection_name}/auth-with-password"
auth_payload = {
'identity': admin_email,
'password': admin_password
}
try:
print(f"Authenticating admin user: {admin_email}")
auth_response = requests.post(auth_url, json=auth_payload)
auth_response.raise_for_status()
auth_data = auth_response.json()
auth_token = auth_data.get('token')
if not auth_token:
print("ERROR: Admin authentication successful but no token received.")
return False
print("Admin authentication successful.")
except requests.exceptions.RequestException as e:
print(f"ERROR: Admin authentication failed: {e} - Response: {e.response.text if e.response else 'No response'}")
return False
except Exception as e:
print(f"ERROR: An unexpected error occurred during admin authentication: {e}")
return False
# Common URL for updates
update_url = f"{base_url}/api/collections/{bulletin_collection_id}/records/{bulletin_record_id}"
headers = {
# Use token directly, as this worked
'Authorization': auth_token
}
# 2. Upload the PDF file (original logic)
pdf_filename = os.path.basename(pdf_path)
try:
with open(pdf_path, 'rb') as f:
files = {
'pdf': (pdf_filename, f, 'application/pdf')
}
print(f"Uploading PDF '{pdf_filename}' to record '{bulletin_record_id}' at {update_url}")
upload_response = requests.patch(update_url, headers=headers, files=files)
upload_response.raise_for_status()
print(f"Successfully uploaded PDF to PocketBase record ID: {bulletin_record_id}")
return True
except FileNotFoundError:
print(f"ERROR: PDF file not found at {pdf_path} for upload.")
return False
except requests.exceptions.RequestException as e:
print(f"ERROR: PDF upload failed: {e} - Response: {e.response.text if e.response else 'No response'}")
return False
except Exception as e:
print(f"ERROR: An unexpected error occurred during PDF upload: {e}")
return False
def cleanup_temp_files(image_path):
"""Removes temporary downloaded files (e.g., cover image)."""
if not image_path or not os.path.exists(image_path):
print(f"INFO: No temporary file to clean up at {image_path}, or file already removed.")
return
try:
os.remove(image_path)
print(f"Successfully removed temporary file: {image_path}")
except OSError as e:
print(f"ERROR: Could not remove temporary file {image_path}: {e}")
except Exception as e:
print(f"ERROR: An unexpected error occurred during file cleanup for {image_path}: {e}")
def main_process(bulletin_date_str):
"""
Main orchestration function.
Takes a date string (e.g., "2024-03-15") to identify the bulletin.
"""
print(f"--- Starting bulletin generation process for date: {bulletin_date_str} ---")
# 1. Load config
config = load_config()
if not config:
print("PROCESS HALTED: Configuration loading failed.")
return
# 2. Get PocketBase client details (not a client object, just config dict)
pb_config = get_pocketbase_client(config)
if not pb_config:
print("PROCESS HALTED: PocketBase client configuration failed.")
return
# Create a datetime.date object from bulletin_date_str for functions that need it
try:
bulletin_date_obj = datetime.datetime.strptime(bulletin_date_str, "%Y-%m-%d").date()
except ValueError:
print(f"PROCESS HALTED: Invalid bulletin_date_str format: '{bulletin_date_str}'. Please use YYYY-MM-DD.")
return
# 3. Fetch bulletin data
print("Fetching bulletin main data...")
bulletin_record = fetch_bulletin_data(pb_config, bulletin_date_str)
if not bulletin_record:
print(f"PROCESS HALTED: Could not fetch bulletin data for {bulletin_date_str}.")
return
print(f"DEBUG: Full bulletin record details: {bulletin_record}") # DEBUG PRINT
bulletin_record_id = bulletin_record.get('id')
bulletin_collection_id = bulletin_record.get('collectionId') # PB provides this
if not bulletin_record_id or not bulletin_collection_id:
print("PROCESS HALTED: Bulletin record ID or Collection ID missing from fetched data.")
return
# 4. Download cover image
print("Downloading cover image...")
# Assuming 'cover_image' is the field name in PB for the image filename
# Ensure TEMP_IMAGE_DIR is an absolute path or resolvable from script location
script_dir = os.path.dirname(os.path.abspath(__file__))
temp_image_save_dir = os.path.join(script_dir, TEMP_IMAGE_DIR)
downloaded_cover_image_path = download_cover_image(
pb_config,
bulletin_collection_id,
bulletin_record_id,
'cover_image', # Field name for the image in the bulletin record
bulletin_record,
temp_image_save_dir
)
if not downloaded_cover_image_path:
print("PROCESS CONTINUING WITHOUT COVER IMAGE: Cover image download failed.")
# Allow process to continue, template can handle missing image
# 5. Fetch and filter events (announcements)
print("Fetching announcements (events data)...")
announcements = fetch_events_data(pb_config, bulletin_date_obj)
# fetch_events_data returns [] on error, so we can proceed
# 6. Parse Sabbath School text
print("Parsing Sabbath School text...")
ss_text = bulletin_record.get('sabbath_school', '')
sabbath_school_items = parse_sabbath_school(ss_text)
# 7. Parse Divine Worship text
print("Parsing Divine Worship text...")
dw_text = bulletin_record.get('divine_worship', '')
divine_worship_items = parse_divine_worship(dw_text)
# 8. Prepare context for Jinja2 template
print("Preparing template context...")
bulletin_theme_title = strip_html_tags(bulletin_record.get('title', 'Welcome'))
sunset_times = strip_html_tags(bulletin_record.get('sunset', 'Not available'))
context_data = {
'bulletin_date': bulletin_date_obj.strftime("%B %d, %Y"), # Formatted date
'bulletin_theme_title': bulletin_theme_title,
'church_name': config.get('church_name', 'Rockville Tolland SDA Church'), # Get from config or default
'cover_image_path': downloaded_cover_image_path, # Will be None if download failed
'sabbath_school_items': sabbath_school_items,
'divine_worship_items': divine_worship_items,
'announcements': announcements,
'sunset_times': sunset_times,
'contact_info': { # Could also be loaded from config if it varies
'phone': config.get('contact_phone', '860-875-0450'),
'website': config.get('contact_website', 'rockvilletollandsda.church'),
'youtube': config.get('contact_youtube', 'YouTube.com/@RockvilleTollandSDAChurch'),
'address': config.get('contact_address', '9 Hartford Tpke Tolland CT 06084')
}
}
# 9. Render HTML
print("Rendering HTML template...")
html_output = render_html_template('bulletin_template.html', context_data)
if not html_output:
print("PROCESS HALTED: HTML rendering failed.")
cleanup_temp_files(downloaded_cover_image_path)
return
# 10. Generate PDF
print("Generating PDF...")
# Ensure OUTPUT_DIR is an absolute path or resolvable
output_dir_abs = os.path.join(script_dir, OUTPUT_DIR)
os.makedirs(output_dir_abs, exist_ok=True)
pdf_filename = f"bulletin_{bulletin_date_str}.pdf"
output_pdf_path = os.path.join(output_dir_abs, pdf_filename)
pdf_generation_success = generate_pdf_from_html(html_output, output_pdf_path)
if not pdf_generation_success:
print("PROCESS HALTED: PDF generation failed.")
cleanup_temp_files(downloaded_cover_image_path)
return
# 11. Upload PDF to PocketBase
print("Uploading PDF to PocketBase...")
upload_success = upload_pdf_to_pocketbase(
pb_config,
bulletin_collection_id,
bulletin_record_id,
output_pdf_path,
bulletin_record # Pass the fetched bulletin_record here
)
if not upload_success:
print("PROCESS WARNING: PDF upload to PocketBase failed. PDF is available locally.")
# Don't halt, PDF is still generated locally.
# 12. Cleanup temp image
print("Cleaning up temporary files...")
cleanup_temp_files(downloaded_cover_image_path)
print(f"--- Bulletin generation process for date: {bulletin_date_str} COMPLETED ---")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate a church bulletin PDF from PocketBase data.")
parser.add_argument(
"--date",
type=str,
help="Optional: Specific date for the bulletin in YYYY-MM-DD format. Defaults to the upcoming Saturday (or today if it is Saturday)."
)
args = parser.parse_args()
target_bulletin_date_str = None
if args.date:
try:
# Validate the provided date format
datetime.datetime.strptime(args.date, "%Y-%m-%d")
target_bulletin_date_str = args.date
print(f"Using provided date: {target_bulletin_date_str}")
except ValueError:
print(f"ERROR: Provided date argument '{args.date}' must be in YYYY-MM-DD format.")
parser.print_help()
exit(1)
else:
today = datetime.date.today()
# weekday(): Monday is 0 and Sunday is 6. Saturday is 5.
if today.weekday() == 5: # It's Saturday
target_bulletin_date = today
print(f"Today is Saturday. Using current date: {target_bulletin_date.strftime('%Y-%m-%d')}")
else: # It's not Saturday, find the upcoming Saturday
days_until_saturday = (5 - today.weekday() + 7) % 7
target_bulletin_date = today + datetime.timedelta(days=days_until_saturday)
print(f"No date provided. Automatically determined upcoming Saturday: {target_bulletin_date.strftime('%Y-%m-%d')}")
target_bulletin_date_str = target_bulletin_date.strftime("%Y-%m-%d")
if target_bulletin_date_str:
main_process(target_bulletin_date_str)
else:
# This case should not be reached if logic is correct, but as a safeguard:
print("ERROR: Could not determine target bulletin date.")
exit(1)

View file

@ -0,0 +1,247 @@
use regex::Regex;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SabbathSchoolItem {
pub label: String,
pub details: String,
pub time: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DivineWorshipItem {
pub label: String,
pub item_type: String,
pub title: Option<String>,
pub speaker: Option<String>,
pub details: Option<String>,
}
pub fn strip_html_tags(text: &str) -> String {
if text.is_empty() {
return String::new();
}
let tag_regex = Regex::new(r"<[^>]+>").unwrap();
let no_tags = tag_regex.replace_all(text, "");
html_escape::decode_html_entities(&no_tags)
.replace('\u{00A0}', " ")
.trim()
.to_string()
}
pub fn parse_sabbath_school(ss_text: &str) -> Vec<SabbathSchoolItem> {
let sabbath_school_times = [
"9:15 AM", "9:30 AM", "10:30 AM", "10:35 AM", "10:45 AM"
];
let clean_text = strip_html_tags(ss_text);
if clean_text.trim().is_empty() {
return Vec::new();
}
let mut items = Vec::new();
let lines: Vec<&str> = clean_text.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() {
i += 1;
continue;
}
if line.ends_with(':') {
let label = line.trim_end_matches(':').trim().to_string();
let mut details_parts = Vec::new();
// Collect all non-empty lines until we hit another label or end of text
let mut j = i + 1;
while j < lines.len() {
let next_line = lines[j].trim();
if next_line.is_empty() {
j += 1;
continue;
}
if next_line.ends_with(':') {
break; // Found next label
}
details_parts.push(next_line.to_string());
j += 1;
}
i = j - 1; // Set i to the last line we processed
let details = details_parts.join(" ").trim().to_string();
let time = if items.len() < sabbath_school_times.len() {
sabbath_school_times[items.len()].to_string()
} else {
"Time TBD".to_string()
};
items.push(SabbathSchoolItem {
label,
details,
time,
});
}
i += 1;
}
items
}
pub fn parse_divine_worship(dw_text: &str) -> Vec<DivineWorshipItem> {
let clean_text = strip_html_tags(dw_text);
if clean_text.trim().is_empty() {
return Vec::new();
}
let mut items = Vec::new();
let lines: Vec<&str> = clean_text.lines().collect();
let mut i = 0;
let mut current_label: Option<String> = None;
let mut detail_lines = Vec::new();
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() {
i += 1;
continue;
}
if line.ends_with(':') {
if let Some(label) = current_label.take() {
items.push(process_dw_item(&label, &detail_lines));
}
current_label = Some(line.trim_end_matches(':').trim().to_string());
detail_lines.clear();
} else {
detail_lines.push(lines[i].to_string());
}
i += 1;
}
if let Some(label) = current_label {
items.push(process_dw_item(&label, &detail_lines));
}
items
}
fn process_dw_item(label: &str, detail_lines: &[String]) -> DivineWorshipItem {
let normalized_label = label.to_lowercase();
let details_text = detail_lines.iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join("\n");
let mut item = DivineWorshipItem {
label: label.to_string(),
item_type: "Default".to_string(),
title: None,
speaker: None,
details: if details_text.is_empty() { None } else { Some(details_text.clone()) },
};
if normalized_label.contains("sermon") {
item.item_type = "Sermon".to_string();
if !detail_lines.is_empty() {
item.title = Some(detail_lines[0].trim().to_string());
}
if detail_lines.len() >= 2 {
item.speaker = Some(detail_lines[1].trim().to_string());
}
if detail_lines.len() > 2 {
item.details = Some(detail_lines[2..].iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join("\n"));
} else {
item.details = None;
}
} else if normalized_label.contains("scripture reading") {
item.item_type = "Scripture Reading".to_string();
if !detail_lines.is_empty() {
item.title = Some(detail_lines[0].trim().to_string());
}
if detail_lines.len() >= 2 {
item.speaker = Some(detail_lines[1].trim().to_string());
}
if detail_lines.len() > 2 {
item.details = Some(detail_lines[2..].iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join("\n"));
} else {
item.details = None;
}
} else if normalized_label.contains("hymn") || normalized_label.contains("song") {
item.item_type = "Hymn".to_string();
item.title = Some(detail_lines.iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join(" "));
item.details = None;
} else if normalized_label.contains("call to worship") {
item.item_type = "Call to Worship".to_string();
item.title = Some(detail_lines.iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join(" "));
item.details = None;
} else if normalized_label.contains("prayer") {
item.item_type = "Prayer".to_string();
if detail_lines.len() == 1 {
item.speaker = Some(detail_lines[0].trim().to_string());
item.details = None;
}
} else if normalized_label.contains("offering") {
item.item_type = "Offering".to_string();
if !detail_lines.is_empty() {
item.title = Some(detail_lines[0].trim().to_string());
}
if detail_lines.len() >= 2 {
item.speaker = Some(detail_lines[1].trim().to_string());
}
if detail_lines.len() > 2 {
item.details = Some(detail_lines[2..].iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join("\n"));
} else {
item.details = None;
}
} else if normalized_label.contains("children's story") || normalized_label.contains("childrens story") {
item.item_type = "Childrens Story".to_string();
if detail_lines.len() == 1 {
item.speaker = Some(detail_lines[0].trim().to_string());
item.details = None;
}
} else if normalized_label.contains("special music") {
item.item_type = "Special Music".to_string();
if detail_lines.len() == 1 {
let detail = detail_lines[0].trim();
if detail.to_lowercase() == "tba" {
item.title = Some("TBA".to_string());
item.speaker = None;
} else {
item.speaker = Some(detail.to_string());
}
}
item.details = None;
} else if normalized_label.contains("announcements") {
item.item_type = "Announcement DW".to_string();
if detail_lines.len() == 1 {
item.speaker = Some(detail_lines[0].trim().to_string());
item.details = None;
}
}
if item.details.as_ref().map_or(false, |d| d.is_empty()) {
item.details = None;
}
item
}

View file

@ -0,0 +1,206 @@
mod html_parser;
mod pdf_generator;
mod template_renderer;
use anyhow::{anyhow, Result};
use base64::prelude::*;
use bulletin_shared::{Config, NewApiClient};
use chrono::{Datelike, Local, NaiveDate, Weekday};
use clap::Parser;
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>,
#[arg(short, long, default_value = "output")]
output_dir: PathBuf,
#[arg(long)]
no_upload: bool,
}
fn get_upcoming_saturday(from_date: Option<NaiveDate>) -> NaiveDate {
let today = from_date.unwrap_or_else(|| Local::now().date_naive());
if today.weekday() == Weekday::Sat && from_date.is_none() {
return today;
}
let days_until_saturday = match today.weekday() {
Weekday::Sat => 7,
Weekday::Sun => 6,
Weekday::Mon => 5,
Weekday::Tue => 4,
Weekday::Wed => 3,
Weekday::Thu => 2,
Weekday::Fri => 1,
};
today + chrono::Duration::days(days_until_saturday as i64)
}
#[tokio::main]
async fn main() -> Result<()> {
// Load environment variables from .env file
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter("info")
.init();
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);
let client = NewApiClient::new();
println!("Fetching current bulletin data from new API...");
let new_bulletin = client.get_current_bulletin().await?;
let bulletin = bulletin_shared::new_api::convert_to_bulletin(new_bulletin.clone())?;
let _bulletin_id = bulletin.id.as_ref()
.ok_or_else(|| anyhow!("Bulletin record ID missing"))?;
println!("Downloading cover image...");
let cover_image_path = if let Some(ref cover_image) = bulletin.cover_image {
let temp_dir = std::env::temp_dir().join("bulletin_images");
std::fs::create_dir_all(&temp_dir)?;
// Handle both full URLs and relative paths
let image_url = if cover_image.starts_with("http") {
cover_image.clone()
} else {
format!("https://api.rockvilletollandsda.church/uploads/bulletins/{}", cover_image)
};
let response = reqwest::get(&image_url).await?;
if response.status().is_success() {
let image_data = response.bytes().await?;
// Extract filename from URL if it's a full URL, otherwise use as-is
let filename = if cover_image.starts_with("http") {
cover_image.split('/').last().unwrap_or("cover_image.webp")
} else {
cover_image
};
let image_path = temp_dir.join(filename);
std::fs::write(&image_path, &image_data)?;
// Convert to base64 data URL for PDF rendering
let mime_type = if filename.ends_with(".webp") {
"image/webp"
} else if filename.ends_with(".jpg") || filename.ends_with(".jpeg") {
"image/jpeg"
} else if filename.ends_with(".png") {
"image/png"
} else {
"image/webp" // default
};
let base64_data = base64::prelude::BASE64_STANDARD.encode(&image_data);
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
Some(std::path::PathBuf::from(data_url))
} else {
println!("Failed to download cover image: HTTP {} from {}", response.status(), image_url);
None
}
} else {
println!("No cover image found, continuing without it");
None
};
println!("Fetching upcoming events...");
let events = client.get_upcoming_events_as_events().await.unwrap_or_else(|e| {
eprintln!("Failed to fetch events: {}", e);
Vec::new()
});
println!("Parsing Sabbath School text...");
let sabbath_school_items = html_parser::parse_sabbath_school(&bulletin.sabbath_school_section);
println!("Parsing Divine Worship text...");
let divine_worship_items = html_parser::parse_divine_worship(&bulletin.divine_worship_section);
println!("Grouping similar events...");
let announcements = template_renderer::group_similar_events(events);
println!("Grouped events into {} announcement items", announcements.len());
println!("Preparing template context...");
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(),
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()),
},
};
println!("Rendering HTML template...");
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let template_dir = manifest_dir.join("templates");
let html_content = template_renderer::render_template(&template_dir, &context)?;
println!("Generating PDF...");
std::fs::create_dir_all(&args.output_dir)?;
let pdf_filename = format!("bulletin_{}.pdf", target_date.format("%Y-%m-%d"));
let pdf_path = args.output_dir.join(&pdf_filename);
pdf_generator::generate_pdf(&html_content, &template_dir, &pdf_path)?;
if !args.no_upload {
println!("Uploading PDF to API...");
// Try to get auth token from environment, or login with credentials
let client_with_auth = if let Ok(auth_token) = std::env::var("BULLETIN_API_TOKEN") {
NewApiClient::new().with_auth_token(auth_token)
} else if let (Ok(username), Ok(password)) = (std::env::var("BULLETIN_API_USERNAME"), std::env::var("BULLETIN_API_PASSWORD")) {
println!("Logging in with credentials...");
match NewApiClient::new().login(username, password).await {
Ok(client) => client,
Err(e) => {
eprintln!("Failed to login: {}", e);
println!("PDF is available locally at: {}", pdf_path.display());
return Ok(());
}
}
} else {
println!("No BULLETIN_API_TOKEN or credentials found in environment - PDF saved locally only");
println!("Add BULLETIN_API_TOKEN or BULLETIN_API_USERNAME/BULLETIN_API_PASSWORD to .env file to enable PDF upload");
return Ok(());
};
println!("Uploading PDF for bulletin ID: {}", _bulletin_id);
match client_with_auth.upload_pdf(_bulletin_id, &pdf_path).await {
Ok(_) => println!("Successfully uploaded PDF to API"),
Err(e) => {
eprintln!("Failed to upload PDF to API: {}", e);
println!("PDF is available locally at: {}", pdf_path.display());
}
}
}
if let Some(ref image_path) = cover_image_path {
let _ = std::fs::remove_file(image_path);
}
println!("--- Bulletin generation process for date: {} COMPLETED ---", target_date);
println!("PDF saved to: {}", pdf_path.display());
Ok(())
}

View file

@ -0,0 +1,67 @@
use anyhow::{anyhow, Result};
use headless_chrome::{Browser, LaunchOptions};
use headless_chrome::types::PrintToPdfOptions;
use std::path::Path;
pub fn generate_pdf(html_content: &str, template_dir: &Path, output_path: &Path) -> Result<()> {
let css_path = template_dir.join("style.css");
if !css_path.exists() {
return Err(anyhow!("CSS file not found at {}", css_path.display()));
}
let css_content = std::fs::read_to_string(&css_path)?;
let full_html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
{}
</style>
</head>
<body>
{}
</body>
</html>"#,
css_content,
html_content.replace(r#"<link rel="stylesheet" href="style.css">"#, "")
);
let launch_options = LaunchOptions::default_builder()
.path(Some("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser".into()))
.build()
.expect("Couldn't find Brave Browser");
let browser = Browser::new(launch_options)?;
let tab = browser.new_tab()?;
tab.navigate_to(&format!("data:text/html,{}", urlencoding::encode(&full_html)))?;
tab.wait_until_navigated()?;
let pdf_options = PrintToPdfOptions {
landscape: Some(true),
display_header_footer: Some(false),
print_background: Some(true),
scale: Some(1.0),
paper_width: Some(11.0),
paper_height: Some(8.5),
margin_top: Some(0.5),
margin_bottom: Some(0.5),
margin_left: Some(0.5),
margin_right: Some(0.5),
page_ranges: None,
ignore_invalid_page_ranges: None,
header_template: None,
footer_template: None,
prefer_css_page_size: Some(true),
transfer_mode: None,
generate_tagged_pdf: None,
generate_document_outline: None,
};
let pdf_data = tab.print_to_pdf(Some(pdf_options))?;
std::fs::write(output_path, pdf_data)?;
Ok(())
}

View file

@ -0,0 +1,355 @@
use crate::html_parser::{SabbathSchoolItem, DivineWorshipItem};
use anyhow::Result;
use bulletin_shared::Event;
use serde::Serialize;
use std::path::Path;
use std::collections::HashMap;
use tera::{Tera, Context};
use regex::Regex;
#[derive(Debug, Serialize)]
pub struct TemplateContext {
pub bulletin_date: String,
pub bulletin_theme_title: String,
pub church_name: String,
pub cover_image_path: Option<String>,
pub sabbath_school_items: Vec<SabbathSchoolItem>,
pub divine_worship_items: Vec<DivineWorshipItem>,
pub announcements: Vec<AnnouncementItem>,
pub sunset_times: String,
pub contact_info: ContactInfo,
}
#[derive(Debug, Serialize)]
pub struct ContactInfo {
pub phone: String,
pub website: String,
pub address: String,
}
#[derive(Debug, Serialize)]
pub struct AnnouncementItem {
pub title: String,
pub description: String,
pub start_time_formatted: Option<String>,
pub end_time_formatted: Option<String>,
pub time_display: Option<String>,
pub location: Option<String>,
pub is_multi_day: bool,
pub is_grouped: bool,
pub group_sessions: Vec<GroupSession>,
}
#[derive(Debug, Serialize)]
pub struct GroupSession {
pub title: String,
pub date: String,
pub time: String,
pub location: Option<String>,
}
impl AnnouncementItem {
pub fn from_event(event: Event) -> Self {
let (start_time_formatted, end_time_formatted, time_display, is_multi_day) =
Self::format_event_times(&event);
Self {
title: crate::html_parser::strip_html_tags(&event.name),
description: crate::html_parser::strip_html_tags(&event.description.unwrap_or_default()),
start_time_formatted,
end_time_formatted,
time_display,
location: event.location.map(|loc| crate::html_parser::strip_html_tags(&loc)),
is_multi_day,
is_grouped: false,
group_sessions: Vec::new(),
}
}
pub fn from_grouped_events(series_name: &str, events: Vec<Event>) -> Self {
let mut group_sessions = Vec::new();
let description = events.first()
.and_then(|e| e.description.as_ref())
.map(|d| crate::html_parser::strip_html_tags(d))
.unwrap_or_default();
let location = events.first()
.and_then(|e| e.location.as_ref())
.map(|l| crate::html_parser::strip_html_tags(l));
for event in events {
let date = if let Some(start_dt) = &event.start_datetime {
start_dt.format("%A, %B %d, %Y").to_string()
} else {
event.date.format("%A, %B %d, %Y").to_string()
};
let time = if let Some(start_dt) = &event.start_datetime {
if let Some(end_dt) = &event.end_datetime {
if start_dt.date_naive() == end_dt.date_naive() {
format!("{} - {}",
start_dt.format("%I:%M %p"),
end_dt.format("%I:%M %p"))
} else {
start_dt.format("%I:%M %p").to_string()
}
} else {
start_dt.format("%I:%M %p").to_string()
}
} else {
event.time.clone()
};
group_sessions.push(GroupSession {
title: crate::html_parser::strip_html_tags(&event.name),
date,
time,
location: event.location.map(|loc| crate::html_parser::strip_html_tags(&loc)),
});
}
Self {
title: series_name.to_string(),
description,
start_time_formatted: None,
end_time_formatted: None,
time_display: Some("Multiple Sessions".to_string()),
location,
is_multi_day: true,
is_grouped: true,
group_sessions,
}
}
fn format_event_times(event: &Event) -> (Option<String>, Option<String>, Option<String>, bool) {
if let (Some(start_dt), Some(end_dt)) = (&event.start_datetime, &event.end_datetime) {
let start_formatted = start_dt.format("%A, %B %d, %Y at %I:%M %p").to_string();
let end_formatted = end_dt.format("%A, %B %d, %Y at %I:%M %p").to_string();
// Check if it's a multi-day event
let is_multi_day = start_dt.date_naive() != end_dt.date_naive();
let time_display = if is_multi_day {
Some(format!("{} to {}", start_formatted, end_formatted))
} else {
// Same day event
let date_str = start_dt.format("%A, %B %d, %Y").to_string();
let start_time = start_dt.format("%I:%M %p").to_string();
let end_time = end_dt.format("%I:%M %p").to_string();
Some(format!("{} from {} to {}", date_str, start_time, end_time))
};
(Some(start_formatted), Some(end_formatted), time_display, is_multi_day)
} else {
// Fallback to old format if datetime fields are missing
let start_time_formatted = if let Some(start_dt) = &event.start_datetime {
Some(start_dt.format("%A, %B %d, %Y at %I:%M %p").to_string())
} else {
// Use date and time fields as fallback
Some(format!("{} at {}", event.date.format("%A, %B %d, %Y"), event.time))
};
(start_time_formatted.clone(), None, start_time_formatted, false)
}
}
}
pub fn render_template(template_dir: &Path, context: &TemplateContext) -> Result<String> {
let mut tera = Tera::new(&format!("{}/**/*", template_dir.display()))?;
tera.register_filter("lower", |value: &tera::Value, _: &std::collections::HashMap<String, tera::Value>| {
match value {
tera::Value::String(s) => Ok(tera::Value::String(s.to_lowercase())),
_ => Ok(value.clone()),
}
});
tera.register_filter("replace", |value: &tera::Value, args: &std::collections::HashMap<String, tera::Value>| {
match value {
tera::Value::String(s) => {
let from = args.get("from").and_then(|v| v.as_str()).unwrap_or("");
let to = args.get("to").and_then(|v| v.as_str()).unwrap_or("");
Ok(tera::Value::String(s.replace(from, to)))
}
_ => Ok(value.clone()),
}
});
let mut tera_context = Context::new();
tera_context.insert("bulletin_date", &context.bulletin_date);
tera_context.insert("bulletin_theme_title", &context.bulletin_theme_title);
tera_context.insert("church_name", &context.church_name);
tera_context.insert("cover_image_path", &context.cover_image_path);
tera_context.insert("sabbath_school_items", &context.sabbath_school_items);
tera_context.insert("divine_worship_items", &context.divine_worship_items);
tera_context.insert("announcements", &context.announcements);
tera_context.insert("sunset_times", &context.sunset_times);
tera_context.insert("contact_info", &context.contact_info);
// Calculate dynamic font size based on content amount
let dynamic_font_size = calculate_dynamic_font_size(&context.announcements);
tera_context.insert("dynamic_announcement_font_size", &dynamic_font_size);
let html = tera.render("bulletin_template.html", &tera_context)?;
Ok(html)
}
pub fn group_similar_events(events: Vec<Event>) -> Vec<AnnouncementItem> {
if events.is_empty() {
return Vec::new();
}
let mut event_groups: HashMap<String, Vec<Event>> = HashMap::new();
let mut ungrouped_events = Vec::new();
// Compile regex patterns for series detection
let patterns = vec![
// "Series Name Part X" or "Series Name Part X:"
Regex::new(r"^(.*?)(?:\s+Part\s+\d+|:\s*Part\s+\d+)(?:\s*:.*)?$").unwrap(),
// "Series Name Session X" or "Series Name Episode X"
Regex::new(r"^(.*?)(?:\s+(?:Session|Episode)\s+\d+)(?:\s*:.*)?$").unwrap(),
// "Series Name - Part X" or "Series Name: Part X"
Regex::new(r"^(.*?)(?:\s*[-:]\s*Part\s+\d+)(?:\s*:.*)?$").unwrap(),
];
for event in events {
let title = event.name.trim();
if title.is_empty() {
ungrouped_events.push(event);
continue;
}
let mut series_name = None;
for pattern in &patterns {
if let Some(captures) = pattern.captures(title) {
if let Some(capture) = captures.get(1) {
let potential_series = capture.as_str().trim();
if potential_series.len() > 2 {
series_name = Some(potential_series.to_string());
break;
}
}
}
}
if let Some(series) = series_name {
let series_key = series.to_lowercase();
event_groups.entry(series_key).or_insert_with(Vec::new).push(event);
} else {
ungrouped_events.push(event);
}
}
let mut result = Vec::new();
// Process grouped events
for (series_key, mut group_events) in event_groups {
if group_events.len() > 1 {
// Sort events by start time
group_events.sort_by(|a, b| {
match (&a.start_datetime, &b.start_datetime) {
(Some(a_dt), Some(b_dt)) => a_dt.cmp(b_dt),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.date.cmp(&b.date),
}
});
// Use the first event's series name extraction for display
let display_name = if let Some(first_event) = group_events.first() {
// Extract the clean series name from the first event
for pattern in &patterns {
if let Some(captures) = pattern.captures(&first_event.name) {
if let Some(capture) = captures.get(1) {
let clean_name = capture.as_str().trim();
if clean_name.len() > 2 {
break;
}
}
}
}
// Fallback to the series_key if extraction fails
series_key.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
} else {
series_key
};
let grouped_item = AnnouncementItem::from_grouped_events(&display_name, group_events);
result.push(grouped_item);
} else {
// Single event, add to ungrouped
ungrouped_events.extend(group_events);
}
}
// Add ungrouped events
for event in ungrouped_events {
result.push(AnnouncementItem::from_event(event));
}
// Sort all items by their start time
result.sort_by(|a, b| {
match (&a.start_time_formatted, &b.start_time_formatted) {
(Some(a_time), Some(b_time)) => a_time.cmp(b_time),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
result
}
fn calculate_dynamic_font_size(announcements: &[AnnouncementItem]) -> String {
// Estimate content density based on announcement count and complexity
let mut content_score = 0.0;
for announcement in announcements {
// Base score per announcement
content_score += 1.0;
// Add score for grouped events (they take more space)
if announcement.is_grouped {
content_score += announcement.group_sessions.len() as f64 * 0.5;
}
// Add score for long descriptions
if announcement.description.len() > 100 {
content_score += 0.5;
}
// Add score for location info
if announcement.location.is_some() {
content_score += 0.3;
}
// Add score for time display
if announcement.time_display.is_some() {
content_score += 0.2;
}
}
// Calculate font size based on content score
// Target: fit roughly 12-15 announcements comfortably
let font_size = if content_score <= 8.0 {
0.85 // Larger font for few announcements
} else if content_score <= 12.0 {
0.75 // Standard font
} else if content_score <= 16.0 {
0.65 // Smaller font for more content
} else if content_score <= 20.0 {
0.58 // Much smaller font
} else {
0.52 // Ultra-compact for lots of content
};
format!("{}em", font_size)
}

View file

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Church Bulletin - {{ bulletin_date }}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Page 1: Inside Left (Left) and Inside Right (Right) -->
<div class="page">
<div class="panel panel-inside-left"> <!-- Panel 2 -->
<h2>{{ bulletin_theme_title | default(value='Welcome') }}</h2>
<!-- Sabbath School Section - Full Width -->
<div class="section sabbath-school">
<h3>Sabbath School</h3>
{% for item in sabbath_school_items %}
<div class="event-item">
<div class="ss-info">
<span class="event-label">{{ item.label }}:</span>
<span class="event-value">{{ item.details }}</span>
</div>
<span class="ss-time">{{ item.time }}</span>
</div>
{% else %}
<p>Sabbath School details not available.</p>
{% endfor %}
</div>
<!-- Divine Worship Section - Full Width Title, Internal 2-Column Content -->
<div class="section divine-worship">
<h3 class="section-title-with-time"><span>Divine Worship</span> <span class="section-time">11:00 AM</span></h3>
<div class="divine-worship-items-column-container"> <!-- New container for 2-column DW items -->
{% for item in divine_worship_items %}
<div class="event-item {{ item.item_type | lower | replace(from=' ', to='-') }}">
<strong class="event-label">{{ item.label }}:</strong>
{% if item.title %}<p class="event-title">{{ item.title }}</p>{% endif %}
{% if item.details %}<p class="event-details">{{ item.details }}</p>{% endif %}
{% if item.speaker %}<p class="event-speaker"><em>{{ item.speaker }}</em></p>{% endif %}
</div>
{% else %}
<p>Divine Worship details not available.</p> <!-- This might look odd in a column if no items -->
{% endfor %}
</div> <!-- End divine-worship-items-column-container -->
</div>
</div>
<div class="panel panel-inside-right"> <!-- Panel 3 (Announcements) -->
<h2>Announcements</h2>
<div class="announcements-column-container" style="font-size: {{ dynamic_announcement_font_size | default(value='0.75em') }};"> <!-- New div for column layout -->
{% for event in announcements %}
<div class="announcement{% if event.is_multi_day %} multi-day{% endif %}{% if event.is_grouped %} grouped{% endif %}">
<strong>{{ event.title }}</strong>
{% if event.is_grouped %}
<p>{{ event.description }}</p>
{% if event.location %}
<p><small>Location: {{ event.location }}</small></p>
{% endif %}
<div class="group-sessions">
{% for session in event.group_sessions %}
<div class="session">
<strong>{{ session.title }}</strong>
<p><small>{{ session.date }} at {{ session.time }}</small></p>
{% if session.location and session.location != event.location %}
<p><small>Location: {{ session.location }}</small></p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
{% if event.time_display %}
<p><small>When: {{ event.time_display }}</small></p>
{% elif event.start_time_formatted %}
<p><small>When: {{ event.start_time_formatted }}</small></p>
{% endif %}
<p>{{ event.description }}</p>
{% if event.location %}
<p><small>Where: {{ event.location }}</small></p>
{% endif %}
{% endif %}
</div>
<!-- hr.announcement-divider was removed in CSS, so no need to put it back here for now -->
{% else %}
<p>No announcements at this time.</p>
{% endfor %}
</div> <!-- End new div -->
</div>
</div>
<!-- Page 2: Back Cover (Left) and Front Cover (Right) -->
<div class="page">
<div class="panel panel-back-cover"> <!-- Panel 4 -->
<h2>Sermon Notes</h2>
<div class="notes-lines">
{% for i in range(end=15) %}<div class="writable-line"></div>{% endfor %}
</div>
<div class="contact-section">
<h3>Contact Us</h3>
<div class="contact-info">
<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>
</div>
<h4>Sunset Times</h4>
<p>{{ sunset_times | default(value='Not available') }}</p>
</div>
</div>
<div class="panel panel-front-cover"> <!-- Panel 1 -->
<h1>{{ church_name | default(value='Rockville-Tolland Seventh-Day Adventist Church') }}</h1>
<p class="bulletin-date-front-cover">{{ bulletin_date }}</p>
{% if cover_image_path %}
<div class="cover-image-container"> <!-- New wrapper div -->
<img src="{{ cover_image_path }}" alt="Cover Image">
</div>
{% endif %}
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,526 @@
/* CSS styles for the bulletin */
/* @import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&display=swap'); */ /* Commented out - will be replaced by local @font-face rules */
/* @import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700;1,900&display=swap'); */ /* Commented out - will be replaced by local @font-face rules */
/* CSS Variables for modern styling */
:root {
--primary-color: #2c3e50;
--secondary-color: #34495e;
--accent-color: #4CAF50;
--text-color: #2c3e50;
--text-light: #7f8c8d;
--background-light: #fafafa;
--background-lighter: #f5f5f5;
--border-color: #e0e0e0;
--border-radius: 3px;
--box-shadow: 0 2px 4px rgba(0,0,0,0.1);
--transition: all 0.3s ease;
--multi-day-bg: linear-gradient(135deg, #f8fff8 0%, #f0f8f0 100%);
--announcement-bg: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
}
/* LOCAL FONT DEFINITIONS */
@font-face {
font-family: 'Dancing Script';
font-style: normal;
font-weight: 400; /* Normal weight */
src: url('fonts/DancingScript-Regular.ttf') format('truetype'); /* ADJUST FILENAME if needed */
}
@font-face {
font-family: 'Dancing Script';
font-style: normal;
font-weight: 700; /* Bold weight */
src: url('fonts/DancingScript-Bold.ttf') format('truetype'); /* ADJUST FILENAME if needed */
}
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: 400; /* Normal/Regular weight */
src: url('fonts/Merriweather_24pt-Regular.ttf') format('truetype'); /* ADJUST FILENAME if needed (e.g., another optical size or if you renamed it) */
}
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: 700; /* Bold weight */
src: url('fonts/Merriweather_24pt-Bold.ttf') format('truetype'); /* ADJUST FILENAME if needed (e.g., Merriweather-Bold.ttf or another optical size) */
}
/* ADD @font-face for the body font (e.g., Open Sans) */
@font-face {
font-family: 'Open Sans'; /* Or your chosen open-source sans-serif */
font-style: normal;
font-weight: 400;
src: url('fonts/OpenSans-Regular.woff2') format('woff2'); /* ADJUST FILENAME if using TTF or different name */
}
@font-face {
font-family: 'Open Sans'; /* Or your chosen open-source sans-serif */
font-style: normal;
font-weight: 700; /* For bold */
src: url('fonts/OpenSans-Bold.woff2') format('woff2'); /* ADJUST FILENAME if using TTF or different name */
}
/* END LOCAL FONT DEFINITIONS */
@page {
size: letter landscape; /* US Letter landscape (11in x 8.5in) */
/* Or use A4 landscape: size: A4 landscape; */
margin: 0.5in; /* Adjust margin as needed */
}
body {
margin: 0; /* Remove default body margin */
font-family: 'Open Sans', sans-serif; /* Using Open Sans as the FOSS body font */
-webkit-print-color-adjust: exact !important; /* Ensure colors print in WebKit browsers */
print-color-adjust: exact !important; /* Standard */
word-break: break-word; /* Help break long words to prevent overflow */
}
.page {
display: flex;
flex-direction: row; /* Panels side-by-side */
width: 100%; /* Fill the content box of the @page */
height: 100%; /* Fill the content box of the @page */
box-sizing: border-box;
page-break-after: always; /* Each .page div creates a new PDF page */
}
body > .page:last-of-type {
page-break-after: avoid !important; /* Avoid a blank page after the last .page, more specific and important */
}
.panel {
width: 50%; /* Fallback */
flex-basis: 50%; /* Explicit flex basis */
flex-shrink: 1; /* Allow shrinking */
flex-grow: 0; /* Do not allow growing beyond basis */
min-height: 0; /* Crucial for allowing shrink with overflow */
height: 100%;
box-sizing: border-box;
padding: 12px; /* Slightly Reduced panel padding */
overflow: hidden; /* Prevent content from overflowing and breaking layout */
page-break-inside: avoid; /* Suggest to renderer not to break inside a panel */
/* border: 1px solid #eee; */ /* Optional: for visualizing panel boundaries */
}
/* Ensure panels that are not the first child in a .page have a left border for separation */
.panel + .panel {
border-left: 1px dotted #ccc; /* Visual separator between panels on the same page */
}
/* General styling for content within panels */
h1, h2, h3, h4 {
margin-top: 0;
color: #333;
}
h1 { font-size: 1.8em; margin-bottom: 0.5em; }
h2 { font-size: 1.5em; margin-bottom: 0.4em; }
h3 { font-size: 1.2em; margin-bottom: 0.3em; }
h4 { font-size: 1em; margin-bottom: 0.2em; }
p {
font-size: 0.9em;
line-height: 1.4;
margin-bottom: 0.8em;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Panel Specific Styles */
/* Panel 1: Front Cover */
.panel-front-cover {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 8px 0.25in; /* Reduced padding for more space */
height: 100%;
box-sizing: border-box;
overflow: hidden; /* Prevent any overflow from this panel */
}
.panel-front-cover h1 {
font-family: 'Dancing Script', cursive;
font-size: 2.4em; /* Reduced from 2.8em for more space */
font-weight: 700; /* Use bold Dancing Script (700) */
margin: 0 0 3px 0; /* Minimal margins */
text-align: center; /* Center the h1 text */
flex-shrink: 0; /* Don't shrink the title */
}
.bulletin-date-front-cover { /* New style for the date */
font-family: 'Dancing Script', cursive;
font-size: 1.2em; /* Reduced from 1.4em */
font-weight: 400; /* Use normal Dancing Script (400) */
color: #333; /* Same color as headings or adjust */
margin: 0 0 8px 0; /* Minimal margins */
text-align: center; /* Ensure it's centered like the h1 */
flex-shrink: 0; /* Don't shrink the date */
}
/* New rule for the container */
.cover-image-container {
width: 100%;
flex: 1; /* Take up remaining space in the flex container */
min-height: 0; /* Allow flex item to shrink */
max-height: 100%; /* Don't exceed parent bounds */
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
margin-bottom: 5px; /* Minimal margin */
box-sizing: border-box;
position: relative;
}
.panel-front-cover img {
max-width: 95%; /* Slightly smaller than container to ensure no overflow */
max-height: 95%; /* Slightly smaller than container to ensure no overflow */
width: auto;
height: auto;
object-fit: contain;
display: block;
margin: 0 auto;
}
/* .panel-front-cover p {
/* font-size: 1.1em;
/* font-weight: bold;
/* }
*/
/* Panel 2: Inside Left (Theme, Sabbath School, Divine Worship) */
.panel-inside-left h2 { /* Bulletin Theme Title */
font-family: 'Dancing Script', cursive; /* Changed from Merriweather */
text-align: center;
margin-bottom: 8px; /* Reduced from 15px */
font-size: 1.7em; /* Reduced from 1.9em */
font-weight: normal; /* Dancing Script often uses normal or a specific weight like 400 or 700 */
}
.section { /* Applies to SS and DW main section divs */
margin-bottom: 20px; /* Increased from 10px to add more space */
}
.section h3 { /* Sabbath School & Divine Worship titles */
font-family: 'Merriweather', serif; /* Or your chosen serif font */
font-weight: 700; /* Changed to bold */
border-bottom: 1px solid #eee;
padding-bottom: 2px; /* Reduced from 3px */
margin-bottom: 6px; /* Reduced from 10px */
font-size: 1.2em; /* Reduced from 1.3em */
}
.divine-worship-items-column-container { /* NEW: For 2-column layout within DW section */
column-count: 2;
column-gap: 6px; /* Further reduced for more space */
column-fill: balance; /* Changed from auto to balance columns better */
orphans: 1; /* Allow single lines at bottom of column */
widows: 1; /* Allow single lines at top of column */
}
.event-item { /* General style for SS and DW items */
margin-bottom: 5px; /* Increased from 3px to use more space */
break-inside: avoid-column; /* Suggestion for items in DW columns */
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
}
.event-label { /* Applies to SS and DW labels */
font-weight: bold;
font-size: 1.0em; /* Increased from 0.95em - we have room now */
}
span.event-value { /* Specifically target SS details span */
font-size: 0.9em; /* Reduced from 1.0em to save space */
display: inline-block;
margin-left: 6px; /* Reduced from 8px */
line-height: 1.3; /* Reduced from 1.45 */
}
/* Target p tags within event-item (mostly affects DW items) */
.event-item p,
.event-item .event-title,
.event-item .event-details,
.event-item .event-speaker {
font-size: 1.0em; /* Increased from 0.9em */
line-height: 1.4; /* Increased from 1.3 */
margin-top: 2px; /* Increased from 1px */
margin-bottom: 4px; /* Increased from 3px */
}
.event-title {
font-style: italic;
margin-left: 6px; /* Further reduced indent */
}
.event-details, .event-speaker {
margin-left: 6px; /* Further reduced indent */
}
.event-speaker em {
font-style: normal; /* Speaker name might not need to be double italicized if .event-speaker is italic */
}
/* New styles for section titles with time and SS item times */
.section-title-with-time {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.section-time { /* For Divine Worship time */
font-size: 0.8em;
font-weight: normal;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #555;
}
.ss-time { /* For Sabbath School item times */
font-weight: normal;
font-size: 0.9em;
margin-right: 8px;
color: #333;
display: inline-block;
}
/* Styles for Sabbath School items with time alignment */
.sabbath-school .event-item { /* Make .event-item within .sabbath-school a flex container */
display: flex;
justify-content: space-between; /* Pushes time to one side, info to the other */
align-items: flex-start; /* Align to top edge, more robust for varying content heights */
/* margin-bottom: 6px; */ /* Retain or adjust item bottom margin, inherits from generic .event-item if not overridden */
}
.sabbath-school .ss-time { /* Styles for the time itself */
font-weight: normal;
font-size: 0.9em;
color: #333;
display: inline-block; /* Keep as inline-block or block as needed */
flex-shrink: 0; /* Prevent time from shrinking */
/* margin-right: 10px; /* Removed, relying on space-between from parent */
}
.sabbath-school .ss-info { /* Container for label and details */
text-align: left; /* Default, adjust if you want label/details aligned right */
flex-grow: 1;
/* margin-left: 10px; /* Add if .ss-time doesn't have margin-right & space-between isn't enough */
display: flex; /* Make this a flex container as well */
align-items: baseline; /* Align children to their baseline */
}
/* Adjustments to existing .event-label and .event-value when inside .sabbath-school */
.sabbath-school .event-label {
/* font-weight: bold; /* Inherits from generic .event-label */
/* font-size: 1.05em; /* Inherits generic .event-label if specific styles here are commented out or match */
/* display: inline; /* Changed from inline */
min-width: 125px; /* Adjust this value as needed - Increased from 110px */
display: inline-block; /* Or just block if preferred, but inline-block allows fixed width */
margin-right: 5px; /* Add some space between label and value */
}
.sabbath-school span.event-value {
/* font-size: 1.0em; /* Inherits from generic span.event-value */
/* display: inline; /* No longer strictly needed if parent is flex and label has width */
/* margin-left: 8px; /* Inherits */
/* line-height: 1.45; /* Inherits */
}
/* Panel 3: Inside Right (Announcements) */
.panel-inside-right {
/* column-count: 2; */ /* Moved to .announcements-column-container */
/* column-gap: 10px; */ /* Moved */
/* column-fill: auto; */ /* Moved */
/* height: 100%; */ /* Panel already has height: 100% */
}
.panel-inside-right h2 { /* Title "Announcements" */
text-align: center;
margin-bottom: 4px; /* Reduced margin for more space */
font-size: 1.3em; /* Slightly smaller header */
font-weight: 700; /* Added to make it explicitly bold */
/* Ensure the body font ('Open Sans' or system fallback) has a bold version available */
}
.announcements-column-container { /* New class for applying column styles */
column-count: 2; /* Enable 2-column layout */
column-gap: 8px; /* Reduced space between columns */
column-fill: balance; /* Balance content between columns */
height: calc(100% - 40px); /* Account for header height */
max-height: 100%; /* Strict height limit */
overflow: hidden; /* If content still overflows columns, clip it here */
orphans: 1; /* Allow single lines at bottom of column */
widows: 1; /* Allow single lines at top of column */
/* Dynamic font scaling based on content amount */
font-size: 0.75em; /* Default, overridden by inline style */
}
.announcement {
margin-bottom: 3px; /* Further reduced for ultra-compact layout */
padding: 3px 4px; /* Very compact padding */
border-radius: var(--border-radius); /* Using CSS variable */
background: var(--announcement-bg); /* Using CSS variable */
border-left: 2px solid var(--border-color); /* Thinner border */
break-inside: avoid-column; /* Prevent breaking across columns */
-webkit-column-break-inside: avoid; /* For Safari/Chrome */
page-break-inside: avoid; /* Fallback */
transition: var(--transition); /* Using CSS variable */
width: 100%; /* Ensure proper width in columns */
box-sizing: border-box; /* Include padding and border in width */
}
.announcement.multi-day {
border-left-color: var(--accent-color); /* Using CSS variable */
background: var(--multi-day-bg); /* Using CSS variable */
}
.announcement:hover {
box-shadow: var(--box-shadow); /* Using CSS variable */
transform: translateY(-1px); /* Slight lift effect */
}
.announcement strong { /* Event Title */
font-size: 1.1em; /* Relative to container's dynamic font size */
display: block;
margin-bottom: 1px; /* Minimal margin */
line-height: 1.0; /* Very tight line height */
color: var(--text-color); /* Using CSS variable */
font-weight: 600; /* Slightly heavier weight */
}
.announcement p {
font-size: 1em; /* Relative to container's dynamic font size */
line-height: 1.1; /* Very tight line height */
margin-top: 0;
margin-bottom: 1px; /* Minimal margin */
color: var(--secondary-color); /* Using CSS variable */
}
.announcement p small { /* For "When" and "Where" */
font-size: 0.9em; /* Relative to parent p */
color: var(--text-light); /* Using CSS variable */
line-height: 1.0; /* Very tight line height */
font-weight: 500; /* Slightly bolder for better visibility */
display: inline-block; /* Better control over spacing */
margin-top: 0; /* No top margin */
}
hr.announcement-divider {
display: none; /* Remove dividers entirely for max space */
/* border: 0;
border-top: 1px dashed #ddd;
margin: 5px 0 8px 0;
column-span: all; */
}
.panel-inside-right > .announcement:last-of-type + hr.announcement-divider {
display:none; /* This rule becomes redundant if all are display:none but harmless */
}
/* Grouped events styling */
.announcement.grouped {
border-left-color: #3498db; /* Blue border for grouped events */
background: linear-gradient(135deg, #f8fcff 0%, #f0f8ff 100%);
}
.group-sessions {
margin-top: 2px;
padding-left: 8px;
border-left: 1px solid #e0e0e0;
}
.group-sessions .session {
margin-bottom: 2px;
padding: 2px 0;
border-bottom: 1px dotted #e0e0e0;
}
.group-sessions .session:last-child {
border-bottom: none;
margin-bottom: 0;
}
.group-sessions .session strong {
font-size: 0.9em; /* Relative to container's dynamic font size */
color: var(--secondary-color);
font-weight: 500;
}
.group-sessions .session p {
font-size: 0.85em; /* Relative to container's dynamic font size */
margin-bottom: 0;
}
.group-sessions .session p small {
font-size: 1em; /* Relative to parent p */
color: var(--text-light);
}
/* Panel 4: Back Cover (Sermon Notes, Contact Info) */
.panel-back-cover {
text-align: center;
/* padding-top: 0.2in; */ /* Attempt to vertically center content - removed for testing */
/* padding-bottom: 0.2in; */ /* Attempt to vertically center content - removed for testing */
}
.panel-back-cover h2 { /* Sermon Notes title */
text-align: center;
margin-bottom: 10px;
}
.notes-lines {
width: 90%; /* ADDED */
margin: 0 auto 15px auto; /* MODIFIED from margin-bottom: 15px; */
}
.writable-line {
height: 1.7em; /* MODIFIED from 1.6em, was 1.8em */
border-bottom: 1px solid #b0b0b0; /* The line itself */
box-sizing: border-box; /* Include border in height if padding were added */
/* No margin needed usually, as height itself creates the row space */
}
.contact-section {
width: 90%; /* ADDED */
margin: 30px auto 0 auto; /* MODIFIED from margin-top: 30px; */
text-align: center; /* Center contact info block */
}
.contact-section h3 {
margin-bottom: 10px;
}
.contact-section h4 { /* Sunset Times sub-header */
margin-top: 15px;
margin-bottom: 5px;
font-size: 0.9em;
font-weight: bold;
color: #444;
}
.contact-info p {
font-size: 0.78em; /* MODIFIED from 0.8em */
line-height: 1.2; /* MODIFIED from 1.3 */
margin-bottom: 3px; /* MODIFIED from 4px */
}
.contact-section > p { /* For the actual sunset times text */
font-size: 0.82em; /* MODIFIED from 0.85em */
font-style: italic;
line-height: 1.2; /* ADDED */
margin-bottom: 3px; /* ADDED */
}
/* Ensure images from user content are constrained */
.panel img {
max-width: 100%;
height: auto;
}
/* Add any further refinements for typography, spacing, etc. */
/* Add @page rules for print specifics later if needed */
/* e.g.
@page {
size: A4 landscape;
margin: 10mm;
}
*/

24
bulletin-input/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "bulletin-input"
version.workspace = true
edition.workspace = true
[[bin]]
name = "bulletin-input"
path = "src/main.rs"
[dependencies]
bulletin-shared = { path = "../bulletin-shared" }
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
chrono.workspace = true
anyhow.workspace = true
csv.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
dialoguer = "0.11"
regex = "1.11"
dotenvy.workspace = true

View file

@ -0,0 +1,674 @@
#!/usr/bin/env python3
import csv
import datetime
import json
import os
import re
import requests
import getpass
from typing import Dict, List, Optional
# PocketBase config
POCKETBASE_URL = "https://pocketbase.rockvilletollandsda.church"
ADMIN_EMAIL = "av@rockvilletollandsda.org"
# Get password from environment or prompt
ADMIN_PASSWORD = os.environ.get('POCKETBASE_PASSWORD')
if not ADMIN_PASSWORD:
ADMIN_PASSWORD = getpass.getpass("Enter PocketBase admin password: ")
# Default times
DEFAULT_SABBATH_SCHOOL_START_TIME = "09:30:00"
DEFAULT_SABBATH_SCHOOL_END_TIME = "10:45:00"
DEFAULT_DIVINE_WORSHIP_START_TIME = "11:00:00"
DEFAULT_DIVINE_WORSHIP_END_TIME = "12:30:00"
def get_upcoming_saturday() -> datetime.date:
"""Get next Saturday date"""
today = datetime.date.today()
days_until_saturday = (5 - today.weekday() + 7) % 7
return today + datetime.timedelta(days=days_until_saturday)
def parse_sermon_details_from_text(text: str) -> Dict[str, str]:
"""Parse sermon details from text input"""
details = {}
print(f"DEBUG: Raw sermon input text: '{text}'")
# Your exact input format:
# Sermon Title: "A Revelation of Jesus Christ" Scripture Reading: Revelation 1:8
# Opening Song: 623 - I Will Follow Thee Closing Song: 330 - Take My Life and Let It Be
# Responsive Reading: 800 - Union with Christ
# Fixed regex patterns for your exact format
patterns = [
(r'Sermon Title:\s*"([^"]+)"', 'sermon_title'),
(r'Scripture Reading:\s*([^A-Za-z]*[A-Za-z][^A-Za-z0-9]*[0-9:]+[^A-Za-z]*)', 'scripture_reading'),
(r'Opening Song:\s*([0-9]+\s*-[^C]*?)(?=\s*Closing Song)', 'opening_song'),
(r'Closing Song:\s*([0-9]+\s*-[^R]*?)(?=\s*Responsive Reading)', 'closing_song'),
(r'Responsive Reading:\s*([0-9]+\s*-[^$]*)', 'responsive_reading')
]
# Simpler approach - split by keywords
if 'Sermon Title:' in text:
# Look for Sermon Title: "anything in quotes" - handle smart quotes too
title_match = re.search(r'Sermon Title:\s*["\'""]([^"\'""]+)["\'""]', text)
if title_match:
details['sermon_title'] = title_match.group(1).strip()
print(f"DEBUG: Found sermon_title: '{details['sermon_title']}'")
else:
# Try without quotes
title_match = re.search(r'Sermon Title:\s*([^S]+?)(?=\s+Scripture Reading)', text)
if title_match:
details['sermon_title'] = title_match.group(1).strip().strip('"').strip('"').strip('"')
print(f"DEBUG: Found sermon_title (no quotes): '{details['sermon_title']}'")
else:
print(f"DEBUG: Sermon Title found in text but regex failed")
print(f"DEBUG: Text around Sermon Title: '{text[text.find('Sermon Title:'):text.find('Sermon Title:')+100]}'")
else:
print(f"DEBUG: 'Sermon Title:' not found in text")
if 'Scripture Reading:' in text:
scripture_match = re.search(r'Scripture Reading:\s*([^\n\r]+?)(?=\s+Opening Song|$)', text)
if scripture_match:
details['scripture_reading'] = scripture_match.group(1).strip()
print(f"DEBUG: Found scripture_reading: '{details['scripture_reading']}'")
if 'Opening Song:' in text:
opening_match = re.search(r'Opening Song:\s*([^\n\r]+?)(?=\s+Closing Song|$)', text)
if opening_match:
song = opening_match.group(1).strip()
# Format as #XXX "Title"
if ' - ' in song:
num, title = song.split(' - ', 1)
details['opening_song'] = f"#{num.strip()} \"{title.strip()}\""
else:
details['opening_song'] = song
print(f"DEBUG: Found opening_song: '{details['opening_song']}'")
if 'Closing Song:' in text:
closing_match = re.search(r'Closing Song:\s*([^\n\r]+?)(?=\s+Responsive Reading|$)', text)
if closing_match:
song = closing_match.group(1).strip()
# Format as #XXX "Title"
if ' - ' in song:
num, title = song.split(' - ', 1)
details['closing_song'] = f"#{num.strip()} \"{title.strip()}\""
else:
details['closing_song'] = song
print(f"DEBUG: Found closing_song: '{details['closing_song']}'")
if 'Responsive Reading:' in text:
responsive_match = re.search(r'Responsive Reading:\s*([^\n\r]+?)(?=\s*$)', text)
if responsive_match:
reading = responsive_match.group(1).strip()
# Format as Hymnal XXX "Title"
if ' - ' in reading:
num, title = reading.split(' - ', 1)
details['responsive_reading'] = f"Hymnal {num.strip()} \"{title.strip()}\""
else:
details['responsive_reading'] = reading
print(f"DEBUG: Found responsive_reading: '{details['responsive_reading']}'")
return details
def parse_schedule_csv(csv_filepath: str, target_date: datetime.date) -> Optional[Dict[str, str]]:
"""Parse CSV for target date schedule"""
try:
with open(csv_filepath, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
lines = list(reader)
except FileNotFoundError:
print(f"ERROR: Schedule CSV file not found at {csv_filepath}")
return None
for i, row in enumerate(lines):
if not any(row):
continue
if "DATE" in row and "SERMON" in row:
header = [cell.replace("\\n", " ").strip() for cell in row]
sub_header = []
if i + 1 < len(lines):
sub_header = [cell.replace("\\n", " ").strip() for cell in lines[i+1]]
# Look for data rows after this header
for j in range(i+2, len(lines)):
data_row = lines[j]
if not data_row or not data_row[0].strip():
continue
date_str = data_row[0].replace("\\n", " ").strip()
try:
day_str, month_str = date_str.split()
month_num = datetime.datetime.strptime(month_str.strip(), "%b").month
current_row_date = datetime.date(target_date.year, month_num, int(day_str.strip()))
except ValueError:
continue
if current_row_date == target_date:
combined_header = []
for idx, h in enumerate(header):
sh = sub_header[idx] if idx < len(sub_header) else ""
if sh and sh not in h:
combined_header.append(f"{h} {sh}".strip())
else:
combined_header.append(h.strip())
values = [data_row[k].replace("\\n", " ").strip() if k < len(data_row) else "" for k in range(len(combined_header))]
return dict(zip(combined_header, values))
return None
def parse_scripture_reference(ref: str) -> tuple:
"""Parse scripture reference like 'Revelation 1:8' into book, chapter, verse"""
try:
# Handle cases like "Revelation 1:8" or "1 John 2:3-5"
parts = ref.strip().split()
# Find where the chapter:verse part starts
chapter_verse_idx = -1
for i, part in enumerate(parts):
if ':' in part:
chapter_verse_idx = i
break
if chapter_verse_idx == -1:
return None, None, None
# Book name is everything before the chapter:verse
book = ' '.join(parts[:chapter_verse_idx])
# Parse chapter:verse
chapter_verse = parts[chapter_verse_idx]
if ':' in chapter_verse:
chapter, verse_part = chapter_verse.split(':', 1)
# Handle verse ranges like "8-10"
if '-' in verse_part:
start_verse, end_verse = verse_part.split('-', 1)
return book, int(chapter), (int(start_verse), int(end_verse))
else:
return book, int(chapter), int(verse_part)
return None, None, None
except:
return None, None, None
def get_scripture_text(scripture_ref: str) -> str:
"""Get scripture text from KJV.json file"""
try:
kjv_path = os.path.join(os.path.dirname(__file__), "KJV.json")
with open(kjv_path, 'r') as f:
kjv_data = json.load(f)
book, chapter, verse = parse_scripture_reference(scripture_ref)
if not book or not chapter or not verse:
return f"<p>[Could not parse scripture reference: {scripture_ref}]</p><p>{scripture_ref} KJV</p>"
# KJV.json structure might be different - let's handle both formats
if isinstance(kjv_data, dict):
# Format: {"Genesis": {"1": ["In the beginning...", "And the earth..."]}}
book_data = kjv_data.get(book)
if book_data and str(chapter) in book_data:
verses = book_data[str(chapter)]
if isinstance(verse, tuple):
start_verse, end_verse = verse
selected_verses = verses[start_verse-1:end_verse]
scripture_text = ' '.join(selected_verses)
else:
if verse <= len(verses):
scripture_text = verses[verse-1]
# Remove reference from beginning if it exists (like "Revelation 1:8 ")
scripture_text = re.sub(r'^[A-Za-z0-9\s]+\s+\d+:\d+\s+', '', scripture_text).strip()
else:
return f"<p>[Verse {verse} not found]</p><p>{scripture_ref} KJV</p>"
return f"<p>{scripture_text}</p><p>{scripture_ref} KJV</p>"
else:
# Format: [{"name": "Genesis", "chapters": [["In the beginning...", "And the earth..."]]}]
for book_entry in kjv_data:
if book_entry.get('name', '').lower() == book.lower():
chapters = book_entry.get('chapters', [])
if chapter <= len(chapters):
chapter_verses = chapters[chapter - 1]
if isinstance(verse, tuple):
start_verse, end_verse = verse
verses_text = []
for v in range(start_verse, end_verse + 1):
if v <= len(chapter_verses):
verses_text.append(chapter_verses[v - 1])
scripture_text = ' '.join(verses_text)
else:
if verse <= len(chapter_verses):
scripture_text = chapter_verses[verse - 1]
# Remove reference from beginning if it exists (like "Revelation 1:8 ")
scripture_text = re.sub(r'^[A-Za-z0-9\s]+\s+\d+:\d+\s+', '', scripture_text).strip()
else:
return f"<p>[Verse {verse} not found]</p><p>{scripture_ref} KJV</p>"
# Return with HTML formatting
return f"<p>{scripture_text}</p><p>{scripture_ref} KJV</p>"
return f"<p>[Scripture not found: {scripture_ref}]</p><p>{scripture_ref} KJV</p>"
except FileNotFoundError:
return f"<p>[KJV.json file not found]</p><p>{scripture_ref} KJV</p>"
except Exception as e:
return f"<p>[Error reading scripture: {e}]</p><p>{scripture_ref} KJV</p>"
"""Authenticate with PocketBase"""
auth_url = f"{POCKETBASE_URL}/api/collections/_superusers/auth-with-password"
auth_data = {
'identity': ADMIN_EMAIL,
'password': ADMIN_PASSWORD
}
response = requests.post(auth_url, json=auth_data)
response.raise_for_status()
return response.json().get('token')
def parse_conference_chart_txt(txt_filepath: str, target_date: datetime.date, location: str) -> Dict[str, Optional[str]]:
"""Parse the conference chart TXT for offering and sunset time."""
try:
with open(txt_filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
except FileNotFoundError:
print(f"ERROR: Conference chart TXT file not found at {txt_filepath}")
return {"offering_focus": None, "sunset_time": None}
except Exception as e:
print(f"ERROR: Could not read conference chart TXT file: {e}")
return {"offering_focus": None, "sunset_time": None}
# Mapping of locations to column indices (Springfield is index 1)
location_columns = {
"south lancaster": 0,
"springfield": 1,
"stoneham": 2,
"bridgeport": 3,
"new haven": 4,
"new london": 5,
"providence": 6,
}
location_col_idx = location_columns.get(location.lower())
if location_col_idx is None:
print(f"ERROR: Location '{location}' not found. Using Springfield as default.")
location_col_idx = 1 # Springfield
target_data = {"offering_focus": None, "sunset_time": None}
current_month_str = None
print(f"DEBUG: Looking for date {target_date}")
for i, line in enumerate(lines):
line = line.strip()
if not line:
continue
# Look for month headers like "June 7"
month_match = re.match(r"^(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d+)", line, re.IGNORECASE)
if month_match:
current_month_str = month_match.group(1)
print(f"DEBUG: Found month: {current_month_str}")
continue
# Look for day lines like "14. Women's Ministries 8:26 8:29..." or "21 Local Church Budget 8:29 8:31..."
if current_month_str:
day_match = re.match(r"^(\d+)\.?\s+(.+)", line)
if day_match:
day_str = day_match.group(1)
rest_of_line = day_match.group(2).strip()
# Split into offering and times - look for where times start (first time pattern)
time_start_match = re.search(r'\d:\d{2}', rest_of_line)
if time_start_match:
time_start_pos = time_start_match.start()
offering_str = rest_of_line[:time_start_pos].strip()
times_str = rest_of_line[time_start_pos:].strip()
else:
offering_str = rest_of_line
times_str = ""
try:
month_num = datetime.datetime.strptime(current_month_str, "%B").month
current_line_date = datetime.date(target_date.year, month_num, int(day_str))
print(f"DEBUG: Checking date {current_line_date} vs target {target_date}")
if current_line_date == target_date:
print(f"DEBUG: MATCH! Found line: '{line}'")
print(f"DEBUG: Offering: '{offering_str}'")
print(f"DEBUG: Times: '{times_str}'")
target_data["offering_focus"] = offering_str
# Extract times - look for patterns like "8:26"
times = re.findall(r'\d:\d{2}', times_str)
print(f"DEBUG: Extracted times: {times}")
if location_col_idx < len(times):
sunset_time = times[location_col_idx]
target_data["sunset_time"] = f"{sunset_time} pm"
print(f"DEBUG: {location} sunset: {target_data['sunset_time']}")
return target_data
except ValueError as e:
print(f"DEBUG: Could not parse date from {current_month_str} {day_str}: {e}")
continue
print(f"ERROR: Could not find data for date {target_date.strftime('%Y-%m-%d')} in conference chart")
return target_data
def authenticate_pocketbase():
"""Authenticate with PocketBase"""
auth_url = f"{POCKETBASE_URL}/api/collections/_superusers/auth-with-password"
auth_data = {
'identity': ADMIN_EMAIL,
'password': ADMIN_PASSWORD
}
response = requests.post(auth_url, json=auth_data)
response.raise_for_status()
return response.json().get('token')
def create_bulletin_in_pocketbase(bulletin_data, events_data, weekly_schedule, parsed_sermon):
"""Create bulletin record in PocketBase"""
print("Authenticating with PocketBase...")
token = authenticate_pocketbase()
# Extract data for PocketBase fields
sabbath_school_info = ""
divine_worship_info = ""
scripture_reading = ""
# Extract people from weekly schedule and prompt for missing values
ss_leader = weekly_schedule.get("S. S. LEADER", "").strip()
if not ss_leader:
ss_leader = input("S.S. Leader not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
ss_teacher = weekly_schedule.get("S. S. TEACHER", "").strip()
if not ss_teacher:
ss_teacher = input("S.S. Teacher not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
mission_story = weekly_schedule.get("MISSION STORY", "").strip()
if not mission_story:
mission_story = input("Mission Story person not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
song_leader = weekly_schedule.get("Song LEADER", "").strip()
if not song_leader:
song_leader = input("Song Leader not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
announcements_person = weekly_schedule.get("SCRIPTURE", "").strip()
if not announcements_person:
announcements_person = input("Announcements person not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
offering_person = weekly_schedule.get("OFFERING", "").strip()
if not offering_person:
offering_person = input("Offering person not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
special_music = weekly_schedule.get("SPECIAL MUSIC", "").strip()
if not special_music:
special_music = input("Special Music person not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
speaker = weekly_schedule.get("SERMON SPEAKER", "").strip()
if not speaker:
speaker = input("Sermon Speaker not found in CSV. Enter name (or press Enter for TBA): ").strip() or "TBA"
print(f"DEBUG: Parsed sermon details: {parsed_sermon}")
print(f"DEBUG: Sermon title from parsing: '{parsed_sermon.get('sermon_title', 'NOT FOUND')}'")
# Pastor Joseph Piresson special logic
is_pastor_speaking = "pastor joseph piresson" in speaker.lower()
# If Pastor is speaking, he also does scripture reading and children's story
if is_pastor_speaking:
print("Pastor Joseph Piresson is speaking - assigning him scripture reading and children's story")
scripture_reader = speaker
childrens_story_person = speaker
else:
scripture_reader = announcements_person
childrens_story_person = announcements_person
opening_song = parsed_sermon.get('opening_song', '').strip()
if not opening_song:
opening_song = input("Opening Song not found in sermon details. Enter song (or press Enter for TBA): ").strip() or "TBA"
closing_song = parsed_sermon.get('closing_song', '').strip()
if not closing_song:
closing_song = input("Closing Song not found in sermon details. Enter song (or press Enter for TBA): ").strip() or "TBA"
scripture_reading = parsed_sermon.get('scripture_reading', '').strip()
if not scripture_reading:
scripture_reading = input("Scripture Reading not found in sermon details. Enter reference (or press Enter for TBA): ").strip() or "TBA"
responsive_reading = parsed_sermon.get('responsive_reading', '').strip()
if not responsive_reading:
responsive_reading = input("Responsive Reading not found in sermon details. Enter reading (or press Enter for TBA): ").strip() or "TBA"
print(f"DEBUG: SS Leader: {ss_leader}")
print(f"DEBUG: SS Teacher: {ss_teacher}")
print(f"DEBUG: Mission Story: {mission_story}")
print(f"DEBUG: Song Leader: {song_leader}")
print(f"DEBUG: Announcements: {announcements_person}")
print(f"DEBUG: Offering: {offering_person}")
print(f"DEBUG: Special Music: {special_music}")
print(f"DEBUG: Speaker: {speaker}")
print(f"DEBUG: Opening Song: {parsed_sermon.get('opening_song', 'TBA')}")
print(f"DEBUG: Closing Song: {parsed_sermon.get('closing_song', 'TBA')}")
print(f"DEBUG: Scripture: {parsed_sermon.get('scripture_reading', 'TBA')}")
# Check what keys are actually in the weekly_schedule
print(f"DEBUG: Weekly schedule keys: {list(weekly_schedule.keys())}")
print(f"DEBUG: Parsed sermon keys: {list(parsed_sermon.keys())}")
# Format Sabbath School as HTML with br tags for tighter spacing
sabbath_school_info = f"""Song Service:<br>
{song_leader}<br><br>
Lesson Study:<br>
{ss_teacher}<br><br>
Leadership:<br>
{ss_leader}<br><br>
Mission Story:<br>
{mission_story}<br><br>
Closing Hymn:<br>
{ss_leader}"""
# Format Divine Worship as HTML with br tags for tighter spacing
divine_worship_info = f"""Announcements:<br>
{announcements_person}<br><br>
Call To Worship:<br>
{responsive_reading}<br><br>
Opening Hymn:<br>
{opening_song}<br><br>
Prayer & Praises:<br>
{announcements_person}<br><br>
Prayer Song:<br>
#671 "As We Come To You in Prayer"<br><br>
Offering:<br>
{bulletin_data.get('offering_focus', 'Local Church Budget')}<br>
{offering_person}<br><br>
Children's Story:<br>
{childrens_story_person}<br><br>
Special Music:<br>
{special_music}<br><br>
Scripture Reading:<br>
{scripture_reading}<br>
{scripture_reader}<br><br>
Sermon:<br>
{parsed_sermon.get('sermon_title', 'TBA')}<br><br>
{speaker}<br><br>
Closing Hymn:<br>
{closing_song}"""
# Scripture reading field - get actual text from KJV.json
scripture_full_text = get_scripture_text(scripture_reading) if scripture_reading != 'TBA' else f"TBA\n\n{scripture_reading}"
# Create PocketBase record
api_url = f"{POCKETBASE_URL}/api/collections/bulletins/records"
headers = {'Authorization': token}
record_data = {
"date": f"{bulletin_data.get('date')} 00:00:00",
"title": parsed_sermon.get('sermon_title', 'Saturday Worship Service'), # Proper Saturday default
"url": "",
"pdf_url": "",
"is_active": True,
"sabbath_school": sabbath_school_info,
"divine_worship": divine_worship_info,
"scripture_reading": scripture_full_text,
"sunset": f"Sunset Tonight: {bulletin_data.get('sunset_time') or 'TBA'}\n\nSunset Next Friday: {bulletin_data.get('next_friday_sunset') or 'TBA'}"
}
print(f"Creating bulletin: {record_data['title']}")
response = requests.post(api_url, headers=headers, json=record_data)
if response.status_code in [200, 201]:
result = response.json()
print(f"SUCCESS! Created bulletin ID: {result.get('id')}")
return True
else:
print(f"FAILED! Status: {response.status_code}")
print(f"Error: {response.text}")
return False
def main():
target_saturday = get_upcoming_saturday()
target_saturday_str = target_saturday.strftime("%Y-%m-%d")
print(f"--- Creating bulletin for Saturday: {target_saturday_str} ---")
# Get file paths
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(script_dir)
schedule_csv_file = os.path.join(project_root, "Quarterly schedule2021 - 2025.csv")
# Get sermon details from user
print("\n--- Enter Sermon Details ---")
print("Paste sermon details and press Enter twice when done:")
sermon_lines = []
empty_line_count = 0
while True:
try:
line = input()
if not line.strip():
empty_line_count += 1
if empty_line_count >= 2:
break
else:
empty_line_count = 0
sermon_lines.append(line)
except EOFError:
break
sermon_text = "\n".join(sermon_lines).strip()
if not sermon_text:
print("No sermon details provided. Exiting.")
return
parsed_sermon = parse_sermon_details_from_text(sermon_text)
# Parse CSV
print(f"\nReading schedule from CSV...")
weekly_schedule = parse_schedule_csv(schedule_csv_file, target_saturday)
if not weekly_schedule:
print("Could not get weekly schedule. Exiting.")
return
# Parse conference chart for offering and sunset
conference_chart_file = os.path.join(project_root, "2025 Offering and Sunset Times Chart.txt")
print(f"\nReading conference chart from: {conference_chart_file}")
# Use Springfield as default location (you can change this)
sunset_location = "springfield"
conference_data = parse_conference_chart_txt(conference_chart_file, target_saturday, sunset_location)
# Get next Friday's sunset too
next_friday = target_saturday + datetime.timedelta(days=6) # 6 days after Saturday = next Friday
# But sunset chart only has Saturdays, so get the closest Saturday (June 21)
next_saturday = target_saturday + datetime.timedelta(days=7) # Next Saturday
next_friday_data = parse_conference_chart_txt(conference_chart_file, next_saturday, sunset_location)
print(f"Found conference data for {target_saturday}: {conference_data}")
print(f"Found conference data for next Saturday {next_saturday} (for Friday sunset): {next_friday_data}")
# If next Saturday data failed, try to get it manually from the chart
if not next_friday_data.get('sunset_time') and conference_data.get('sunset_time'):
# Use a slightly later time as approximation
current_time = conference_data.get('sunset_time', '8:29 pm')
if ':' in current_time:
time_part = current_time.replace(' pm', '').replace(' am', '')
hour, minute = map(int, time_part.split(':'))
# Add a few minutes for next Friday
minute += 2
if minute >= 60:
hour += 1
minute -= 60
next_friday_data['sunset_time'] = f"{hour}:{minute:02d} pm"
print(f"Approximated next Friday sunset: {next_friday_data['sunset_time']}")
# Create bulletin data
bulletin_data = {
"date": target_saturday_str,
"title": parsed_sermon.get("sermon_title", "Saturday Worship Service"), # Use proper Saturday default
"offering_focus": conference_data.get("offering_focus"),
"sunset_time": conference_data.get("sunset_time"),
"next_friday_sunset": next_friday_data.get("sunset_time"),
"cover_image_path": None
}
# Create events data
events_data = []
# Sabbath School
ss_desc_parts = []
if weekly_schedule.get("S. S. LEADER"): ss_desc_parts.append(f"Leader: {weekly_schedule['S. S. LEADER']}")
if weekly_schedule.get("S. S. TEACHER"): ss_desc_parts.append(f"Teacher: {weekly_schedule['S. S. TEACHER']}")
if weekly_schedule.get("MISSION STORY"): ss_desc_parts.append(f"Mission Story: {weekly_schedule['MISSION STORY']}")
events_data.append({
"title": "Sabbath School",
"description": ", ".join(ss_desc_parts),
"start_time": f"{target_saturday_str} {DEFAULT_SABBATH_SCHOOL_START_TIME}",
"end_time": f"{target_saturday_str} {DEFAULT_SABBATH_SCHOOL_END_TIME}",
"type": "recurring"
})
# Divine Worship
dw_desc_parts = []
if weekly_schedule.get("SERMON SPEAKER"): dw_desc_parts.append(f"Speaker: {weekly_schedule['SERMON SPEAKER']}")
if weekly_schedule.get("Song LEADER"): dw_desc_parts.append(f"Song Leader: {weekly_schedule['Song LEADER']}")
if weekly_schedule.get("SCRIPTURE"):
dw_desc_parts.append(f"Announcements: {weekly_schedule['SCRIPTURE']}")
dw_desc_parts.append(f"Prayer & Praises: {weekly_schedule['SCRIPTURE']}")
dw_desc_parts.append(f"Scripture Reading: {weekly_schedule['SCRIPTURE']} ({parsed_sermon.get('scripture_reading', 'TBA')})")
if weekly_schedule.get("OFFERING"): dw_desc_parts.append(f"Offering: {weekly_schedule['OFFERING']}")
if parsed_sermon.get("opening_song"): dw_desc_parts.append(f"Opening Song: {parsed_sermon['opening_song']}")
if parsed_sermon.get("closing_song"): dw_desc_parts.append(f"Closing Song: {parsed_sermon['closing_song']}")
if weekly_schedule.get("SPECIAL MUSIC"): dw_desc_parts.append(f"Special Music: {weekly_schedule['SPECIAL MUSIC']}")
events_data.append({
"title": "Divine Worship",
"description": ". ".join(dw_desc_parts),
"start_time": f"{target_saturday_str} {DEFAULT_DIVINE_WORSHIP_START_TIME}",
"end_time": f"{target_saturday_str} {DEFAULT_DIVINE_WORSHIP_END_TIME}",
"type": "recurring"
})
# Create in PocketBase
print("\n--- Creating bulletin in PocketBase ---")
if create_bulletin_in_pocketbase(bulletin_data, events_data, weekly_schedule, parsed_sermon):
print("\nDone! Check your PocketBase - bulletin created successfully!")
else:
print("\nFailed to create bulletin.")
if __name__ == "__main__":
main()

352
bulletin-input/src/main.rs Normal file
View file

@ -0,0 +1,352 @@
mod sermon_parser;
use anyhow::{anyhow, Result};
use bulletin_shared::{Config, 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>,
#[arg(short, long, default_value = "springfield")]
location: String,
#[arg(short, long)]
auth_token: Option<String>,
}
fn get_upcoming_saturday(from_date: Option<NaiveDate>) -> NaiveDate {
let today = from_date.unwrap_or_else(|| Local::now().date_naive());
let days_until_saturday = match today.weekday() {
Weekday::Sat => 0,
Weekday::Sun => 6,
Weekday::Mon => 5,
Weekday::Tue => 4,
Weekday::Wed => 3,
Weekday::Thu => 2,
Weekday::Fri => 1,
};
if days_until_saturday == 0 && from_date.is_none() {
today + chrono::Duration::days(7)
} else {
today + chrono::Duration::days(days_until_saturday as i64)
}
}
fn prompt_for_missing_value(field_name: &str, default: &str) -> String {
let theme = ColorfulTheme::default();
let result: String = Input::with_theme(&theme)
.with_prompt(format!("{} not found in CSV. Enter name", field_name))
.default(default.to_string())
.interact()
.unwrap_or_else(|_| default.to_string());
if result.is_empty() {
default.to_string()
} else {
result
}
}
fn format_sabbath_school_section(
song_leader: &str,
ss_leader: &str,
ss_teacher: &str,
mission_story: &str,
) -> String {
format!(
r#"Song Service:<br>
{}<br><br>
Lesson Study:<br>
{}<br><br>
Leadership:<br>
{}<br><br>
Mission Story:<br>
{}<br><br>
Closing Hymn:<br>
{}"#,
song_leader, ss_teacher, ss_leader, mission_story, ss_leader
)
}
fn format_divine_worship_section(
announcements_person: &str,
responsive_reading: &str,
opening_song: &str,
offering_focus: &str,
offering_person: &str,
childrens_story_person: &str,
special_music: &str,
scripture_reading: &str,
scripture_reader: &str,
sermon_title: &str,
speaker: &str,
closing_song: &str,
) -> String {
format!(
r#"Announcements:<br>
{}<br><br>
Call To Worship:<br>
{}<br><br>
Opening Hymn:<br>
{}<br><br>
Prayer & Praises:<br>
{}<br><br>
Prayer Song:<br>
#671 "As We Come To You in Prayer"<br><br>
Offering:<br>
{}<br>
{}<br><br>
Children's Story:<br>
{}<br><br>
Special Music:<br>
{}<br><br>
Scripture Reading:<br>
{}<br>
{}<br><br>
Sermon:<br>
{}<br><br>
{}<br><br>
Closing Hymn:<br>
{}"#,
announcements_person,
responsive_reading,
opening_song,
announcements_person,
offering_focus,
offering_person,
childrens_story_person,
special_music,
scripture_reading,
scripture_reader,
sermon_title,
speaker,
closing_song
)
}
#[tokio::main]
async fn main() -> Result<()> {
// Load environment variables from .env file
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter("info")
.init();
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);
println!("\n--- Enter Sermon Details ---");
println!("Paste sermon details and press Enter twice when done:");
let mut sermon_lines = Vec::new();
let mut empty_line_count = 0;
loop {
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(_) => {
if line.trim().is_empty() {
empty_line_count += 1;
if empty_line_count >= 2 {
break;
}
} else {
empty_line_count = 0;
sermon_lines.push(line.trim_end().to_string());
}
}
Err(_) => break,
}
}
let sermon_text = sermon_lines.join("\n");
if sermon_text.trim().is_empty() {
return Err(anyhow!("No sermon details provided"));
}
let parsed_sermon = sermon_parser::parse_sermon_details(&sermon_text);
println!("\n--- Creating API client ---");
let client = if let Some(token) = args.auth_token.or_else(|| std::env::var("BULLETIN_API_TOKEN").ok()) {
// Use provided token directly
NewApiClient::new().with_auth_token(token)
} else {
// Auto-login with username/password
let username = std::env::var("BULLETIN_API_USERNAME")
.map_err(|_| anyhow!("BULLETIN_API_USERNAME environment variable required"))?;
let password = std::env::var("BULLETIN_API_PASSWORD")
.map_err(|_| anyhow!("BULLETIN_API_PASSWORD environment variable required"))?;
println!("Logging in with username/password...");
NewApiClient::new().login(username, password).await?
};
println!("\nFetching schedule data from API...");
let weekly_schedule = client.get_schedule_data(target_saturday).await?;
println!("Fetching conference data from API...");
let conference_data = client.get_conference_data(target_saturday).await?;
let ss_leader = if weekly_schedule.ss_leader.is_empty() {
prompt_for_missing_value("S.S. Leader", "TBA")
} else {
weekly_schedule.ss_leader.clone()
};
let ss_teacher = if weekly_schedule.ss_teacher.is_empty() {
prompt_for_missing_value("S.S. Teacher", "TBA")
} else {
weekly_schedule.ss_teacher.clone()
};
let mission_story = if weekly_schedule.mission_story.is_empty() {
prompt_for_missing_value("Mission Story person", "TBA")
} else {
weekly_schedule.mission_story.clone()
};
let song_leader = if weekly_schedule.song_leader.is_empty() {
prompt_for_missing_value("Song Leader", "TBA")
} else {
weekly_schedule.song_leader.clone()
};
let announcements_person = if weekly_schedule.announcements.is_empty() {
prompt_for_missing_value("Announcements person", "TBA")
} else {
weekly_schedule.announcements.clone()
};
let offering_person = if weekly_schedule.offering.is_empty() {
prompt_for_missing_value("Offering person", "TBA")
} else {
weekly_schedule.offering.clone()
};
let special_music = if weekly_schedule.special_music.is_empty() {
prompt_for_missing_value("Special Music person", "TBA")
} else {
weekly_schedule.special_music.clone()
};
let speaker = if weekly_schedule.speaker.is_empty() {
prompt_for_missing_value("Sermon Speaker", "TBA")
} else {
weekly_schedule.speaker.clone()
};
let is_pastor_speaking = speaker.to_lowercase().contains("pastor joseph piresson");
let (scripture_reader, childrens_story_person) = if is_pastor_speaking {
println!("Pastor Joseph Piresson is speaking - assigning him scripture reading and children's story");
(speaker.clone(), speaker.clone())
} else {
(announcements_person.clone(), announcements_person.clone())
};
let opening_song = parsed_sermon.get("opening_song")
.cloned()
.unwrap_or_else(|| prompt_for_missing_value("Opening Song", "TBA"));
let closing_song = parsed_sermon.get("closing_song")
.cloned()
.unwrap_or_else(|| prompt_for_missing_value("Closing Song", "TBA"));
let scripture_reading = parsed_sermon.get("scripture_reading")
.cloned()
.unwrap_or_else(|| prompt_for_missing_value("Scripture Reading", "TBA"));
let responsive_reading = parsed_sermon.get("responsive_reading")
.cloned()
.unwrap_or_else(|| prompt_for_missing_value("Responsive Reading", "TBA"));
let sermon_title = parsed_sermon.get("sermon_title")
.cloned()
.unwrap_or_else(|| "Saturday Worship Service".to_string());
// TODO: Move scripture lookup to API when Bible JSON is added to backend
let _scripture_full_text = format!("<p>{}</p><p>{} KJV</p>", scripture_reading, scripture_reading);
let sabbath_school_section = format_sabbath_school_section(
&song_leader,
&ss_leader,
&ss_teacher,
&mission_story,
);
let divine_worship_section = format_divine_worship_section(
&announcements_person,
&responsive_reading,
&opening_song,
&conference_data.offering_focus,
&offering_person,
&childrens_story_person,
&special_music,
&scripture_reading,
&scripture_reader,
&sermon_title,
&speaker,
&closing_song,
);
let _sunset_text = format!(
"Sunset Tonight: {}\n\nSunset Next Friday: {}",
conference_data.sunset_tonight,
conference_data.sunset_next_friday
);
let bulletin = Bulletin {
id: None,
date: target_saturday,
sermon_title: sermon_title.clone(),
sermon_scripture: scripture_reading,
sermon_preacher: speaker,
sermon_opening_hymn: opening_song,
sermon_closing_hymn: closing_song,
sabbath_school_section,
divine_worship_section,
pdf: None,
cover_image: None,
created: None,
updated: 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);
println!("Done! Check your API - bulletin created successfully!");
Ok(())
}

View file

@ -0,0 +1,119 @@
use regex::Regex;
use std::collections::HashMap;
pub fn parse_sermon_details(text: &str) -> HashMap<String, String> {
let mut details = HashMap::new();
// Handle different input formats
let lines: Vec<&str> = text.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
// Check for "Opening Song:", "Scripture:", "Sermon:" format
for line in &lines {
if line.starts_with("Opening Song:") {
details.insert("opening_song".to_string(), line.replace("Opening Song:", "").trim().to_string());
} else if line.starts_with("Scripture:") {
details.insert("scripture_reading".to_string(), line.replace("Scripture:", "").trim().to_string());
} else if line.starts_with("Sermon:") {
details.insert("sermon_title".to_string(), line.replace("Sermon:", "").trim().to_string());
} else if line.starts_with("Closing Song:") {
details.insert("closing_song".to_string(), line.replace("Closing Song:", "").trim().to_string());
} else if line.starts_with("Call to worship:") {
details.insert("responsive_reading".to_string(), line.replace("Call to worship:", "").trim().to_string());
}
}
// If we found data in the above format, return early
if !details.is_empty() {
return details;
}
// Try simple line-by-line parsing if no structured labels found
if !text.contains("Sermon Title:") && !text.contains("Scripture Reading:") && lines.len() >= 3 {
// Simple format: line 1 = speaker, line 2 = title, line 3 = scripture, etc.
if lines.len() > 1 {
details.insert("sermon_title".to_string(), lines[1].to_string());
}
if lines.len() > 2 {
details.insert("scripture_reading".to_string(), lines[2].to_string());
}
if lines.len() > 3 && lines[3].starts_with("Opening:") {
details.insert("opening_song".to_string(), lines[3].replace("Opening:", "").trim().to_string());
}
if lines.len() > 4 && lines[4].starts_with("Closing:") {
details.insert("closing_song".to_string(), lines[4].replace("Closing:", "").trim().to_string());
}
if lines.len() > 5 && lines[5].starts_with("Responsive Reading:") {
details.insert("responsive_reading".to_string(), lines[5].replace("Responsive Reading:", "").trim().to_string());
}
return details;
}
if text.contains("Sermon Title:") {
let title_regex = Regex::new(r#"Sermon Title:\s*["\'""]([^"\'""]+)["\'""]"#).unwrap();
if let Some(captures) = title_regex.captures(text) {
details.insert("sermon_title".to_string(), captures.get(1).unwrap().as_str().trim().to_string());
} else {
let title_regex_no_quotes = Regex::new(r"Sermon Title:\s*([^\n\r]+)").unwrap();
if let Some(captures) = title_regex_no_quotes.captures(text) {
let title = captures.get(1).unwrap().as_str()
.trim()
.trim_matches('"')
.trim_matches('"')
.trim_matches('"')
.to_string();
details.insert("sermon_title".to_string(), title);
}
}
}
if text.contains("Scripture Reading:") {
let scripture_regex = Regex::new(r"Scripture Reading:\s*([^\n\r]+)").unwrap();
if let Some(captures) = scripture_regex.captures(text) {
details.insert("scripture_reading".to_string(), captures.get(1).unwrap().as_str().trim().to_string());
}
}
if text.contains("Opening:") {
let opening_regex = Regex::new(r"Opening:\s*([^\n\r]+)").unwrap();
if let Some(captures) = opening_regex.captures(text) {
let song = captures.get(1).unwrap().as_str().trim();
let formatted_song = if song.contains(" - ") {
let parts: Vec<&str> = song.splitn(2, " - ").collect();
format!("#{} \"{}\"", parts[0].trim(), parts[1].trim())
} else {
song.to_string()
};
details.insert("opening_song".to_string(), formatted_song);
}
}
if text.contains("Closing:") {
let closing_regex = Regex::new(r"Closing:\s*([^\n\r]+)").unwrap();
if let Some(captures) = closing_regex.captures(text) {
let song = captures.get(1).unwrap().as_str().trim();
let formatted_song = if song.contains(" - ") {
let parts: Vec<&str> = song.splitn(2, " - ").collect();
format!("#{} \"{}\"", parts[0].trim(), parts[1].trim())
} else {
song.to_string()
};
details.insert("closing_song".to_string(), formatted_song);
}
}
if text.contains("Responsive Reading:") {
let responsive_regex = Regex::new(r"Responsive Reading:\s*([^\n\r]+)").unwrap();
if let Some(captures) = responsive_regex.captures(text) {
let reading = captures.get(1).unwrap().as_str().trim();
let formatted_reading = if reading.contains(" - ") {
let parts: Vec<&str> = reading.splitn(2, " - ").collect();
format!("Hymnal {} \"{}\"", parts[0].trim(), parts[1].trim())
} else {
reading.to_string()
};
details.insert("responsive_reading".to_string(), formatted_reading);
}
}
details
}

View file

@ -0,0 +1,15 @@
[package]
name = "bulletin-shared"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
chrono.workspace = true
thiserror.workspace = true
anyhow.workspace = true
reqwest.workspace = true
tokio.workspace = true
uuid.workspace = true

View file

@ -0,0 +1,24 @@
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

@ -0,0 +1,7 @@
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

@ -0,0 +1,211 @@
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Bulletin {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub date: NaiveDate,
pub sermon_title: String,
pub sermon_scripture: String,
pub sermon_preacher: String,
pub sermon_opening_hymn: String,
pub sermon_closing_hymn: String,
pub sabbath_school_section: String,
pub divine_worship_section: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pdf: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewApiBulletin {
pub id: String,
pub title: String,
pub date: String,
pub url: Option<String>,
pub cover_image_path: Option<String>,
pub pdf_url: Option<String>,
#[serde(default = "default_bool_true")]
pub is_active: bool,
pub pdf_file: Option<String>,
pub sabbath_school: String,
pub divine_worship: String,
pub scripture_reading: String,
pub sunset: Option<String>,
pub cover_image: Option<String>,
pub pdf_path: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NewApiResponse {
pub success: bool,
pub data: NewApiBulletin,
pub message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NewApiEventsResponse {
pub success: bool,
pub data: Vec<NewApiEvent>,
pub message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewApiEvent {
pub id: String,
pub title: String,
pub description: String,
pub start_time: String,
pub end_time: String,
pub location: String,
pub location_url: Option<String>,
pub image: String,
pub thumbnail: Option<String>,
pub category: String,
pub is_featured: bool,
pub recurring_type: Option<String>,
pub approved_from: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Event {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
pub date: NaiveDate,
pub time: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
pub event_type: EventType,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_datetime: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_datetime: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum EventType {
Announcement,
Event,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Personnel {
pub date: NaiveDate,
pub elder: String,
pub deacon: String,
pub greeters: String,
pub stream_host: String,
pub camera: String,
pub computer: String,
}
#[derive(Debug, Deserialize)]
pub struct OfferingData {
pub date: NaiveDate,
pub offering_focus: String,
pub sunset_time: String,
}
#[derive(Debug, Deserialize)]
pub struct BibleVerse {
pub book: String,
pub chapter: i32,
pub verse: i32,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConferenceDataResponse {
pub success: bool,
pub data: ConferenceData,
pub message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConferenceData {
pub date: String,
pub offering_focus: String,
pub sunset_tonight: String,
pub sunset_next_friday: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ScheduleResponse {
pub success: bool,
pub data: ScheduleData,
pub message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ScheduleData {
pub date: String,
pub personnel: PersonnelData,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PersonnelData {
#[serde(default = "default_empty_string")]
pub ss_leader: String,
#[serde(default = "default_empty_string")]
pub ss_teacher: String,
#[serde(default = "default_empty_string")]
pub mission_story: String,
#[serde(default = "default_empty_string")]
pub song_leader: String,
#[serde(default = "default_empty_string")]
pub announcements: String,
#[serde(default = "default_empty_string")]
pub offering: String,
#[serde(default = "default_empty_string")]
pub special_music: String,
#[serde(default = "default_empty_string")]
pub speaker: String,
}
fn default_empty_string() -> String {
String::new()
}
fn default_bool_true() -> bool {
true
}
#[derive(Debug, Serialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginResponse {
pub success: bool,
pub data: Option<LoginData>,
pub message: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct LoginData {
pub token: String,
}

View file

@ -0,0 +1,306 @@
use crate::models::{NewApiResponse, NewApiBulletin, Bulletin, NewApiEventsResponse, NewApiEvent, Event, EventType, ConferenceDataResponse, ConferenceData, ScheduleResponse, PersonnelData, LoginRequest, LoginResponse};
use anyhow::{anyhow, Result};
use reqwest::Client;
use chrono::{NaiveDate, DateTime, Utc};
pub struct NewApiClient {
client: Client,
base_url: String,
auth_token: Option<String>,
}
impl NewApiClient {
pub fn new() -> Self {
Self {
client: Client::new(),
base_url: "https://api.rockvilletollandsda.church".to_string(),
auth_token: None,
}
}
pub fn with_auth_token(mut self, token: String) -> Self {
self.auth_token = Some(token);
self
}
pub async fn login(mut self, username: String, password: String) -> Result<Self> {
let url = format!("{}/api/auth/login", self.base_url);
let login_request = LoginRequest { username, password };
let response = self.client
.post(&url)
.json(&login_request)
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
return Err(anyhow!("Failed to login: {}", error_text));
}
let login_response: LoginResponse = response.json().await?;
if !login_response.success {
return Err(anyhow!("Login failed: {:?}", login_response.message));
}
let data = login_response.data
.ok_or_else(|| anyhow!("No data received from login"))?;
let token = data.token;
self.auth_token = Some(token);
Ok(self)
}
pub async fn get_current_bulletin(&self) -> Result<NewApiBulletin> {
let url = format!("{}/api/bulletins/next", 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 next bulletin: {}", error_text));
}
let api_response: NewApiResponse = response.json().await?;
if !api_response.success {
return Err(anyhow!("API returned error: {:?}", api_response.message));
}
Ok(api_response.data)
}
pub async fn get_current_bulletin_as_bulletin(&self) -> Result<Bulletin> {
let new_bulletin = self.get_current_bulletin().await?;
Ok(convert_to_bulletin(new_bulletin)?)
}
pub async fn get_upcoming_events(&self) -> Result<Vec<NewApiEvent>> {
let url = format!("{}/api/events/upcoming", 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 upcoming events: {}", error_text));
}
let api_response: NewApiEventsResponse = response.json().await?;
if !api_response.success {
return Err(anyhow!("API returned error: {:?}", api_response.message));
}
Ok(api_response.data)
}
pub async fn get_upcoming_events_as_events(&self) -> Result<Vec<Event>> {
let new_events = self.get_upcoming_events().await?;
Ok(new_events.into_iter().map(convert_to_event).collect::<Result<Vec<_>>>()?)
}
pub async fn create_bulletin(&self, bulletin: &NewApiBulletin) -> Result<NewApiBulletin> {
let url = format!("{}/api/admin/bulletins", self.base_url);
let mut request = self.client.post(&url).json(bulletin);
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request.send().await?;
if !response.status().is_success() {
let error_text = response.text().await?;
return Err(anyhow!("Failed to create bulletin: {}", error_text));
}
let api_response: NewApiResponse = response.json().await?;
if !api_response.success {
return Err(anyhow!("API returned error: {:?}", api_response.message));
}
Ok(api_response.data)
}
pub async fn create_bulletin_from_bulletin(&self, bulletin: &Bulletin) -> Result<NewApiBulletin> {
let conference_data = self.get_conference_data(bulletin.date).await?;
let sunset_text = format!(
"Sunset Tonight: {}\n\nSunset Next Friday: {}",
conference_data.sunset_tonight,
conference_data.sunset_next_friday
);
let new_bulletin = convert_from_bulletin(bulletin.clone(), sunset_text)?;
self.create_bulletin(&new_bulletin).await
}
pub async fn upload_pdf(&self, bulletin_id: &str, pdf_path: &std::path::Path) -> Result<String> {
let url = format!("{}/api/upload/bulletins/{}/pdf", self.base_url, bulletin_id);
let file = tokio::fs::read(pdf_path).await?;
let file_name = pdf_path.file_name()
.ok_or_else(|| anyhow!("Invalid file path"))?
.to_string_lossy()
.to_string();
let part = reqwest::multipart::Part::bytes(file)
.file_name(file_name)
.mime_str("application/pdf")?;
println!("Debug: Sending 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 PDF (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);
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 conference data: {}", error_text));
}
let api_response: ConferenceDataResponse = response.json().await?;
if !api_response.success {
return Err(anyhow!("API returned error: {:?}", api_response.message));
}
Ok(api_response.data)
}
pub async fn get_schedule_data(&self, date: NaiveDate) -> Result<PersonnelData> {
let date_str = date.format("%Y-%m-%d").to_string();
let url = format!("{}/api/schedule?date={}", self.base_url, date_str);
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 schedule data: {}", error_text));
}
let api_response: ScheduleResponse = response.json().await?;
if !api_response.success {
return Err(anyhow!("API returned error: {:?}", api_response.message));
}
Ok(api_response.data.personnel)
}
}
pub fn convert_to_bulletin(new_bulletin: NewApiBulletin) -> Result<Bulletin> {
let date = NaiveDate::parse_from_str(&new_bulletin.date, "%Y-%m-%d")
.map_err(|e| anyhow!("Failed to parse date: {}", e))?;
Ok(Bulletin {
id: Some(new_bulletin.id),
date,
sermon_title: new_bulletin.title,
sermon_scripture: new_bulletin.scripture_reading,
sermon_preacher: "Pastor Joseph Piresson".to_string(), // Default from API response
sermon_opening_hymn: "".to_string(), // Not available in new API
sermon_closing_hymn: "".to_string(), // Not available in new API
sabbath_school_section: new_bulletin.sabbath_school,
divine_worship_section: new_bulletin.divine_worship,
pdf: new_bulletin.pdf_file,
cover_image: new_bulletin.cover_image,
created: None,
updated: None,
})
}
fn convert_to_event(new_event: NewApiEvent) -> Result<Event> {
// Parse the start_time to extract date
let start_datetime = DateTime::parse_from_rfc3339(&new_event.start_time)
.map_err(|e| anyhow!("Failed to parse start_time: {}", e))?;
let date = start_datetime.date_naive();
// Extract time from start_time
let time = start_datetime.format("%I:%M %p").to_string();
// Parse end_time
let end_datetime = DateTime::parse_from_rfc3339(&new_event.end_time)
.map_err(|e| anyhow!("Failed to parse end_time: {}", e))?;
let end_time = Some(end_datetime.format("%I:%M %p").to_string());
// Determine event type based on category
let event_type = match new_event.category.as_str() {
"Service" => EventType::Event,
_ => EventType::Announcement,
};
Ok(Event {
id: Some(new_event.id),
name: new_event.title,
date,
time,
end_time,
description: Some(new_event.description),
location: Some(new_event.location),
event_type,
start_datetime: Some(start_datetime.with_timezone(&Utc)),
end_datetime: Some(end_datetime.with_timezone(&Utc)),
recurring_type: new_event.recurring_type,
created: None,
updated: None,
})
}
fn convert_from_bulletin(bulletin: Bulletin, sunset_text: String) -> Result<NewApiBulletin> {
Ok(NewApiBulletin {
id: bulletin.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
title: bulletin.sermon_title,
date: bulletin.date.format("%Y-%m-%d").to_string(),
url: Some("".to_string()),
cover_image_path: None,
pdf_url: Some("".to_string()),
is_active: true,
pdf_file: bulletin.pdf,
sabbath_school: bulletin.sabbath_school_section,
divine_worship: bulletin.divine_worship_section,
scripture_reading: bulletin.sermon_scripture,
sunset: Some(sunset_text),
cover_image: bulletin.cover_image,
pdf_path: Some("".to_string()),
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
})
}

4
bulletin.txt Normal file
View file

@ -0,0 +1,4 @@
Sermon Title: Don't Hit Send
Scripture Reading: Proverbs 17:9-10
Opening Song: 311 I would be like Jesus
Closing Song: 305 Give me Jesus

View file

@ -0,0 +1,14 @@
[package]
name = "data-upload-tools"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "multipart"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
dotenvy = "0.15"

View file

@ -0,0 +1,224 @@
use anyhow::Result;
use chrono::NaiveDate;
use clap::{Parser, Subcommand};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Upload quarterly schedule from JSON file
Schedule {
/// JSON file containing schedule data
#[arg(short, long)]
file: PathBuf,
},
/// Upload yearly conference data (offering focuses and sunset times)
Conference {
/// JSON file containing conference data
#[arg(short, long)]
file: PathBuf,
},
/// Generate sample JSON files for reference
GenerateSamples,
}
#[derive(Debug, Serialize, Deserialize)]
struct ScheduleEntry {
date: String,
personnel: PersonnelData,
}
#[derive(Debug, Serialize, Deserialize)]
struct PersonnelData {
ss_leader: String,
ss_teacher: String,
mission_story: String,
song_leader: String,
announcements: String,
offering: String,
special_music: String,
speaker: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ConferenceEntry {
date: String,
offering_focus: String,
sunset_tonight: String,
sunset_next_friday: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ScheduleUpload {
schedule: Vec<ScheduleEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ConferenceUpload {
conference_data: Vec<ConferenceEntry>,
}
#[derive(Debug, Deserialize)]
struct ApiResponse {
success: bool,
message: Option<String>,
}
async fn upload_schedule(file_path: &PathBuf) -> Result<()> {
let content = std::fs::read_to_string(file_path)?;
let schedule_data: ScheduleUpload = serde_json::from_str(&content)?;
let auth_token = std::env::var("BULLETIN_API_TOKEN")
.map_err(|_| anyhow::anyhow!("BULLETIN_API_TOKEN not found in environment"))?;
let client = Client::new();
let response = client
.post("https://api.rockvilletollandsda.church/api/admin/schedule")
.header("Authorization", format!("Bearer {}", auth_token))
.json(&schedule_data)
.send()
.await?;
if response.status().is_success() {
let api_response: ApiResponse = response.json().await?;
if api_response.success {
println!("✅ Schedule uploaded successfully!");
println!(" Uploaded {} schedule entries", schedule_data.schedule.len());
} else {
println!("❌ API returned error: {:?}", api_response.message);
}
} else {
let error_text = response.text().await?;
println!("❌ Upload failed: {}", error_text);
}
Ok(())
}
async fn upload_conference_data(file_path: &PathBuf) -> Result<()> {
let content = std::fs::read_to_string(file_path)?;
let conference_data: ConferenceUpload = serde_json::from_str(&content)?;
let auth_token = std::env::var("BULLETIN_API_TOKEN")
.map_err(|_| anyhow::anyhow!("BULLETIN_API_TOKEN not found in environment"))?;
let client = Client::new();
let response = client
.post("https://api.rockvilletollandsda.church/api/admin/conference-data")
.header("Authorization", format!("Bearer {}", auth_token))
.json(&conference_data)
.send()
.await?;
if response.status().is_success() {
let api_response: ApiResponse = response.json().await?;
if api_response.success {
println!("✅ Conference data uploaded successfully!");
println!(" Uploaded {} conference entries", conference_data.conference_data.len());
} else {
println!("❌ API returned error: {:?}", api_response.message);
}
} else {
let error_text = response.text().await?;
println!("❌ Upload failed: {}", error_text);
}
Ok(())
}
fn generate_sample_files() -> Result<()> {
// Generate sample schedule JSON
let sample_schedule = ScheduleUpload {
schedule: vec![
ScheduleEntry {
date: "2025-01-04".to_string(),
personnel: PersonnelData {
ss_leader: "Wayne Tino".to_string(),
ss_teacher: "Orville Castillo".to_string(),
mission_story: "Jerry Travers".to_string(),
song_leader: "Lisa Wroniak".to_string(),
announcements: "Orville Castillo".to_string(),
offering: "Audley Brown".to_string(),
special_music: "Michelle Maitland".to_string(),
speaker: "Pastor Joseph Piresson".to_string(),
}
},
ScheduleEntry {
date: "2025-01-11".to_string(),
personnel: PersonnelData {
ss_leader: "Wayne Tino".to_string(),
ss_teacher: "Orville Castillo".to_string(),
mission_story: "Jerry Travers".to_string(),
song_leader: "Lisa Wroniak".to_string(),
announcements: "Orville Castillo".to_string(),
offering: "Audley Brown".to_string(),
special_music: "Michelle Maitland".to_string(),
speaker: "Guest Speaker".to_string(),
}
}
]
};
let schedule_json = serde_json::to_string_pretty(&sample_schedule)?;
std::fs::write("sample_schedule.json", schedule_json)?;
println!("📄 Generated sample_schedule.json");
// Generate sample conference data JSON
let sample_conference = ConferenceUpload {
conference_data: vec![
ConferenceEntry {
date: "2025-01-04".to_string(),
offering_focus: "Local Church Budget".to_string(),
sunset_tonight: "5:15 pm".to_string(),
sunset_next_friday: "5:16 pm".to_string(),
},
ConferenceEntry {
date: "2025-01-11".to_string(),
offering_focus: "World Budget".to_string(),
sunset_tonight: "5:16 pm".to_string(),
sunset_next_friday: "5:18 pm".to_string(),
}
]
};
let conference_json = serde_json::to_string_pretty(&sample_conference)?;
std::fs::write("sample_conference.json", conference_json)?;
println!("📄 Generated sample_conference.json");
println!("\n📋 Sample files created! Edit them and upload with:");
println!(" cargo run -- schedule --file sample_schedule.json");
println!(" cargo run -- conference --file sample_conference.json");
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
let args = Args::parse();
match args.command {
Commands::Schedule { file } => {
println!("📤 Uploading schedule from: {}", file.display());
upload_schedule(&file).await?;
}
Commands::Conference { file } => {
println!("📤 Uploading conference data from: {}", file.display());
upload_conference_data(&file).await?;
}
Commands::GenerateSamples => {
generate_sample_files()?;
}
}
Ok(())
}

View file

@ -0,0 +1,90 @@
Rhode
Massachusetts Comecticut Island
202 5 Southern New England Conference _ = «| 8
9 2 2 |
Offering Schedule & Sunset Calendar S E eh + & ,8 3 3
Date Offering AA a z ea Z Ss Z 4 cs
January 4 Local Church Budget 4:29 4:33. 4:26 | 4:39 4:37 4:34] 4:30
11 Religious Liberty 4:36 4:41 4:33] 446 4:44 441 | 4:37
18 Local Church Budget 444 449 4:42] 4:54 4:52 4:49] 4:45
25 Conference Budget 4:53 4:57 4:50 | 5:02 5:01 4:57 4:53
February 1 Local Church Budget 5:02 5:06 4:59 | 5:11 5:09 5:06 3:02
8 Adventist Television Ministries Evangelism 5:11 5:15 5:09 | 5:19 5:18 5:15 3:11
15 Local Church Budget 5:20 5:24 5:18 | 5:28 5:27 5:23 | 5:20
22 Conference Budget 5:29 5:33. 5:27 | 5:36 5:35 5:32 5:29
March! Local Church Budget 5:38 3:42) 5:35 | 3:45 5:44 5:40 3:37
8 Adventist World Radio 5:46 5:50 5:44 | 5:53 5:52 5:48 | 5:45
Daylight Savings Begins
15 Local Church Budget 6:54 6:58 6:52 | 7:00 6:59 6:56 | 6:53
22 Conference Budget 7:02 7:06 7:00 | 7:08 7:07 7:04 7:01
29 Local Church Budget 7:10 7:14 7:08 | 7:16 7:15 7:11 | 7:09
April5 Local Church Budget 7:18 7:22 7:16 | 7:23) 7:22) 7:19 | 7:17
12 Hope Channel International, Inc. 7:26 7:29 7:24 | 7:31 7:30 7:26 | 7:24
19 Local Church Budget 7:34 7:37 7:32 | 7:38 7:37 7:34 | 7:32
26 Conference Budget 7:42. 7A5 740 | 7:46 7:45 7:42 | 7:40
May3 Local Church Budget 7:50 7:53, 7:48 | 7:53 7:52 7:49 | 7:47
10 Disaster & Famine Relief 7:58 8:00 7:55 | 8:00 8:00 7:56 | 7:55
17 Local Church Budget 8:05 8:08 8:03 | 8:07 8:07 8:04] 8:02
24 Conference Budget 8:12 8:14 8:10} 8:14 8:13 8:10 8:09
31 Local Church Budget 8:18 8:20 8:16 | 8:20 8:19 8:16] 8:15
June 7 Local Church Budget 8:23, 8:25 8:21 | 8:25 8:24 8:21 8:20
14. Women's Ministries 8:26 8:29 8:24 | 8:28 8:27 8:24 8:23
21 Local Church Budget 8:29 8:31 8:26 | 8:30 8:30 8:26] 8:25
28 Conference Budget 8:29 8:32 8:27 | 8:31 8:30 8:27] 8:26
July 5 Local Church Budget 8:28 8:30 «8:26 | 8:30 «8:29 8:26] 8:25
12. GC Session Offering: Digital Strategy for Mission 8:25 8:27) 8:23 | 8:27 8:26 8:23 8:22
19 Local Church Budget 8:20 8:23) 8:18 | 8:23) 8:22 8:19] 8:17
26 Conference Budget 8:14 8:17) 8:12 | 8:17 8:16 8:13] 8:11
August 2 Local Church Budget 8:06 8:09 8:04] 8:09 8:08 8:05] 8:04
9 Christian Record Services 7:57 = 8:00 7:55 | 8:01 8:00 7:57 | 7:55
16 Local Church Budget TAT 7:50 745 | 7:51 7:50 7:47 | 7:45
23 Conference Budget 7:37 TAO 7:34 | 7:41 7:40 7:37 7:35
30 Local Church Budget 7:25 7:28 7:23 | 7:30 7:29 7:25 | 7:23
September 6 Local Church Budget 71300 7:16 Fl | 7:18 7:17 7:14 TM
13. World Budget (Emphasis: Radio Ministries) 7:01 7:04 6:59 | 7:06 7:05 7:02 6:59
20 Local Church Budget 6:48 6:52 6:46] 6:54 6:53 6:50 | 6:47
27 Conference Budget 6:36 6:40 6:34 | 6:42 6:41 6:38 6:35
October 4 Local Church Budget 6:24 6:28 6:21 | 6:30 6:29 6:26 | 6:23
11 Disaster & Famine Relief (Atlantic Union Conf.) 6:12 6:16 6:10] 6:19 6:18 6:15 6:11
18 Local Church Budget 6:01 6:05 5:58 | 6:08 6:07 6:04] 6:00
25 Conference Budget 5:50 5:54 5:48 | 5:58 5:57 5:54 5:50
November 1 Local Church Budget 5:41 5:45 5:38 | 5:49 5:48 5:44 5:41
Daylight Savings Ends
8 World Budget (Emphasis: Annual Sacrifice for Global Mission) 4:32 437 430] 4:41 4:40 4:36 4:33
15 Local Church Budget 4:26 4:30 4:23 | 4:35 4:33 4:30] 4:26
22 Conference Budget 4:20 4:25 4:18 | 4:30 4:28 4:25 4:21
29 Adventist Community Services (Atlantic Union Conf) 4:17 4:21 4:14 | 4:26 4:25 4:21 | 4:17
December 6 Local Church Budget 4:15 4:20 4:13 | 4:25 4:24 4:20] 4:16
13 Adventist Community Services 4:15 4:20 4:13 | 4:25 4:24 4:21] 4:16
20 Local Church Budget 4:18 4:22 4:15 | 4:28 4:26 4:23] 4:19
27 Conference Budget 4:22 4:27 4:19 | 4:32 4:30 4:27 | 4:23
Living,
eC
STEWAROSHIP) PLANHED GIVING 6 TRUST SERVICES
Thirteenth Sabbath
Offerings for 2025
First Quarter:
Northern Asia-Pacific
Division (NSD)
Southern Asia-
Division (SSD)
Third Quarter:
Southern Africa-Indian
Ocean Division (SID)
Fourth Quarter:
South American Division
(SAD)
f
Seventh-day
Adventist Church

33614
data/KJV.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
SCHEDULE FOR 1st QUARTER 2025,,,,,,,,,,,,
,,,,,,,,,,,,
DATE,Song,S. S.,S. S.,MISSION ,SPECIAL,SERMON,SCRIPTURE,OFFERING,DEACONS,SPECIAL,CHILDREN'S,AFTERNOON
,LEADER,LEADER,TEACHER,STORY,PROGRAM,SPEAKER,,,DEACONESSES,MUSIC,STORY,PROGRAM
4 Jan,Glen,Wayne,Orville,Jerry,,"Gary
Walton","Orville
Castillo","James
Lee","Audley
Elijah",,,
11 Jan,Lisa,Orville,Wayne,James,Communion,Pastor,"Jerry
Travers","Glen
Young","James
Edwardo",,,
18 Jan,James,Lera,Jerry,Gary,,"Joseph
Farah","Frank
Varricchio","Audley
Brown","Tony
Ellsworth",,,
25 Jan,Lynette,Jerry,Gary,Lynette,,Pastor,"Jermaine
Pinnock","Ellsworth
Cross","Glen
Jerry",,,
1 Feb,Glen,Orville,Wayne,Jerry,,"Jermaine
Pinnock","Orville
Castillo","Joe
Jaffat","Lisa
Margin",,,
8 Feb,Lisa,Jerry,Orville,Lera,,Pastor,"Jerry
Travers","Edwardo
Carcache","Frank
Joe",,,
15 Feb,James,Lera,Jerry,James,,"Orville
Castillo","Frank
Varricchio","Tony
Rosa","Jermaine
James",,,
22 Feb,Lynette,Orville,Wayne,Lynette,,Pastor,"Jermaine
Pinnock","Jerry
Travers","Edwardo
Ellsworth",,,
1 Mar,Glen,Wayne,Orville,Lera,,"Jerry
Travers","Orville
Castillo","Glen
Young","Joe
Tony",,,
8 Mar,Lisa,Lera,Jerry,James,,Pastor,"Jerry
Travers","James
Lee","Audley
Elijah",,,
15 Mar,James,Jerry,Wayne,Gary,,"Frank
Torres","Frank
Varricchio","Edwardo
Carcache","Orville
Glen",,,
22 Mar,Lynette,Orville,Gary,Lynette,,Pastor,"Jermaine
Pinnock","Joe
Jaffat","Sam B
Sam G",,,
29 Mar,Nadine,Wayne,Jerry,James,,"Edwardo
Garcia","Orville
Castillo","Ellsworth
Cross","James
Tony",,,
,,,,,,,,,,,,
,,,,,,,,,,,,
,,,,,,,,,,,,
SCHEDULE FOR 2nd QUARTER 2025,,,,,,,,,,,,
,,,,,,,,,,,,
DATE,Song,S. S.,S. S.,MISSION ,SPECIAL,SERMON,SCRIPTURE,OFFERING,DEACONS,SPECIAL,CHILDREN'S,AFTERNOON
,LEADER,TEACHER,LEADER,STORY,PROGRAM,SPEAKER,,,DEACONESSES,MUSIC,STORY,PROGRAM
5 Apr,Glen,Orville,Wayne,Jerry,,"Gary
Walton","Jermaine
Pinnock","Jerry
Travers","Audley
Ellsworth",,,
12 Apr,Lisa,Wayne,Orville,Lera,,Pastor,"Frank
Varricchio","Orville
Castillo","James L
Glenton Y",,,
19 Apr,James,Jerry,Lera,James,,Pr Maitland,"Orville
Castillo","Frank
Varricchio","Tony
Eddie C",,,
26 Apr,Lynette,Gary,Jerry,Lynette,,"Frank
Torres","Jermaine
Pinnock","Glenton
Young","James L
Joe J",,,
3 May,Glen,Orville,Wayne,Jerry,,"Peter
Pulaski","Jerry
Travers","Edwardo
Carcache","Lisa
Sam",,,
10 May,Lisa,Wayne,Orville,Lera,,Pastor,"Frank
Varricchio","Ellsworth
Cross","Audley
Elijah",,,
17 May,James,Jerry,Lera,Wayne,,"Joseph
Farah","Jermaine
Pinnock","James
Lee","Glenton
Tony",,,
24 May,Lynette,Gary,Jerry,Lynette,,"Leslie
Williams","Jerry
Travers","Jermaine
Pinnock","Joe J
Ellsworth",,,
31 May,Nadine,Wayne,Orville,Lera,,"Jerry
Travers","Frank
Varricchio","Joe
Jaffat","Audley
Elijah",,,
7 Jun,Glen,Orville,Wayne,James,,Pastor,"Jermaine
Pinnock","Jerry
Travers","Margin
Linda",,,
14 Jun,Lisa,Wayne,Orville,Jerry,,,"Orville
Castillo",Audley Brown,"James
Eddie",,,
21 Jun,James,Jerry,Lera,Wayne,"Camp
MTG",,"Jerry
Travers",,,,,
28 Jun,Lynette,Gary,Wayne,Lynette,"Camp
MTG",,"Frank
Varricchio",,,,,
,,,,,,,,,,,,
,,,,,,,,,,,,
SCHEDULE FOR 3rd QUARTER 2025,,,,,,,,,,,,
,,,,,,,,,,,,
DATE,Song,S. S.,S. S.,MISSION ,SPECIAL,SERMON,SCRIPTURE,OFFERING,DEACONS,SPECIAL,CHILDREN'S,AFTERNOON
,LEADER,TEACHER,LEADER,STORY,PROGRAM,SPEAKER,,,DEACONESSES,MUSIC,STORY,PROGRAM
5 Jul,Glen,,,,,,,,,,,
12 Jul,Lisa,,,,,,,,,,,
19 Jul,James,,,,,,,,,,,
26 Jul,Lynette,,,,,,,,,,,
2 Aug,Glen,,,,,,,,,,,
9 Aug,Lisa,,,,,,,,,,,
16 Aug,James,,,,,,,,,,,
23 Aug,Lynette,,,,,,,,,,,
30 Aug,Nadine,,,,,,,,,,,
6 Sep,Glen,,,,,,,,,,,
13 Sep,Lisa,,,,,,,,,,,
20 Sep,James,,,,,,,,,,,
27 Sep,Lynette,,,,,,,,,,,
,,,,,,,,,,,,
SCHEDULE FOR 4th QUARTER 2025,,,,,,,,,,,,
,,,,,,,,,,,,
DATE,Song,S. S.,S. S.,MISSION ,SPECIAL,SERMON,SCRIPTURE,OFFERING,DEACONS,SPECIAL,CHILDREN'S,AFTERNOON
,LEADER,TEACHER,LEADER,STORY,PROGRAM,SPEAKER,,,DEACONESSES,MUSIC,STORY,PROGRAM
4 Oct,Glen,,,,,,,,,,,
11 Oct,Lisa,,,,,,,,,,,
18 Oct,James,,,,,,,,,,,
25 Oct,Lynette,,,,,,,,,,,
1 Nov,Glen,,,,,,,,,,,
8 Nov,Lisa,,,,,,,,,,,
15 Nov,James,,,,,,,,,,,
22 Nov,Lynette,,,,,,,,,,,
29 Nov,Nadine,,,,,,,,,,,
6 Dec,Glen,,,,,,,,,,,
13 Dec,Lisa,,,,,,,,,,,
20 Dec,James,,,,,,,,,,,
27 Dec,Lynette,,,,,,,,,,,
1 SCHEDULE FOR 1st QUARTER 2025
2
3 DATE Song S. S. S. S. MISSION SPECIAL SERMON SCRIPTURE OFFERING DEACONS SPECIAL CHILDREN'S AFTERNOON
4 LEADER LEADER TEACHER STORY PROGRAM SPEAKER DEACONESSES MUSIC STORY PROGRAM
5 4 Jan Glen Wayne Orville Jerry Gary Walton Orville Castillo James Lee Audley Elijah
6 11 Jan Lisa Orville Wayne James Communion Pastor Jerry Travers Glen Young James Edwardo
7 18 Jan James Lera Jerry Gary Joseph Farah Frank Varricchio Audley Brown Tony Ellsworth
8 25 Jan Lynette Jerry Gary Lynette Pastor Jermaine Pinnock Ellsworth Cross Glen Jerry
9 1 Feb Glen Orville Wayne Jerry Jermaine Pinnock Orville Castillo Joe Jaffat Lisa Margin
10 8 Feb Lisa Jerry Orville Lera Pastor Jerry Travers Edwardo Carcache Frank Joe
11 15 Feb James Lera Jerry James Orville Castillo Frank Varricchio Tony Rosa Jermaine James
12 22 Feb Lynette Orville Wayne Lynette Pastor Jermaine Pinnock Jerry Travers Edwardo Ellsworth
13 1 Mar Glen Wayne Orville Lera Jerry Travers Orville Castillo Glen Young Joe Tony
14 8 Mar Lisa Lera Jerry James Pastor Jerry Travers James Lee Audley Elijah
15 15 Mar James Jerry Wayne Gary Frank Torres Frank Varricchio Edwardo Carcache Orville Glen
16 22 Mar Lynette Orville Gary Lynette Pastor Jermaine Pinnock Joe Jaffat Sam B Sam G
17 29 Mar Nadine Wayne Jerry James Edwardo Garcia Orville Castillo Ellsworth Cross James Tony
18
19
20
21 SCHEDULE FOR 2nd QUARTER 2025
22
23 DATE Song S. S. S. S. MISSION SPECIAL SERMON SCRIPTURE OFFERING DEACONS SPECIAL CHILDREN'S AFTERNOON
24 LEADER TEACHER LEADER STORY PROGRAM SPEAKER DEACONESSES MUSIC STORY PROGRAM
25 5 Apr Glen Orville Wayne Jerry Gary Walton Jermaine Pinnock Jerry Travers Audley Ellsworth
26 12 Apr Lisa Wayne Orville Lera Pastor Frank Varricchio Orville Castillo James L Glenton Y
27 19 Apr James Jerry Lera James Pr Maitland Orville Castillo Frank Varricchio Tony Eddie C
28 26 Apr Lynette Gary Jerry Lynette Frank Torres Jermaine Pinnock Glenton Young James L Joe J
29 3 May Glen Orville Wayne Jerry Peter Pulaski Jerry Travers Edwardo Carcache Lisa Sam
30 10 May Lisa Wayne Orville Lera Pastor Frank Varricchio Ellsworth Cross Audley Elijah
31 17 May James Jerry Lera Wayne Joseph Farah Jermaine Pinnock James Lee Glenton Tony
32 24 May Lynette Gary Jerry Lynette Leslie Williams Jerry Travers Jermaine Pinnock Joe J Ellsworth
33 31 May Nadine Wayne Orville Lera Jerry Travers Frank Varricchio Joe Jaffat Audley Elijah
34 7 Jun Glen Orville Wayne James Pastor Jermaine Pinnock Jerry Travers Margin Linda
35 14 Jun Lisa Wayne Orville Jerry Orville Castillo Audley Brown James Eddie
36 21 Jun James Jerry Lera Wayne Camp MTG Jerry Travers
37 28 Jun Lynette Gary Wayne Lynette Camp MTG Frank Varricchio
38
39
40 SCHEDULE FOR 3rd QUARTER 2025
41
42 DATE Song S. S. S. S. MISSION SPECIAL SERMON SCRIPTURE OFFERING DEACONS SPECIAL CHILDREN'S AFTERNOON
43 LEADER TEACHER LEADER STORY PROGRAM SPEAKER DEACONESSES MUSIC STORY PROGRAM
44 5 Jul Glen
45 12 Jul Lisa
46 19 Jul James
47 26 Jul Lynette
48 2 Aug Glen
49 9 Aug Lisa
50 16 Aug James
51 23 Aug Lynette
52 30 Aug Nadine
53 6 Sep Glen
54 13 Sep Lisa
55 20 Sep James
56 27 Sep Lynette
57
58 SCHEDULE FOR 4th QUARTER 2025
59
60 DATE Song S. S. S. S. MISSION SPECIAL SERMON SCRIPTURE OFFERING DEACONS SPECIAL CHILDREN'S AFTERNOON
61 LEADER TEACHER LEADER STORY PROGRAM SPEAKER DEACONESSES MUSIC STORY PROGRAM
62 4 Oct Glen
63 11 Oct Lisa
64 18 Oct James
65 25 Oct Lynette
66 1 Nov Glen
67 8 Nov Lisa
68 15 Nov James
69 22 Nov Lynette
70 29 Nov Nadine
71 6 Dec Glen
72 13 Dec Lisa
73 20 Dec James
74 27 Dec Lynette

6
shared/config.toml Normal file
View file

@ -0,0 +1,6 @@
# 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"