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:
commit
1eb1fc9909
3
.env.example
Normal file
3
.env.example
Normal 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
27
.gitignore
vendored
Normal 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
28
Cargo.toml
Normal 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
109
README.md
Normal 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
31
SERMON_FORMAT.md
Normal 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
20
autoprintbulletin.sh
Normal 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
|
28
bulletin-generator/Cargo.toml
Normal file
28
bulletin-generator/Cargo.toml
Normal 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
755
bulletin-generator/main.py
Normal 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
|
||||
|
||||
# --- 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 ."""
|
||||
if not text:
|
||||
return ""
|
||||
# 1. Remove HTML tags
|
||||
text_no_tags = re.sub(r'<[^>]+>', '', text)
|
||||
# 2. Unescape HTML entities (e.g., -> \xa0, & -> &)
|
||||
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)
|
247
bulletin-generator/src/html_parser.rs
Normal file
247
bulletin-generator/src/html_parser.rs
Normal 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
|
||||
}
|
206
bulletin-generator/src/main.rs
Normal file
206
bulletin-generator/src/main.rs
Normal 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(())
|
||||
}
|
67
bulletin-generator/src/pdf_generator.rs
Normal file
67
bulletin-generator/src/pdf_generator.rs
Normal 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(())
|
||||
}
|
355
bulletin-generator/src/template_renderer.rs
Normal file
355
bulletin-generator/src/template_renderer.rs
Normal 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)
|
||||
}
|
122
bulletin-generator/templates/bulletin_template.html
Normal file
122
bulletin-generator/templates/bulletin_template.html
Normal 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>
|
526
bulletin-generator/templates/style.css
Normal file
526
bulletin-generator/templates/style.css
Normal 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
24
bulletin-input/Cargo.toml
Normal 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
|
674
bulletin-input/all_in_one_bulletin.py
Normal file
674
bulletin-input/all_in_one_bulletin.py
Normal 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
352
bulletin-input/src/main.rs
Normal 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(())
|
||||
}
|
119
bulletin-input/src/sermon_parser.rs
Normal file
119
bulletin-input/src/sermon_parser.rs
Normal 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
|
||||
}
|
15
bulletin-shared/Cargo.toml
Normal file
15
bulletin-shared/Cargo.toml
Normal 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
|
24
bulletin-shared/src/config.rs
Normal file
24
bulletin-shared/src/config.rs
Normal 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)
|
||||
}
|
||||
}
|
7
bulletin-shared/src/lib.rs
Normal file
7
bulletin-shared/src/lib.rs
Normal 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;
|
211
bulletin-shared/src/models.rs
Normal file
211
bulletin-shared/src/models.rs
Normal 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,
|
||||
}
|
306
bulletin-shared/src/new_api.rs
Normal file
306
bulletin-shared/src/new_api.rs
Normal 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
4
bulletin.txt
Normal 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
|
14
data-upload-tools/Cargo.toml
Normal file
14
data-upload-tools/Cargo.toml
Normal 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"
|
224
data-upload-tools/src/main.rs
Normal file
224
data-upload-tools/src/main.rs
Normal 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(())
|
||||
}
|
90
data/2025 Offering and Sunset Times Chart.txt
Normal file
90
data/2025 Offering and Sunset Times Chart.txt
Normal 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
33614
data/KJV.json
Normal file
File diff suppressed because it is too large
Load diff
162
data/Quarterly schedule2021 - 2025.csv
Normal file
162
data/Quarterly schedule2021 - 2025.csv
Normal 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,,,,,,,,,,,
|
|
6
shared/config.toml
Normal file
6
shared/config.toml
Normal 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"
|
Loading…
Reference in a new issue