Skip to content

Releases: micbwilliam/Saint-Porphyrius

v6.10.1

Choose a tag to compare

@github-actions github-actions released this 28 Jul 12:05

Fixed

🗄️ Database tab reported an issue that did not exist

6.10.0 added the seven sp_lesson_* tables to the DB-health check so schema drift would finally be visible on them. One of those column lists was wrong: sp_lesson_quiz_attempts was listed as having a passed column, which it never had — whether an attempt passed is derived from percentage against the lesson's passing percent, never stored. So the tab reported Table "wp_sp_lesson_quiz_attempts" missing columns: passed on a perfectly healthy database. sp_lesson_quiz_questions was also missing sort_order and updated_at from its list. All seven lists are now checked against the actual CREATE TABLE statements.

🗄️ Auto-Repair claimed the database was healthy while an issue was on screen

Auto-repair maps a problem to the migration that fixes it, but only for a hardcoded handful of tables. Anything outside that list produced no migration to run — and the empty result was reported as "No schema repairs needed. Database is healthy.", directly beneath a panel listing the problem. It now says which tables it has no mapping for instead of claiming success, and the lesson-prep tables are mapped, so a genuinely missing one can be repaired.

v6.10.0

Choose a tag to compare

@github-actions github-actions released this 28 Jul 11:55

Fixed

📚 Lesson Preparation — "حدث خطأ في الاتصال" when submitting

Members hit this intermittently, with no pattern: some on their first submission, some when reopening one, some getting a genuine "you have used all 3 attempts" instead. It was never one bug — it was two blocking network calls sitting on the submit request, an error handler that hid every other cause behind that one message, and a data-model flaw that quietly created duplicate preparations.

  • Submitting ran a 120-second OpenAI call inside the request. save_preparation() called run_ai_detection() inline whenever submit=1 — never on a draft save, which is why drafts always worked and only التقديم failed. wp_remote_post was set to wait two minutes; PHP's max_execution_time and the gateway's read timeout are far shorter, so a slow completion meant the browser got a 502/504 HTML page instead of JSON. Worse, PHP usually finished the write after the gateway had already answered, so the preparation was saved and submission_count incremented for a submission the member had been told had failed — they retried, burned attempts 2 and 3, and then hit the real limit. Detection now runs on cron (sp_lesson_prep_ai_detect), the same way 6.7.0 moved push notifications off the request; submitting is a pure database write. Points are only awarded at admin approval, so nothing user-facing depended on the score being ready. SP_Quiz_AI::call_api() also takes a timeout argument now, and the detection passes 30s rather than 120.
  • WordPress's plugin-update check dragged a 15-second GitHub call onto every AJAX request. admin-ajax.php fires admin_init, which runs core's _maybe_update_plugins(); SP_Updater filtered that with a guard reading DOING_AJAX && !isset($_POST['action']) — which bails only on AJAX without an action, i.e. it opted every real request in. And every failure path returned without caching, so once the shared 60-requests-per-hour unauthenticated GitHub limit was spent — a whole congregation shares one IP — every subsequent save paid the full timeout again, indefinitely. Update checks now run only on genuine admin page loads and cron, failures are cached for 15 minutes, and the timeout dropped to 8s.
  • Every server-side failure was reported as a connection error. The wizard read responses with a bare r.json() and no status check, then read response.data.message in the failure branch. admin-ajax answers the bare string 0 when a request never reaches a handler (lapsed login, or PHP discarding an oversized $_POST) — and JSON.parse("0") succeeds, returning the number 0, so reading .data.message off it threw inside the .then and landed in the same .catch. Five unrelated failures came out identical. The honest reader written for the admin wizard in 6.4.6 is now shared (assets/js/ajax-reader.js, loaded in the head so inline scripts can use it) and every lesson-prep screen uses it, along with a real request timeout — there was previously no timeout anywhere in the codebase.

