Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ src/rivercrossing/vectors/*.csv text eol=lf
# compiled_css + theme.css + the .j2 templates, added in EPIC 6).
src/rivercrossing/htmlexport/templates/*.j2 text eol=lf
src/rivercrossing/htmlexport/templates/compiled_css text eol=lf
# The WordPress-scoped variant is derived from compiled_css by the same
# LF-emitting generator and byte-compared by css_drift + test_gen_css,
# so it needs the identical eol=lf contract (Windows autocrlf turned it
# into CRLF and broke windows-latest).
src/rivercrossing/htmlexport/templates/compiled_css_wp text eol=lf
src/rivercrossing/htmlexport/templates/fonts_css text eol=lf
src/rivercrossing/htmlexport/templates/theme.css text eol=lf
tests/unit/fixtures/htmlexport/*.html text eol=lf
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,7 @@ artifacts/

# build-time npm installs (Tailwind CLI, vendored CSS step)
node_modules/

# Local WordPress publish credentials for testing Results > Publish to WordPress
# (a real Application Password; never commit it)
.wp-password.txt
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes to RiverCrossing are recorded here. The format follows

## [Unreleased]

### Added

- **Publish results to WordPress** — the Results menu gains **Publish to WordPress…**, which creates
or updates a page on a WordPress site through its REST API (an Application Password over HTTPS).
The dialog collects the site URL, username, Application Password, page title and slug, an optional
parent "section" page, and the publish status; the connection and page defaults persist in the
settings file. The published content is the app's own results layout, scoped so it cannot restyle
the site's theme.

## [1.1.0] - 2026-09-21

### Added
Expand Down
7 changes: 5 additions & 2 deletions design/docs-md/spec.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions design/docs-md/xrc-windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,21 @@ Simulated 240 of 1 800`sim_status_lbl`
Cancel`cancel_btn`

⚠ code-side: `sim_running_dlg` is the modal progress window GO opens — the status line reads "Simulated <n> of <t> crossings" and the gauge holds `100 * completed // total`, both refreshed after every recorded crossing. The race runs on the main thread inside this dialog's own modal loop, with `wx.Yield()` after each crossing so the gauge repaints and a Cancel click is dispatched without waiting for the race to end; Cancel (or Escape, which the view points at it with SetEscapeId, since cancel_btn carries a custom id) ends the run early. The window lives in simulation.xrc (§15b).

H · WordPress publish (Phase 4b)

Publish to WordPress`publish_wordpress_dlg`✕

▣ Site — Site URL / Username / Application password

▣ Page — Title / Slug / Parent page (id or slug) / Status

`wp_url_input (focused — the form's first field, recorded in views.dialogs.FORM_FIRST_FIELDS) · wp_username_input · wp_password_input (a wxTE_PASSWORD field, so the Application Password is never shown in clear) — one "Site" wxStaticBoxSizer over a 2-column caption/value wxFlexGridSizer, each input preceded by its own unnamed label (UX-DESKTOP §7: never placeholder text) · wp_title_input · wp_slug_input · wp_parent_input · wp_status_choice (authored with no `<content>`: the view fills it from `presenters/publish_wordpress.STATUS_CHOICES` — Draft / Publish / Pending / Private — and selects the stored `AppSettings.wp_status`) — the "Page" wxStaticBoxSizer over the same grid`

The Application Password is kept in this machine's settings file for now. A blank parent page publishes at the top level. The site account needs permission to publish pages with inline styles. `(fixed help line, <wrap>420</wrap>)`

PublishCancel

`wxID_OK ("Publish", default) · wxID_CANCEL — the stock std sizer and stock ids (§13, UX-DESKTOP §3)`

⚠ code-side (Phase 4b): Results ▸ Publish to WordPress… (`mi_publish_wordpress`, FINISHED-only) opens this form; `views/publish_wordpress.py`'s `PublishWordpressDialog` seeds the site fields from the live settings and the title/slug from the live ride's name (`default_title`/`default_slug`), collects one `PublishForm` on OK, and only then calls `app._publish_wordpress` — a refused form (`PublishForm.errors`: a blank field, or a site URL not beginning `https://`; the same rule the client's `InsecureURLError` enforces) shows its messages in one native error alert and leaves the dialog open, so the correction is a field edit and nothing is lost. The app persists the five `wp_*` settings first, then renders `htmlexport.render_wordpress` and publishes on a background thread (the console never blocks on the network): the parent page resolves as a numeric id or through `wordpress.find_page_by_slug` (a miss is top level), an existing page at the slug is updated in place rather than duplicated, and success posts "Published to <link>" on the status bar while a `WordPressError` — a refused Application Password, a WordPress error document, an unreachable host — arrives in its own native error alert. The window lives in dialogs.xrc (§15b).
11 changes: 11 additions & 0 deletions docs/user-guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,18 @@ <h2 id="publishing-results">Publishing results</h2>
<li><strong>Podium Poster HTML…</strong> — the same poster as a self-contained page.</li>
<li><strong>Export Standings CSV…</strong> — place, plate, entry, type, sex, laps, hand, draw, and times when shown.</li>
<li><strong>Preview HTML in Browser</strong> / <strong>Preview Podium Poster HTML in Browser</strong> / <strong>Preview PDF in Browser</strong> — open this session's last export of that format in your browser. Each needs a finished ride and an export made this session. After a restart the preview is disabled until the next export.</li>
<li><strong>Publish to WordPress…</strong> — create or update a page on your WordPress site from the finished ride's results. Enter the site URL, a WordPress username, and an <strong>Application Password</strong>, then the page title, slug, an optional parent page (the "section" it sits under), and the publish status. The page is looked up by slug: one already there is updated, and a new page is created when there is none. The connection is remembered in this machine's settings file, so you type it once.</li>
<li><strong>Publish Options</strong> — six checkable rows on the Results menu that shape every export: <strong>Show lap &amp; total times</strong>, <strong>Laps leaderboard</strong>, <strong>Fastest-time leaderboard</strong>, <strong>Full field</strong>, <strong>All cards drawn</strong>, and <strong>Show DNS Riders</strong>. A rider or team with no recorded laps on a finished ride shows as <strong>DNS</strong> at the bottom of its section; unchecking <strong>Show DNS Riders</strong> hides those rows. With show-times off, the exports embed no time data, and the fastest-time leaderboard is cleared and disabled.</li>
</ul>
<h3 id="before-wordpress">Before you publish to WordPress</h3>
<p>The site must be on <strong>HTTPS</strong>, and the account you use needs permission to publish pages. Do <strong>not</strong> use your normal WordPress password — create an <strong>Application Password</strong> at <strong>Users → Your Profile → Application Passwords</strong>, on the same page that shows the site URL and username to enter.</p>
<p>If that <strong>Application Passwords</strong> section is missing from your profile, a security plugin has switched it off. <strong>Wordfence disables Application Passwords by default</strong>, and the option is not obvious to find:</p>
<ol>
<li>Open <strong>Wordfence → Firewall → All Firewall Options</strong>.</li>
<li>The page has a <strong>search box at the top</strong> — search for <code>application passwords</code>. (Without the search, expand the <strong>Brute Force Protection</strong> section and look under its <em>Additional Options</em> heading.)</li>
<li>Untick <strong>Disable WordPress application passwords</strong> and save. The label is inverted, so it must be <strong>empty</strong> for Application Passwords to work.</li>
</ol>
<p>Leave it unticked — an Application Password stops authenticating the moment that option is switched back on.</p>
<h2 id="poker-the-run-way">How scoring works</h2>
<p>It is not a race. It is a poker run. Each completed lap deals one card. At the finish, every entry's <strong>best 5-card hand from all cards drawn</strong> is scored. Jokers are wild: a joker plays as any card you need, but only when it actually improves the hand. A spare joker that does not improve it is left unused.</p>
<h3>How a card is selected on a crossing</h3>
Expand Down Expand Up @@ -314,6 +324,7 @@ <h2 id="troubleshooting-faq">Troubleshooting &amp; FAQ</h2>
<li><strong>Moving the database to another machine</strong> — copy <code>rides.db</code>, and its <code>rides.db.backups/</code> folder if you want the history. The app opens it in place on the new machine.</li>
<li><strong>Restoring a backup</strong> — replace <code>rides.db</code> with the backup file, then relaunch.</li>
<li><strong>Theme change on Windows</strong> — a dark or light switch applies at the next launch. The status bar says so.</li>
<li><strong>WordPress rejects the Application Password</strong> — a security plugin has switched Application Passwords off. In Wordfence, open <strong>Firewall → All Firewall Options</strong>, search <code>application passwords</code> in the box at the top (or expand <strong>Brute Force Protection → Additional Options</strong>), and untick <strong>Disable WordPress application passwords</strong>. The setting is not obvious.</li>
</ul>
<h2 id="appendix-a-shortcuts">Appendix A — Keyboard shortcuts</h2>
<p>The same shortcuts appear in <strong>Help ▸ Keyboard Shortcuts</strong>, generated from the accelerator table so they never drift. This page writes the modifier key for the platform you are reading on: <kbd>⌘</kbd> on macOS, <kbd>Ctrl</kbd> on Windows.</p>
Expand Down
14 changes: 14 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,21 @@ A ⚠ badge beside a tied place gives the tie's explanation on a double-click. O
- **Podium Poster HTML…** — the same poster as a self-contained page.
- **Export Standings CSV…** — place, plate, entry, type, sex, laps, hand, draw, and times when shown.
- **Preview HTML in Browser** / **Preview Podium Poster HTML in Browser** / **Preview PDF in Browser** — open this session's last export of that format in your browser. Each needs a finished ride and an export made this session. After a restart the preview is disabled until the next export.
- **Publish to WordPress…** — create or update a page on your WordPress site from the finished ride's results. Enter the site URL, a WordPress username, and an **Application Password**, then the page title, slug, an optional parent page (the "section" it sits under), and the publish status. The page is looked up by slug: one already there is updated, and a new page is created when there is none. The connection is remembered in this machine's settings file, so you type it once.
- **Publish Options** — six checkable rows on the Results menu that shape every export: **Show lap & total times**, **Laps leaderboard**, **Fastest-time leaderboard**, **Full field**, **All cards drawn**, and **Show DNS Riders**. A rider or team with no recorded laps on a finished ride shows as **DNS** at the bottom of its section; unchecking **Show DNS Riders** hides those rows. With show-times off, the exports embed no time data, and the fastest-time leaderboard is cleared and disabled.

### Before you publish to WordPress {: #before-wordpress }

The site must be on **HTTPS**, and the account you use needs permission to publish pages. Do **not** use your normal WordPress password — create an **Application Password** at **Users → Your Profile → Application Passwords**, on the same page that shows the site URL and username to enter.

If that **Application Passwords** section is missing from your profile, a security plugin has switched it off. **Wordfence disables Application Passwords by default**, and the option is not obvious to find:

1. Open **Wordfence → Firewall → All Firewall Options**.
2. The page has a **search box at the top** — search for `application passwords`. (Without the search, expand the **Brute Force Protection** section and look under its *Additional Options* heading.)
3. Untick **Disable WordPress application passwords** and save. The label is inverted, so it must be **empty** for Application Passwords to work.

Leave it unticked — an Application Password stops authenticating the moment that option is switched back on.

## How scoring works {: #poker-the-run-way }

It is not a race. It is a poker run. Each completed lap deals one card. At the finish, every entry's **best 5-card hand from all cards drawn** is scored. Jokers are wild: a joker plays as any card you need, but only when it actually improves the hand. A spare joker that does not improve it is left unused.
Expand Down Expand Up @@ -344,6 +357,7 @@ A red check at launch opens the window so you can read it before carrying on. A
- **Moving the database to another machine** — copy `rides.db`, and its `rides.db.backups/` folder if you want the history. The app opens it in place on the new machine.
- **Restoring a backup** — replace `rides.db` with the backup file, then relaunch.
- **Theme change on Windows** — a dark or light switch applies at the next launch. The status bar says so.
- **WordPress rejects the Application Password** — a security plugin has switched Application Passwords off. In Wordfence, open **Firewall → All Firewall Options**, search `application passwords` in the box at the top (or expand **Brute Force Protection → Additional Options**), and untick **Disable WordPress application passwords**. The setting is not obvious.

## Appendix A — Keyboard shortcuts {: #appendix-a-shortcuts }

Expand Down
20 changes: 12 additions & 8 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,13 @@ def userguide_drift(session):

@nox.session(python=PYTHON)
def gen_css(session):
"""Regenerate the vendored compiled_css + fonts_css (E6.2.1).
"""Regenerate the three vendored CSS artifacts (E6.2.1).

Needs the pinned Tailwind CLI; :func:`_ensure_node_modules` installs
it with the lockfile's ``npm ci`` on a clean checkout. The nox
``unit`` session never needs it.
compiled_css, compiled_css_wp (its WordPress-scoped variant,
derived by the same ``--write``) and fonts_css. Needs the pinned
Tailwind CLI; :func:`_ensure_node_modules` installs it with the
lockfile's ``npm ci`` on a clean checkout. The nox ``unit`` session
never needs it.
"""
_ensure_node_modules(session)
session.install(DEV)
Expand All @@ -172,10 +174,12 @@ def css_drift(session):
"""Fail if the vendored CSS disagrees with the templates (E6.2.1).

This single ``--check`` is both the CI compile and the TB-7
staleness gate. Passes vacuously until the templates exist, so the
gate can be wired into CI before they are authored. Needs the pinned
Tailwind CLI; :func:`_ensure_node_modules` installs it with the
lockfile's ``npm ci`` on a clean checkout.
staleness gate, for the compiled artifact and for the WordPress-
scoped variant derived from it. Passes vacuously until the
templates exist, so the gate can be wired into CI before they are
authored. Needs the pinned Tailwind CLI;
:func:`_ensure_node_modules` installs it with the lockfile's
``npm ci`` on a clean checkout.
"""
if not GEN_CSS.exists() or not any(HTMLEXPORT_TEMPLATES.glob("*.j2")):
session.log("no htmlexport templates yet - nothing to check")
Expand Down
15 changes: 14 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,20 @@ rivercrossing = [
"ui/assets/cards/*.png",
"ui/assets/sounds/*.wav",
"vectors/*.csv",
# E6.2.1: the frozen results templates plus the two vendored CSS
# E6.2.1: the frozen results templates plus the vendored CSS
# artifacts. Explicit per-file entries (no glob, no fonts/) -- the
# base64 fonts_css ships instead of the woff2 sources.
"htmlexport/templates/base.html.j2",
"htmlexport/templates/macros.html.j2",
# The podium poster's own page (htmlexport.render_poster).
"htmlexport/templates/poster.html.j2",
# The WordPress content fragment (htmlexport.render_wordpress) and
# the stylesheet it inlines: compiled_css with every selector the
# fragment renders already scoped under its .rc-results wrapper.
"htmlexport/templates/wordpress.html.j2",
"htmlexport/templates/theme.css",
"htmlexport/templates/compiled_css",
"htmlexport/templates/compiled_css_wp",
"htmlexport/templates/fonts_css",
# P7 (E6.3.1): the PDF report's three TTF faces. fpdf2 reads them
# from the package at render time and embeds them into the PDF
Expand Down Expand Up @@ -138,6 +143,11 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
# Tests assert with bare `assert` and use literal expected values on purpose.
"tests/**" = ["S101", "PLR2004", "INP001", "ANN201", "D100", "D103", "SLF001"]
# The generated frozen-name registry: its constants are the XRC `name`
# attributes, so `WP_PASSWORD_INPUT = "wp_password_input"` names a wx control,
# not a credential (an S105 false positive). It is regenerated wholesale by
# tools/gen_ids.py -- `nox -s gen_ids` -- so an inline noqa would be wiped.
"src/rivercrossing/ui/ids.py" = ["S105"]
# Developer tooling is a script, not library code, and prints its progress.
# PLC0415: the probes import wx *inside* each function on purpose -- the driver
# process must stay importable when wx is broken, which is the case it reports.
Expand Down Expand Up @@ -264,5 +274,8 @@ source_modules = [
"rivercrossing.csvio",
"rivercrossing.standings",
"rivercrossing.store",
# The WordPress publish client (topic/wordpress-publish): stdlib
# urllib only, and no wx.
"rivercrossing.wordpress",
]
forbidden_modules = ["wx"]
Loading
Loading