mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Merge branch 'main' into auth
Brought in latest changes from main including: - RSS feed implementation - First letter edge case fixes - Updated ranking formula Resolved conflicts by: - Combining .env.example variables from both branches - Keeping auth version (3.0.0alpha) - Preserving extended user schema from auth - Keeping onMount umami approach and adding RSS feed link Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@
|
|||||||
"Read(./secrets/**)",
|
"Read(./secrets/**)",
|
||||||
"Read(./config/credentials.json)",
|
"Read(./config/credentials.json)",
|
||||||
"Read(./build)",
|
"Read(./build)",
|
||||||
"Read(./**.xml)",
|
|
||||||
"Read(./embeddings**)"
|
"Read(./embeddings**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
14
.env.example
14
.env.example
@@ -1,5 +1,19 @@
|
|||||||
DATABASE_URL=example.db
|
DATABASE_URL=example.db
|
||||||
|
|
||||||
|
PUBLIC_SITE_URL=https://bibdle.com
|
||||||
|
|
||||||
|
# nodemailer
|
||||||
|
SMTP_USERNAME=email@example.com
|
||||||
|
SMTP_TOKEN=TOKEN
|
||||||
|
SMTP_SERVER=smtp.example.com
|
||||||
|
SMTP_PORT=port
|
||||||
|
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
|
||||||
|
|
||||||
|
# sign in with Discord
|
||||||
|
|
||||||
|
# sign in with google
|
||||||
|
|
||||||
|
# sign in with apple
|
||||||
AUTH_SECRET=your-random-secret-here
|
AUTH_SECRET=your-random-secret-here
|
||||||
APPLE_ID=com.yourcompany.yourapp.client
|
APPLE_ID=com.yourcompany.yourapp.client
|
||||||
APPLE_TEAM_ID=your-team-id
|
APPLE_TEAM_ID=your-team-id
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,4 +28,5 @@ vite.config.ts.timestamp-*
|
|||||||
llms-*
|
llms-*
|
||||||
|
|
||||||
embeddings*
|
embeddings*
|
||||||
*.xml
|
*bible.xml
|
||||||
|
engwebu_usfx.xml
|
||||||
|
|||||||
@@ -24143,7 +24143,7 @@
|
|||||||
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
||||||
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
||||||
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
||||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
|
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.”</verse>
|
||||||
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
||||||
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
||||||
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
||||||
|
|||||||
31
analyze_top_users.sh
Executable file
31
analyze_top_users.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# analyze_top_users.sh
|
||||||
|
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
|
||||||
|
|
||||||
|
# Set database path from argument or default to dev.db
|
||||||
|
DB_PATH="${1:-dev.db}"
|
||||||
|
|
||||||
|
# Check if database file exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database file not found: $DB_PATH"
|
||||||
|
echo "Usage: $0 [database_path]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the analysis query
|
||||||
|
sqlite3 "$DB_PATH" <<EOF
|
||||||
|
.mode column
|
||||||
|
.headers on
|
||||||
|
.width 36 16 16 17
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
anonymous_id,
|
||||||
|
COUNT(*) as completion_count,
|
||||||
|
MIN(date) as first_completion,
|
||||||
|
MAX(date) as latest_completion
|
||||||
|
FROM daily_completions
|
||||||
|
GROUP BY anonymous_id
|
||||||
|
ORDER BY completion_count DESC
|
||||||
|
LIMIT 10;
|
||||||
|
EOF
|
||||||
20
clear-today-verse.sh
Executable file
20
clear-today-verse.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
# Clear today's verse from daily_verses table
|
||||||
|
DB_PATH="dev.db"
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
echo "Deleting verse for date: $TODAY"
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ Successfully deleted verse for $TODAY"
|
||||||
|
|
||||||
|
# Show remaining verses in table
|
||||||
|
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
|
||||||
|
echo "Remaining verses in database: $COUNT"
|
||||||
|
else
|
||||||
|
echo "✗ Failed to delete verse"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
34
daily_completions_report.sh
Executable file
34
daily_completions_report.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
DB_PATH="./local.db"
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database not found at $DB_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Query for daily completions on 2026-02-01 with ranking
|
||||||
|
echo "Daily Completions for 2026-02-01"
|
||||||
|
echo "================================="
|
||||||
|
echo ""
|
||||||
|
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
|
||||||
|
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
|
||||||
|
|
||||||
|
# Execute query with custom column mode
|
||||||
|
sqlite3 "$DB_PATH" <<SQL
|
||||||
|
.mode column
|
||||||
|
.headers off
|
||||||
|
.width 12 10 6
|
||||||
|
SELECT
|
||||||
|
SUBSTR(anonymous_id, 1, 10) as anon_id,
|
||||||
|
guess_count,
|
||||||
|
RANK() OVER (ORDER BY guess_count ASC) as rank
|
||||||
|
FROM daily_completions
|
||||||
|
WHERE date = '2026-02-01'
|
||||||
|
ORDER BY rank, guess_count;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Total entries:"
|
||||||
|
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"
|
||||||
@@ -28,6 +28,11 @@
|
|||||||
return "bg-red-500 border-red-600";
|
return "bg-red-500 border-red-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFirstLetter(bookName: string): string {
|
||||||
|
const match = bookName.match(/[a-zA-Z]/);
|
||||||
|
return match ? match[0] : bookName[0];
|
||||||
|
}
|
||||||
|
|
||||||
function getBoxContent(
|
function getBoxContent(
|
||||||
guess: Guess,
|
guess: Guess,
|
||||||
column: "book" | "firstLetter" | "testament" | "section",
|
column: "book" | "firstLetter" | "testament" | "section",
|
||||||
@@ -44,16 +49,27 @@
|
|||||||
(correctBook?.section === "Pauline Epistles" ||
|
(correctBook?.section === "Pauline Epistles" ||
|
||||||
correctBook?.section === "General Epistles") &&
|
correctBook?.section === "General Epistles") &&
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessStartsWithNumber = guess.book.name[0] === "1";
|
const guessIsEpistlesWithNumber =
|
||||||
|
(guess.book.section === "Pauline Epistles" ||
|
||||||
|
guess.book.section === "General Epistles") &&
|
||||||
|
guess.book.name[0] === "1";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
correctIsEpistlesWithNumber &&
|
correctIsEpistlesWithNumber &&
|
||||||
guessStartsWithNumber &&
|
guessIsEpistlesWithNumber &&
|
||||||
guess.firstLetterMatch
|
guess.firstLetterMatch
|
||||||
) {
|
) {
|
||||||
return "Yes"; // Special wordplay case
|
const words = [
|
||||||
|
"Exactly",
|
||||||
|
"Right",
|
||||||
|
"Yes",
|
||||||
|
"Naturally",
|
||||||
|
"Of course",
|
||||||
|
"Sure",
|
||||||
|
];
|
||||||
|
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||||
}
|
}
|
||||||
return guess.book.name[0]; // Normal case: just show the first letter
|
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||||
case "testament":
|
case "testament":
|
||||||
return (
|
return (
|
||||||
guess.book.testament.charAt(0).toUpperCase() +
|
guess.book.testament.charAt(0).toUpperCase() +
|
||||||
|
|||||||
@@ -76,7 +76,12 @@
|
|||||||
if (/^[a-z]/.test(formatted)) {
|
if (/^[a-z]/.test(formatted)) {
|
||||||
formatted = "..." + formatted;
|
formatted = "..." + formatted;
|
||||||
}
|
}
|
||||||
formatted = formatted.replace(/[,:;-—]$/, "...");
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(
|
||||||
|
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||||
|
"...$1",
|
||||||
|
);
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -136,11 +141,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instructions {
|
/*.instructions {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
.verses {
|
.verses {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export const user = sqliteTable('user', {
|
export const user = sqliteTable('user', {
|
||||||
|
|||||||
@@ -21,11 +21,6 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<!-- <script
|
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||||
defer
|
|
||||||
src="https://umami.snail.city/script.js"
|
|
||||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
|
||||||
data-domains="bibdle.com,www.bibdle.com"
|
|
||||||
></script> -->
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -93,13 +93,20 @@ export const actions: Actions = {
|
|||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
const guessRank = betterGuesses + 1;
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|
||||||
// Average guesses
|
// Average guesses
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
|
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||||
|
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||||
|
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -87,6 +87,11 @@
|
|||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFirstLetter(bookName: string): string {
|
||||||
|
const match = bookName.match(/[a-zA-Z]/);
|
||||||
|
return match ? match[0] : bookName[0];
|
||||||
|
}
|
||||||
|
|
||||||
function submitGuess(bookId: string) {
|
function submitGuess(bookId: string) {
|
||||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
if (guesses.some((g) => g.book.id === bookId)) return;
|
||||||
|
|
||||||
@@ -103,15 +108,19 @@
|
|||||||
// Special case: if correct book is in the Epistles + starts with "1",
|
// Special case: if correct book is in the Epistles + starts with "1",
|
||||||
// any guess starting with "1" counts as first letter match
|
// any guess starting with "1" counts as first letter match
|
||||||
const correctIsEpistlesWithNumber =
|
const correctIsEpistlesWithNumber =
|
||||||
correctBook.section === "Pauline Epistles" &&
|
(correctBook.section === "Pauline Epistles" ||
|
||||||
|
correctBook.section === "General Epistles") &&
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessStartsWithNumber = book.name[0] === "1";
|
const guessIsEpistlesWithNumber =
|
||||||
|
(book.section === "Pauline Epistles" ||
|
||||||
|
book.section === "General Epistles") &&
|
||||||
|
book.name[0] === "1";
|
||||||
|
|
||||||
const firstLetterMatch =
|
const firstLetterMatch =
|
||||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||||
? true
|
? true
|
||||||
: book.name[0].toUpperCase() ===
|
: getFirstLetter(book.name).toUpperCase() ===
|
||||||
correctBook.name[0].toUpperCase();
|
getFirstLetter(correctBook.name).toUpperCase();
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
||||||
@@ -215,15 +224,19 @@
|
|||||||
|
|
||||||
// Apply same first letter logic as in submitGuess
|
// Apply same first letter logic as in submitGuess
|
||||||
const correctIsEpistlesWithNumber =
|
const correctIsEpistlesWithNumber =
|
||||||
correctBook.section === "Pauline Epistles" &&
|
(correctBook.section === "Pauline Epistles" ||
|
||||||
|
correctBook.section === "General Epistles") &&
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessStartsWithNumber = book.name[0] === "1";
|
const guessIsEpistlesWithNumber =
|
||||||
|
(book.section === "Pauline Epistles" ||
|
||||||
|
book.section === "General Epistles") &&
|
||||||
|
book.name[0] === "1";
|
||||||
|
|
||||||
const firstLetterMatch =
|
const firstLetterMatch =
|
||||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||||
? true
|
? true
|
||||||
: book.name[0].toUpperCase() ===
|
: getFirstLetter(book.name).toUpperCase() ===
|
||||||
correctBook.name[0].toUpperCase();
|
getFirstLetter(correctBook.name).toUpperCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book,
|
book,
|
||||||
@@ -433,7 +446,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
|
||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
<!-- <meta
|
<!-- <meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -44,11 +44,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
// Solve rank: position in time-ordered list
|
// Solve rank: position in time-ordered list
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||||
const uniqueBetterGuessCounts = new Set(
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
const guessRank = betterGuesses + 1;
|
||||||
);
|
|
||||||
const guessRank = uniqueBetterGuessCounts.size + 1;
|
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
@@ -110,11 +108,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
// Solve rank: position in time-ordered list
|
// Solve rank: position in time-ordered list
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||||
const uniqueBetterGuessCounts = new Set(
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
const guessRank = betterGuesses + 1;
|
||||||
);
|
|
||||||
const guessRank = uniqueBetterGuessCounts.size + 1;
|
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|||||||
145
src/routes/feed.xml/+server.ts
Normal file
145
src/routes/feed.xml/+server.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyVerses } from '$lib/server/db/schema';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Helper: Escape XML special characters
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format YYYY-MM-DD to RFC 822 date string
|
||||||
|
function formatRFC822(dateStr: string): string {
|
||||||
|
// Parse date in America/New_York timezone (EST/EDT)
|
||||||
|
// Assuming midnight ET
|
||||||
|
const date = new Date(dateStr + 'T00:00:00-05:00');
|
||||||
|
return date.toUTCString().replace('GMT', 'EST');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format YYYY-MM-DD to readable date
|
||||||
|
function formatReadableDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'America/New_York'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
||||||
|
function formatVerseText(text: string): string {
|
||||||
|
let formatted = text;
|
||||||
|
|
||||||
|
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
||||||
|
const pairs: [string, string][] = [
|
||||||
|
['(', ')'],
|
||||||
|
['[', ']'],
|
||||||
|
['{', '}'],
|
||||||
|
['"', '"'],
|
||||||
|
["'", "'"],
|
||||||
|
['\u201C', '\u201D'], // " "
|
||||||
|
['\u2018', '\u2019'] // ' '
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if text starts with opening punctuation without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
|
formatted += '...' + close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text ends with closing punctuation without opening
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
|
formatted = open + '...' + formatted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text contains unbalanced opening quotes (not at start) without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
||||||
|
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
||||||
|
if (openCount > closeCount) {
|
||||||
|
formatted += close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
||||||
|
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
// Query last 30 verses, ordered by date descending
|
||||||
|
const verses = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyVerses)
|
||||||
|
.orderBy(desc(dailyVerses.date))
|
||||||
|
.limit(30);
|
||||||
|
|
||||||
|
// Generate ETag based on latest verse date
|
||||||
|
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
|
||||||
|
|
||||||
|
// Check if client has cached version
|
||||||
|
if (request.headers.get('If-None-Match') === etag) {
|
||||||
|
return new Response(null, { status: 304 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get site URL from environment or use default
|
||||||
|
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
||||||
|
|
||||||
|
// Build RSS XML
|
||||||
|
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
||||||
|
|
||||||
|
const items = verses
|
||||||
|
.map(
|
||||||
|
(verse) => `
|
||||||
|
<item>
|
||||||
|
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
||||||
|
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
|
||||||
|
<link>${SITE_URL}</link>
|
||||||
|
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
||||||
|
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
||||||
|
</item>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Bibdle</title>
|
||||||
|
<link>${SITE_URL}</link>
|
||||||
|
<description>A daily Bible game</description>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||||
|
<ttl>720</ttl>${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||||
|
ETag: etag
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('RSS feed generation error:', error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user