📚 Lesson Preparation — duplicate preparations, and work that looked lost

  • Reopening a submitted preparation showed a blank form and created a second one. The wizard only recognised draft and needs_revision; for submitted/under_review/approved it found nothing, rendered every field empty, and omitted the hidden row id — so saving took the INSERT branch. sp_lesson_preparations only had a plain KEY user_lesson, so the database allowed it. The duplicates then inflated the max-submissions gate, which summed submission_count across every row for the pair, and could award the lesson's points twice since the approval dedupe key is per row.
  • There is now one preparation per (member, lesson), enforced by a UNIQUE key. save_preparation() resolves the row server-side instead of trusting the posted id, and folds a lost INSERT race into the winner's row. A migration collapses existing duplicates — keeping the reviewed row, salvaging any section text that only exists on a row being removed, and taking the highest submission count rather than the sum, so members wrongly locked out get their attempts back.
  • The wizard now loads the member's preparation whatever its status, so submitted work is visible instead of appearing to have vanished. Submitted / under-review / approved render read-only behind a status banner (carrying the admin's note); editing reopens only when an admin marks it يحتاج تعديل. The rule is enforced in save_preparation(), not just the UI.
  • A pending autosave could turn a submitted preparation back into a draft. The submit handler never cancelled the 2-second autosave timer, and since submit was meanwhile blocked on OpenAI, the autosave usually landed first — carrying submit=0. Autosaves are now serialised one at a time, cancelled on submit, disabled on read-only preparations, and rejected server-side against a non-editable row.
  • The remaining attempts are shown in the wizard, so the 3-submission limit stops arriving as a surprise at the moment of pressing Submit.

📚 Lesson Preparation — text grew a backslash every two seconds

save_preparation() ran wp_kses_post() directly on slashed $_POST data with no wp_unslash(), so every apostrophe was stored as \', rendered back into the textarea, and re-slashed by the next autosave — visibly producing \\\\\\\\"ليكن لى كقولك" in submitted work. Fixed at the three write points, and a migration unwinds the accumulated slashes on existing rows (only sequences WordPress' own slashing produces, so ordinary text is untouched).

📚 Lesson Preparation — failures you could not diagnose

  • $wpdb->last_error was never read anywhere in the module: a packet-too-large, a charset failure and a dropped connection all surfaced as the same فشل في حفظ التحضير. Saves now capture the driver's message, log the failure to sp_lesson_ai_log, and return distinct error codes.
  • A member's lesson access could silently disappear. set_lesson_access() deletes then bulk-inserts, but its return value was discarded by both create_lesson() and update_lesson() — a failed insert stripped every member's access, who then met ليس لديك صلاحية الوصول لهذا الدرس with nothing to explain it. Failures are now reported, and a member listed twice under one grade no longer takes the whole batch down with them.
  • Editing only the member list saved nothing: update_lesson() returned early when no lesson field had changed, before reaching the access write.
  • The sp_lesson_* tables are now listed in the DB-health tab, which knew about none of them.

Added

🔔 Lesson Preparation notifications

The module never called SP_Notifications once — submitting told no admin, and approvals reached members only as a generic ⭐ +N نقطة that named no lesson, or as nothing at all when the award was 0. All queued (never sent inline), so nothing blocks a request:

  • Members — تم استلام تحضيرك on submit; تحضيرك قيد المراجعة when review starts; تم قبول تحضيرك with the points, linking to the preparation; تحضيرك يحتاج تعديل carrying the admin's note and linking straight back into the wizard; درس جديد متاح للتحضير when a lesson is published to them; and a reminder before the lesson's event if they still have not submitted.
  • Admins — تحضير جديد بانتظار المراجعة on every submission, linked to the review queue; an alert when the background check flags a preparation as AI-written; and an alert when that check fails, so it can never die silently.

👤 Admin review — the member, properly

The review screen identified members with one grey line of text and no way to reach them. It now shows their profile photo, name, church, grade and submission date as a single block linking to their profile.

Changed

⭐ Point values are no longer capped

Admin point fields carried max="100" / max="1000", so setting attendance to 200 was refused by the browser ("Value must be less than or equal to 100") even though nothing in the database or the server validation required it. Every point-value field is now uncapped — attendance, late and absence points, the gamification awards, subscription points, point-sharing fees, bus booking fee, lesson-prep section points and quiz max points. Genuine percentages (AI threshold, passing percent, fee percentage) keep their 0–100 range.

