diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 084a540ed..0794a2a10 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -69,38 +69,115 @@ jobs: env: OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} run: | - if [ -z "$OCS_PATREON_TOKEN" ]; then - echo '[]' > dist/supporters.json; exit 0 - fi - CID=$(curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \ - "https://www.patreon.com/api/oauth2/v2/campaigns" \ - | jq -r '.data[0].id // empty' || true) - if [ -z "$CID" ]; then - echo '[]' > dist/supporters.json; exit 0 - fi - curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \ - "https://www.patreon.com/api/oauth2/v2/campaigns/$CID/members?include=pledge_history&fields%5Bmember%5D=full_name,patron_status,currently_entitled_amount_cents&fields%5Bpledge-event%5D=amount_cents,date,payment_status&page%5Bcount%5D=200" \ - | jq -c ' - (.included // [] - | map(select(.type == "pledge-event")) - | map({key: .id, value: .attributes}) - | from_entries) as $events - | [.data[] - | select(.attributes.patron_status == "active_patron") - | . as $member - | ([($member.relationships.pledge_history.data // [])[] - | $events[.id] - | select(.payment_status == "Paid") - | select(.amount_cents > 0)] - | sort_by(.date) - | last) as $last_paid - | {name: ((.attributes.full_name // "") | gsub("^\\s+|\\s+$"; "")), - cents: ($last_paid.amount_cents // .attributes.currently_entitled_amount_cents)} - | select(.name != "") - | select(.cents > 0)] - | sort_by(-.cents)' \ - > dist/supporters.json || echo '[]' > dist/supporters.json - echo "supporters: $(jq 'length' dist/supporters.json)" + python3 - <<'PY' + import json + import os + import urllib.parse + import urllib.request + from datetime import datetime, timedelta, timezone + from decimal import Decimal, ROUND_HALF_UP + from pathlib import Path + + output = Path("dist/supporters.json") + token = os.environ.get("OCS_PATREON_TOKEN", "") + if not token: + output.write_text("[]\n", encoding="utf-8") + raise SystemExit(0) + + headers = { + "Authorization": f"Bearer {token}", + "User-Agent": "OpenCADStudio-supporters", + } + + def fetch_json(url, authenticated=True): + request = urllib.request.Request( + url, + headers=headers if authenticated else {"User-Agent": headers["User-Agent"]}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + campaigns = fetch_json("https://www.patreon.com/api/oauth2/v2/campaigns") + campaign_id = campaigns["data"][0]["id"] + params = urllib.parse.urlencode({ + "include": "pledge_history", + "fields[member]": "full_name", + "fields[pledge-event]": "amount_cents,currency_code,date,payment_status", + "page[count]": "200", + }) + url = ( + f"https://www.patreon.com/api/oauth2/v2/campaigns/" + f"{campaign_id}/members?{params}" + ) + + cutoff_date = (datetime.now(timezone.utc) - timedelta(days=31)).date() + payments = [] + for _ in range(50): + page = fetch_json(url) + included = { + (item.get("type"), item.get("id")): item.get("attributes", {}) + for item in page.get("included", []) + if item.get("type") == "pledge-event" + } + for member in page.get("data", []): + latest = None + history = ( + member.get("relationships", {}) + .get("pledge_history", {}) + .get("data", []) + ) + for relationship in history: + event = included.get((relationship.get("type"), relationship.get("id"))) + if not event or event.get("payment_status") != "Paid": + continue + cents = int(event.get("amount_cents") or 0) + currency = str(event.get("currency_code") or "").strip().upper() + date_text = str(event.get("date") or "") + try: + paid_at = datetime.fromisoformat(date_text.replace("Z", "+00:00")) + except ValueError: + continue + if cents <= 0 or not currency or paid_at.date() < cutoff_date: + continue + if latest is None or paid_at > latest[0]: + latest = (paid_at, cents, currency) + name = str(member.get("attributes", {}).get("full_name") or "").strip() + if latest is not None and name: + payments.append((name, latest[1], latest[2])) + url = page.get("links", {}).get("next") + if not url: + break + + rates = {"USD": Decimal("1")} + if any(currency != "USD" for _, _, currency in payments): + rate_entries = fetch_json( + "https://api.frankfurter.dev/v2/rates?base=USD", + authenticated=False, + ) + rates.update({ + str(entry["quote"]).upper(): Decimal(str(entry["rate"])) + for entry in rate_entries + if Decimal(str(entry.get("rate", 0))) > 0 + }) + + supporters = [] + for name, cents, currency in payments: + rate = rates.get(currency) + if rate is None: + continue + usd_cents = int( + (Decimal(cents) / rate).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + ) + if usd_cents > 0: + supporters.append({"name": name, "cents": usd_cents}) + + supporters.sort(key=lambda item: (-item["cents"], item["name"])) + output.write_text( + json.dumps(supporters, ensure_ascii=False, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + print(f"supporters: {len(supporters)}") + PY # Browsers cannot reliably fetch YouTube playlist/oEmbed responses because # of CORS. Build a same-origin listing and thumbnail directory for the diff --git a/src/app/mod.rs b/src/app/mod.rs index 32c88ecaa..6ea74c4a9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -277,8 +277,8 @@ pub(super) struct OpenCADStudio { /// mid-edit; applied on Enter). Kept in sync when the +/- buttons change it. recent_limit_input: String, command_line: CommandLine, - /// Paying Patreon supporters shown on the Start page (name, pledge cents), - /// fetched once at boot, highest pledge first. + /// Recent Patreon supporters shown on the Start page (name, USD cents), + /// fetched once at boot, highest payment first. patrons: Vec<(String, i64)>, /// Tutorial-playlist videos for the Start page: seeded from the on-disk /// cache at boot, refreshed by a live playlist fetch. @@ -2287,7 +2287,7 @@ pub enum Message { PluginRegistryErrorDetailsToggle, /// Copy registry URL, platform, version, and raw error details. PluginRegistryCopyDiagnostics, - /// Patreon supporters fetched at boot for the Start page (name, pledge cents). + /// Patreon supporters fetched at boot for the Start page (name, USD cents). PatronsFetched(Result, String>), /// Tutorial-playlist videos fetched at boot for the Start page. VideosFetched(Result, String>), diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index fdefd0e8f..9ee4d73d6 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2744,8 +2744,8 @@ fn start_page_content<'a>( }) .width(Fill); for (name, cents) in patrons { - // Cents → the campaign currency's main unit. Symbol assumed "$"; - // adjust if the campaign bills in another currency. + // Patreon payments are normalized to USD cents while the list is + // generated; hand-maintained entries use USD cents as well. let amount = format!("${:.2}", *cents as f64 / 100.0); list = list.push( iced::widget::row![ diff --git a/src/patreon.rs b/src/patreon.rs index 1fa926c7c..f92b0aced 100644 --- a/src/patreon.rs +++ b/src/patreon.rs @@ -7,13 +7,20 @@ //! can still be extracted from a shipped binary, so it should be a //! campaign-scoped token with the minimum needed access. +#[cfg(not(target_arch = "wasm32"))] +const SUPPORT_WINDOW_DAYS: u64 = 31; + +#[cfg(not(target_arch = "wasm32"))] +const USD_RATES_URL: &str = "https://api.frankfurter.dev/v2/rates?base=USD"; + /// Supporters who donated outside Patreon (direct transfer, crypto, one-off /// gifts, …), maintained by hand here. They are merged with the fetched patrons /// and ranked together by amount, so the combined Start-page list is ordered by /// pledge regardless of where the donation came from. /// -/// To add a supporter, add a `("Display name", cents)` line below. The amount is -/// in **cents**: a $25 donation is `2500`, €10 is `1000`. +/// To add a supporter, add a `("Display name", usd_cents)` line below. The +/// amount is in **USD cents**: a $25 donation is `2500`; convert other +/// currencies to USD before adding them. const MANUAL_SUPPORTERS: &[(&str, i64)] = &[ ("Stefano", 8750), // $87.50 ]; @@ -38,9 +45,9 @@ pub fn merge_manual(mut patrons: Vec<(String, i64)>) -> Vec<(String, i64)> { #[cfg(not(target_arch = "wasm32"))] const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")); -/// Fetch the paying patrons from the Patreon API as `(display name, amount in -/// cents)`, highest pledge first. `Err` when no token is configured or the API -/// call fails. +/// Fetch patrons with a successful payment in the last month from the Patreon +/// API as `(display name, amount in USD cents)`, highest payment first. `Err` +/// when no token is configured or an API call fails. #[cfg(not(target_arch = "wasm32"))] pub fn fetch_patrons() -> Result, String> { let token = option_env!("OCS_PATREON_TOKEN") @@ -55,14 +62,17 @@ pub fn fetch_patrons() -> Result, String> { .as_str() .ok_or("no Patreon campaign found for this token")? .to_string(); + let usd_rates = fetch_usd_rates(&agent)?; + let cutoff_date = utc_date_days_ago(SUPPORT_WINDOW_DAYS); - // Page through the campaign members, keeping only paying patrons. + // Page through the campaign members, keeping only patrons whose latest + // successful payment falls inside the rolling one-month window. let mut patrons: Vec<(String, i64)> = Vec::new(); let mut url = format!( "https://www.patreon.com/api/oauth2/v2/campaigns/{campaign_id}/members\ ?include=pledge_history\ - &fields%5Bmember%5D=full_name,patron_status,currently_entitled_amount_cents\ - &fields%5Bpledge-event%5D=amount_cents,date,payment_status\ + &fields%5Bmember%5D=full_name\ + &fields%5Bpledge-event%5D=amount_cents,currency_code,date,payment_status\ &page%5Bcount%5D=200" ); // Bound the loop so a malformed `next` link can never spin forever. @@ -75,27 +85,17 @@ pub fn fetch_patrons() -> Result, String> { if let Some(arr) = page["data"].as_array() { for m in arr { let attrs = &m["attributes"]; - // Paying supporters only: an active patron currently entitled to - // a non-zero amount (excludes free followers, $0 tiers, declined - // and former patrons). - if attrs["patron_status"].as_str() != Some("active_patron") { + let Some(usd_cents) = latest_recent_paid_usd( + m, + included, + &usd_rates, + &cutoff_date, + ) else { continue; - } - // The entitlement can retain the previous tier amount after a - // pledge change. The latest successful pledge event records - // what was actually paid; retain the entitlement as a fallback - // for memberships whose history is unavailable. - let cents = latest_paid_amount(m, included).unwrap_or_else(|| { - attrs["currently_entitled_amount_cents"] - .as_i64() - .unwrap_or(0) - }); - if cents <= 0 { - continue; - } + }; let name = attrs["full_name"].as_str().unwrap_or("").trim(); if !name.is_empty() { - patrons.push((name.to_string(), cents)); + patrons.push((name.to_string(), usd_cents)); } } } @@ -111,12 +111,14 @@ pub fn fetch_patrons() -> Result, String> { } #[cfg(not(target_arch = "wasm32"))] -fn latest_paid_amount( +fn latest_recent_paid_usd( member: &serde_json::Value, included: &[serde_json::Value], + usd_rates: &std::collections::HashMap, + cutoff_date: &str, ) -> Option { let history = member["relationships"]["pledge_history"]["data"].as_array()?; - let mut latest: Option<(String, i64)> = None; + let mut latest: Option<(String, i64, String)> = None; for relationship in history { let Some(id) = relationship["id"].as_str() else { @@ -140,22 +142,96 @@ fn latest_paid_amount( continue; } let date = attrs["date"].as_str().unwrap_or(""); + let Some(event_date) = date.get(..10) else { + continue; + }; + if event_date < cutoff_date { + continue; + } + let Some(currency) = attrs["currency_code"].as_str() else { + continue; + }; + let currency = currency.trim().to_uppercase(); + if currency.is_empty() { + continue; + } if latest .as_ref() - .map(|(current, _)| date > current.as_str()) + .map(|(current, _, _)| date > current.as_str()) .unwrap_or(true) { - latest = Some((date.to_string(), cents)); + latest = Some((date.to_string(), cents, currency)); } } - latest.map(|(_, cents)| cents) + let (_, cents, currency) = latest?; + let rate = *usd_rates.get(¤cy)?; + if !rate.is_finite() || rate <= 0.0 { + return None; + } + Some((cents as f64 / rate).round() as i64) +} + +#[cfg(not(target_arch = "wasm32"))] +fn fetch_usd_rates( + agent: &ureq::Agent, +) -> Result, String> { + let json = get_public_json(agent, USD_RATES_URL)?; + let entries = json + .as_array() + .ok_or("exchange-rate response is not an array")?; + let mut rates = std::collections::HashMap::new(); + rates.insert("USD".to_string(), 1.0); + for entry in entries { + let Some(currency) = entry["quote"].as_str() else { + continue; + }; + let Some(rate) = entry["rate"].as_f64() else { + continue; + }; + if rate.is_finite() && rate > 0.0 { + rates.insert(currency.to_uppercase(), rate); + } + } + Ok(rates) +} + +#[cfg(not(target_arch = "wasm32"))] +fn utc_date_days_ago(days_ago: u64) -> String { + let unix_days = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + / 86_400; + let (year, month, day) = civil_from_days(unix_days as i64 - days_ago as i64); + format!("{year:04}-{month:02}-{day:02}") +} + +// Convert days since 1970-01-01 to a Gregorian calendar date. +#[cfg(not(target_arch = "wasm32"))] +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) + / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + if month <= 2 { + year += 1; + } + (year, month, day) } /// Web build: the browser can't call the Patreon API directly (CORS + the /// token would be exposed in the bundle), so it fetches a pre-generated /// `supporters.json` published next to the app on the same origin (produced by -/// CI with the token held server-side). Shape: `[{ "name": .., "cents": .. }]`. +/// CI with the token held server-side). Shape: +/// `[{ "name": .., "cents": }]`. #[cfg(target_arch = "wasm32")] pub async fn fetch_patrons_web() -> Result, String> { use wasm_bindgen::JsCast; @@ -205,3 +281,16 @@ fn get_json( .map_err(|e| e.to_string())?; serde_json::from_str(&body).map_err(|e| e.to_string()) } + +#[cfg(not(target_arch = "wasm32"))] +fn get_public_json(agent: &ureq::Agent, url: &str) -> Result { + let body = agent + .get(url) + .header("User-Agent", UA) + .call() + .map_err(|e| e.to_string())? + .body_mut() + .read_to_string() + .map_err(|e| e.to_string())?; + serde_json::from_str(&body).map_err(|e| e.to_string()) +}