Performance

  • SP_Perf can no longer corrupt an AJAX response. It samples on shutdown, which runs after wp_send_json_*() has written the body — so anything it printed landed after valid JSON and broke the client's parse. And because slow requests are always sampled, and the submit was always slow, it landed on exactly the requests already in trouble. The write is now fully insulated from the output stream.
  • APCu drop-in: flush_token()/group_token() were read-then-write, so under memory pressure several PHP-FPM workers could each mint a different token, orphan each other's writes and drive the hit rate to zero — turning slow requests into timing-out ones. They now use apcu_add() so exactly one worker wins. A cached object whose class is not yet loaded (__PHP_Incomplete_Class) is treated as a miss instead of being cloned, which raised an uncaught fatal on a random worker.

v6.9.0

Choose a tag to compare

@github-actions github-actions released this 14 Jul 19:54

Added

🚀 APCu object cache — one click, no server to install

This is the piece that makes WordPress itself faster, not just Saint Porphyrius. Until now WordPress threw away its caches of options, users, user-meta, posts and terms at the end of every request and re-read the same rows from MySQL on the next one. APCu keeps that memory alive between requests.

APCu is shared memory built into PHP. There is no service to run, no port, no password — which is why it is available on most shared hosting where Redis is not.

  • Settings → Performance now detects APCu and offers a single button to turn it on. It installs wp-content/object-cache.php; the page can turn it off again at any time. Verified against a real server: the first request reads from MySQL and every request after it is served from shared memory.
  • It cannot take the site down. If APCu is missing, disabled, or out of memory, every operation quietly falls back to a per-request array — which is exactly WordPress's own default behaviour. The site keeps working; it just stops being faster. This was tested explicitly with APCu absent: the full wp_cache_* API behaves identically.
  • It will not clobber another cache backend. If Redis, W3TC or LiteSpeed already own object-cache.php, the button refuses and says so. That file is not ours to overwrite.
  • Flushing does not wipe other sites on the same host. apcu_clear_cache() would empty the shared memory of every application in the PHP pool — on shared hosting, that is somebody else's site. Instead the cache rotates a random namespace token, orphaning only our own entries. Verified: a neighbouring app's data survives our flush untouched. Entries are also namespaced per site, so two WordPress installs sharing a pool cannot read each other's data.
  • The panel shows whether it is actually working: hit rate, memory used vs available, and how many times APCu has run out of room and had to throw things away (which means apc.shm_size is too small and the cache keeps rebuilding itself).
  • Saint Porphyrius picks it up automatically. SP_Cache is built on transients, and WordPress routes transients through a persistent object cache whenever one exists — so the standings snapshot and everything else moves into shared memory with no code change.

One caveat, stated plainly on the page: APCu memory belongs to the web server's PHP pool. A WP-CLI command runs in a different process and cannot see or clear it, so changing data from the command line can leave the site serving the old value. Entries WordPress asks to keep "forever" are therefore capped at 12 hours (an expired entry is never wrong data — WordPress simply re-reads it from MySQL), and there is a Flush button. If you start doing real work through WP-CLI, flush afterwards, or move to Redis, where this does not arise.

v6.8.0

Choose a tag to compare

@github-actions github-actions released this 14 Jul 19:41

Added

⚡ Caching — one standings snapshot behind every leaderboard and rank

  • New SP_Cache. A deliberately small wrapper over WordPress transients: per-request memo → transient → compute. It is not wp_cache_* layered on top of transients, because that is what transients already are — they route through a persistent object cache when one exists and fall back to the options table when one does not. Layering both would store every value twice and let a stale copy outlive its own invalidation. The upshot: this works today on your host with zero infrastructure, and the day Redis is switched on it gets faster with no code change.
  • The standings snapshot. Every leaderboard and every rank in the app used to be its own full aggregate of the points log (GROUP BY user_id SUM(points) — a temp table and a filesort). The member dashboard ran one on every load just to work out one integer. The share-points preview ran two. They now all read one cached snapshot: building it costs 3 queries, reading a rank or a leaderboard slice out of it costs none.
  • Invalidation is provably complete, not a TTL guess. SP_Points is the only writer of the points log anywhere in the plugin, so every write flushes the snapshot and it cannot go stale behind our back. The 15-minute TTL is only a safety net. Awarding points to 200 members collapses to a single cache delete rather than 200.
  • The community page now loads every member's discipline status in one query instead of roughly two per member.

Fixed

  • Most members were shown rank 0. The dashboard fetched a 100-row leaderboard and looked for the member in a PHP loop — so anyone outside the top 100 was simply never found, and their rank stayed at its initial 0. The same bug existed on the share-points screen (top 100) and the social profile (top 200). Rank now resolves for everyone.
  • Rank and the leaderboard could disagree. Rank was computed against the sp_points_balance user-meta cache, while the leaderboard summed the points log itself. The two could drift apart. Both now come from the log.
  • Members on identical scores now correctly share a rank (two members on 300 are both 1st, and the next is 3rd) instead of being ordered arbitrarily.

v6.5.0

Choose a tag to compare

@github-actions github-actions released this 14 Jul 19:15

Added

⚡ Performance tab — measure before optimising

A new Settings → Performance tab in wp-admin. This release deliberately adds only the measurement, and changes no behaviour: the optimisation work lands next, and if we optimised first there would be nothing honest left to compare against.

  • It tells you whether a persistent object cache is actually running. This is the question you cannot otherwise answer from inside WordPress. Today the answer is almost certainly "no": with no object-cache.php drop-in, WordPress throws away its caches of options, users and user-meta at the end of every request and re-reads them from MySQL on the next one. The tab detects the drop-in, the backend class, and the Redis/Memcached PHP extensions, and — when none are present — says what to ask the host for. The plugin will pick a cache up automatically if one ever appears; there is nothing to configure.
  • Real request sampling. SP_Perf records roughly 1 request in 10 (app routes and sp_* AJAX only — never cron, never wp-admin), plus every request slower than 800ms so a rare stall cannot hide. It records the query count, wall time and peak memory. SAVEQUERIES is never enabled: retaining every query and a backtrace for each costs far more than the thing being measured, so the count comes from $wpdb->num_queries, which WordPress tracks anyway.
  • Because slow requests are always kept while fast ones are sampled, the stored rows are not a uniform sample of traffic — a median taken over all of them would be badly inflated (with 12% of traffic slow, it would report 3000ms where the truth is 150ms). Forced captures are therefore tagged, and the median/p95 figures are computed over the uniform subset only. The slow-screens table deliberately reads both.
  • Record baseline, so later releases can show a real before/after rather than a claim.
  • Benchmark hot paths times the known-expensive reads immediately, in-process — useful on day one, before traffic sampling has anything to say.
  • Also reports: the slowest screens (median/p95/queries), autoloaded-option weight with the ten largest offenders (these are read from the database on every request, used or not), and which of the indexes the hot queries want are missing.
  • Samples live in a bounded sp_perf_samples table — a table, not an option, because an option is read-modify-written whole on every sample, which is a lost-update race under concurrency and a blob that grows forever. Pruned daily to 14 days and capped at 20,000 rows.

v6.4.6

Choose a tag to compare

@github-actions github-actions released this 14 Jul 18:30

Fixed

📅 Events — duplicated on creation

  • Creating an event created it twice. The events screen handles its POST at the top of the template and then falls through and re-renders, so the browser was left sitting on the POST result — and, because the URL stayed on ?action=new, on an empty create form with a success banner, which reads as "nothing saved". A refresh, a PWA pull-to-refresh, or Back→Forward replayed the body and inserted the event a second time. WP nonces do not stop this: wp_verify_nonce() accepts the same nonce for 12–24h. SP_Events::create() was a bare INSERT with no unique constraint, so nothing downstream caught it either, and a duplicated published event fired sp_event_created twice — duplicating the push notification as well.
  • Fixed with the same three layers the points log got in 6.4.5: a dedupe_key column with a UNIQUE index on sp_events; an optional $dedupe_key argument on SP_Events::create() that reports a replay back as ['duplicate' => true] instead of inserting; and a single-use form token that carries the key. The token is what closes the double-tap race, where two requests are in flight at once and a check-then-act guard would let both through.
  • The events screen now redirects after a successful write (POST/Redirect/GET), so there is no POST result left to replay — for update, delete and complete as well as create. Failures still render inline on purpose, so a rejected submission does not throw away what the admin typed. The submit button also disables itself on submit.
  • The shared pieces live in a new SP_Form_Guard helper, so the other admin screens that handle their POST inline can adopt the same protection.

📚 Lesson Preparation — "فشل الاتصال بالخادم" when publishing

  • Publishing a lesson usually failed, and never said why. The wizard built its save request with new FormData(form) over the whole form, and the PDF file inputs live inside that form — even though PDFs are already uploaded by their own sp_lesson_pdf_upload request and stored against the lesson. So every draft autosave, every save-as-draft and the publish itself re-uploaded every selected PDF. That pushed the request past post_max_size, at which point PHP discards $_POST entirely, admin-ajax finds no action, and answers with the bare string 0. The save requests now drop the file inputs from their payload.
  • The error handler was hiding its own cause. The response was read with a bare r.json() and no status check. JSON.parse("0") succeeds and returns the number 0, so the failure branch then read resp.data.message off it and threw — landing in the .catch that reports "فشل الاتصال بالخادم". Every server-side failure — oversized POST, HTTP 500, expired session — surfaced as a connection error, which is why this was never diagnosable. Responses now go through a shared reader that checks the status, recognises admin-ajax's 0/-1 replies, and reports what actually went wrong.
  • A successful publish could still report failure. persistQuestions() had no error path of its own, so a questions-only failure claimed the connection died even though the lesson had published. It now says so, and does not invite the admin to press Publish again.
  • Publishing could time out on a large member list. set_lesson_access() ran one INSERT per (grade × member) — 360 round-trips for 6 grades × 60 members. It is now a single batched INSERT.
  • A retried publish could create a second lesson. The lesson id was only recorded after a successful parse, so when the server had written the row but the response was lost, the next click re-ran sp_lesson_create. The id is now recorded as soon as the create succeeds, and the draft/publish buttons lock while either save is in flight.

Pending migrations now also run for admins outside wp-admin, since the screens that need the new schema live on the frontend under /app/admin.

v6.4.5

Choose a tag to compare

@github-actions github-actions released this 09 Jul 11:23

Fixed

🎯 Points — duplicated awards

  • Completing an event awarded every attendance a second time. SP_Attendance::mark() writes points to the log immediately but never set the points_processed flag. SP_Events::complete_event() then calls process_event_points(), which settles every record whose flag is still 0 — i.e. all of them. Marking an event complete therefore duplicated the attendance and penalty points of every member. mark() now sets points_processed = 1, process_event_points() carries a dedupe key, and complete_event() returns early when the event is already completed. A migration backfills the flag on historical rows so completing an old event no longer re-awards it.
  • sp_points_log had no idempotency key. SP_Points::add() was a bare INSERT, so any repeated call — double-tap, retried AJAX, refreshed POST — appended a second row and permanently doubled the balance (the log is the source of truth for get_balance() and the leaderboard). Added a dedupe_key column with a UNIQUE index and an optional $dedupe_key argument; repeat awards are now rejected by the database and reported back as ['duplicate' => true]. NULL keys still repeat, so manual adjustments are unaffected.
  • Concurrent awards lost an update. add() read the balance from the sp_points_balance user-meta cache, which can be stale, then wrote back balance + points. Two overlapping requests both read the same value and one award vanished from balance_after. The balance is now derived from the log inside a transaction with SELECT … FOR UPDATE, which serialises awards per user.
  • type was an enum of 6 values while the code wrote 25. Values such as attendance, birthday_reward and bus_booking_refund were silently coerced to ''. That broke the duplicate guard in refund_bus_booking_fee(), which matches on type = 'bus_booking_refund' and so never fired. type is now varchar(40).
  • Two add() calls passed their arguments in the wrong order, writing the reason string into the event_id column: excuse submission/denial (SP_Excuses) and waiting-list bus fees (SP_Bus).
  • Manual points adjustment re-ran on browser refresh. The admin screens render the POST result inline instead of redirecting, so refresh or Back resubmitted the adjustment. Both forms now carry a single-use token that doubles as the award's dedupe key.
  • Birthday-congratulation rollback deleted the wrong row. It used $wpdb->insert_id after add() had run its own INSERT, so the id no longer referred to the congratulation record.

Idempotency keys were also added to the once-per-occurrence awards that previously relied on a racy check-then-act guard: birthday, feast day, profile completion, story quiz, service instructions, birthday gift, birthday congratulations, push-notification subscription, appeal decisions, excuse submission/denial, lesson-prep approval and quiz passes, bus booking fees and refunds, quiz best-score top-ups, and point sharing (deduped over a one-minute window so a deliberate repeat gift still works).

v6.4.4

Choose a tag to compare

@github-actions github-actions released this 31 May 18:06

Fixed

📚 Lesson Preparation — Members can finally see their assigned lessons

  • Members saw "لا توجد دروس" even when correctly assigned. get_lessons() built its prepared-statement parameters in WHERE-first order, but the access INNER JOIN (… WHERE user_id = %d) subquery appears before the WHERE l.status = %s clause in the SQL text. $wpdb->prepare() binds placeholders left-to-right, so for a member the user ID and status string were swapped: user_id was bound to "published" (→ 0, no such user) and status to the numeric user ID (no such status) — so the query matched nothing. The join params are now merged ahead of the WHERE params so binding order matches the SQL text.
  • Admins were unaffected (their preview path adds no access join), which is why this stayed hidden until per-member access actually started saving in 6.4.2. Lessons must still be published (not draft) to appear to members.

v6.4.2

Choose a tag to compare

@github-actions github-actions released this 31 May 17:37

Fixed

📚 Lesson Preparation — Member access & per-lesson settings now save

  • Member selections are saved again. The "الأعضاء المسموح لهم" picker silently dropped every selection. The per-grade access list is sent as a JSON string, but WordPress slash-escapes all $_POST data (magic quotes); the un-unslashed JSON failed to decode (json_decode returned null), so set_lesson_access() was never called. The result: members saw no lessons, and reopening the wizard showed every checkbox unchecked. create_lesson/update_lesson now wp_unslash() the access payload before decoding (the bare grades array was unaffected only because it contains no quote characters).
  • Per-lesson quiz, points & AI-detection settings persist. The same magic-quote issue caused quiz_config, prep_points_config, ai_detection_config, and pdf_urls to be stored as invalid (slash-escaped) JSON, so on read they decoded to empty and silently fell back to the global defaults. These are now unslashed before storage.

Note: lessons saved before this fix have no stored member access — reopen each one, reselect members per grade, and save again.

v6.4.1

Choose a tag to compare

@github-actions github-actions released this 31 May 13:44

Added

📚 Lesson Preparation — Per-Grade Member Access

  • Assign members per target year — The lesson create/edit wizard now shows one member panel per selected target grade (الصف ١، الصف ٣، …) instead of a single flat list, so admins add members to each year. Each panel has its own search, "تحديد الكل / إلغاء الكل", and a live member count.
  • Per-grade storage & visibility — Access is saved per grade; a member who is assigned under multiple years no longer sees the lesson duplicated in their list, and a preparation records the grade the member was actually assigned to for that lesson.

📅 Link Lessons to Draft Events

  • The lesson wizard's "ربط بفعالية" picker now includes upcoming draft events (marked (مسودة)), not only published ones, so a lesson can be prepared before its event goes live. Completed events stay out, and an already-linked event remains selectable when editing.

🖼️ Profile & Dashboard

  • Tap-to-enlarge profile photos — On the social profile page, tapping the cover photo or the profile picture now opens it full-screen in a lightbox (close with the ✕ button, backdrop tap, or Esc). Only photos that actually exist are zoomable — the letter-fallback avatar is not.
  • Clickable birthday cards — On the dashboard "أعياد ميلاد اليوم" cards, the member's name and photo now link to their social profile page. The congratulation/gift controls below remain unaffected.

Changed

  • Lesson list visibility now filters per-user via a DISTINCT-lesson access subquery (prevents duplicate rows when a member holds several grade assignments).