Jump to content

About:Pharmacopedia.ext: Difference between revisions

From Pharmacopedia
[checked revision][checked revision]
Bump to 0.9.3: life-story observations + episodes + visual timeline + vote choice/multi + ClamAV hardening + TimePicker kit
Doc update for v0.9.3: life-story timeline, observations, episodes, choice/multi voting, sharing subsystem, ClamAV rule, TimePicker
Line 1: Line 1:
{{TOC right}}
= Pharmacopedia extension specification =
= Pharmacopedia extension specification =


Line 6: Line 5:
'''Source:''' <code>/var/www/mediawiki/extensions/Pharmacopedia/</code>
'''Source:''' <code>/var/www/mediawiki/extensions/Pharmacopedia/</code>


The Pharmacopedia extension turns a MediaWiki install into a structured, community-edited medicine reference with rich user-profile and assessment infrastructure. It adds parser tags, special pages, API modules, a chip-picker / autosave UI framework, and a database schema that together support:
The Pharmacopedia extension turns a MediaWiki install into a structured, community-edited medicine reference with rich user-profile, assessment, life-story, and visibility-sharing infrastructure. It adds parser tags, special pages, API modules, a chip-picker / autosave UI framework, a vis-timeline-based visual life timeline, a granular per-record sharing subsystem, and a database schema that together support:


* Structured medicine pages via the <code><nowiki>{{MedTemplate}}</nowiki></code> template
* Structured medicine pages via the <code><nowiki>{{MedTemplate}}</nowiki></code> template
* Per-user rating on effects, problems, titration strategies, anecdotes, and drug-drug interactions (continuous 0–100 sliders, ±100 valence; no 0–5 likert anywhere)
* Per-user rating on effects, problems, titration strategies, anecdotes, and drug-drug interactions (continuous 0–100 sliders, ±100 valence; no 0–5 likert anywhere)
* Binary AND choice/multi voting on arbitrary content (<code>type="single"</code> / <code>type="multi"</code> with 2-5 options, results-visibility policy per element)
* Two-perspective data capture (personal vs. provider) wherever clinically meaningful
* Two-perspective data capture (personal vs. provider) wherever clinically meaningful
* User profile with dimensional personality / autism assessments (CATI, CAT-Q, MBTI, Enneagram, PID-5-BF, OCEAN/BFI-10) and rich auto-generated reports
* User profile with dimensional personality / autism assessments (CATI, CAT-Q, MBTI, Enneagram, PID-5-BF, OCEAN/BFI-10) and rich auto-generated reports
* Diagnosis autocomplete backed by ~25,500 ICD-10-CM and ~15,800 ICD-11 codes
* Diagnosis autocomplete backed by ~41,500 ICD-10-CM, ICD-11, and DSM-5 codes
* '''Life-story timeline''': visual swimlanes (vis-timeline 7.7.3, vendored Apache-2.0) plus a synchronized trait-trajectory overlay (vis.Graph2d); card-list view alongside; quick-add free-text observation parser; range-date episode form with severity slider
* '''Per-record sharing subsystem''': rule types include public, private, users, cohort, link_token, reciprocal; time-bounded; audit log of who-viewed-what; bulk free-text → structured ref upgrader; privacy mode that disables legacy fallback
* Chip-picker / autosave / slider-precise-input UI framework shared across editor surfaces
* Chip-picker / autosave / slider-precise-input UI framework shared across editor surfaces
* Date + time + range kit: PCPDatePicker (point/range/possibility), PCPTimePicker (fuzzy time-only: "4p", "noon", "quarter past 6")
* Verified-provider role with document-based verification
* Verified-provider role with document-based verification
* Fail-closed ClamAV scan on every image / file upload (hard project rule)


== Precision doctrine ==
== Precision doctrine ==
Line 29: Line 33:
== High-level architecture ==
== High-level architecture ==


* '''Backend (PHP):''' <code>includes/</code>, one class per parser tag, store, special page, or API module. Auto-loaded under <code>MediaWiki\Extension\Pharmacopedia\</code>. Assessment classes under <code>includes/Assessments/</code>.
* '''Backend (PHP):''' <code>includes/</code>, one class per parser tag, store, special page, or API module. Auto-loaded under <code>MediaWiki\Extension\Pharmacopedia\</code>. Assessment classes under <code>includes/Assessments/</code>. API modules under <code>includes/Api/</code>.
* '''Frontend (JS):''' <code>resources/ext.pharmacopedia.js</code>, single IIFE binding click handlers, modals, chip-pickers, autocomplete, autosave debouncer, slider-precise click-to-type. <code>resources/ext.pharmacopedia.blocksave.js</code> is the autosave infrastructure (debounced AJAX per block, race-safe).
* '''Frontend (JS):''' multiple ResourceModules per surface area:
* '''Styles (CSS):''' <code>resources/ext.pharmacopedia.css</code>, shared row layout, per-tag chrome, dark-theme palette (black / dark-grey / purple / white primary; red / green / blue / teal sparingly for semantic distinction).
** <code>ext.pharmacopedia</code>: main IIFE (chip-picker, dx autocomplete, BFI-10 compute, vote logic for both binary and choice/multi)
* '''Datepicker:''' <code>resources/ext.pharmacopedia.datepicker.js</code> + <code>.css</code>, supports single dates, ranges, and possibility-mixed dates (e.g. "1995 or 1996").
** <code>ext.pharmacopedia.blocksave</code>: debounced autosave per block (race-safe)
* '''Schema:''' <code>sql/</code>, ~20 core tables plus migration patches. Picked up via <code>LoadExtensionSchemaUpdates</code> hook.
** <code>ext.pharmacopedia.datepicker</code>: range / possibility-mix date widget
** <code>ext.pharmacopedia.timepicker</code>: time-only widget (fuzzy parsing, extracted from DatePicker)
** <code>ext.pharmacopedia.share</code>: per-record share dialog (People / Link / Cohorts tabs)
** <code>ext.pharmacopedia.observation</code>: quick-add observation textarea + live preview
** <code>ext.pharmacopedia.refupgrade</code>: bulk linker for free-text → structured refs
** <code>ext.pharmacopedia.vis-timeline-vendor</code>: vis-timeline 7.7.3 (vendored)
** <code>ext.pharmacopedia.lifetimeline</code>: visual life-story timeline + swimlanes + toolbar
** <code>ext.pharmacopedia.lifegraph</code>: trait-trajectory overlay (vis.Graph2d) synced to timeline
** <code>ext.pharmacopedia.kitsync</code>: glue that propagates kit-widget changes into legacy hidden inputs + drives privacy-mode toggle
* '''Styles (CSS):''' shared dark-theme palette (black / dark-grey / purple / white primary; red / green / blue / teal sparingly for semantic distinction).
* '''Schema:''' <code>sql/</code>, ~25 core tables plus migration patches. Picked up via <code>LoadExtensionSchemaUpdates</code> hook.


== Parser tags ==
== Parser tags ==
Line 42: Line 56:
! Tag !! Purpose !! Class
! Tag !! Purpose !! Class
|-
|-
| <code>&lt;vote&gt;</code> || Generic up/down binary vote on an arbitrary slug || <code>VoteTag</code>
| <code>&lt;vote&gt;</code> || Binary up/down (default) OR choice/multi when <code>type="single"</code> or <code>type="multi"</code> + <code>options="A; B; C"</code> (2-5 options, semicolon-separated). Optional <code>results="live\|after-vote\|hidden"</code> for tally-visibility policy. || <code>VoteTag</code>
|-
|-
| <code>&lt;effect&gt;</code> || Therapeutic or adverse effect; patient + provider perspectives; provider freq slider 0–100; shared valence slider ±100 || <code>EffectTag</code>
| <code>&lt;effect&gt;</code> || Therapeutic or adverse effect; patient + provider perspectives; provider freq slider 0–100; shared valence slider ±100 || <code>EffectTag</code>
Line 81: Line 95:
<pharmaInteractions/>
<pharmaInteractions/>
<pharmaExperience/>
<pharmaExperience/>
<vote slug="fav-color" type="single" options="Red; Blue; Green">
What's your favorite color?</vote>
<vote slug="side-effects" type="multi" results="after-vote"
  options="Dry mouth; Insomnia; Anxiety; Headache; None">
Which side effects did you experience?</vote>
</pre>
</pre>


Line 88: Line 109:
! Element !! Scale !! Perspectives !! Storage
! Element !! Scale !! Perspectives !! Storage
|-
|-
| Vote tag || +1 / −1 binary || single || <code>pcp_votes</code>
| Vote tag (binary) || +1 / −1 binary || single || <code>pcp_votes.v_value</code>
|-
| Vote tag (single-choice) || one of 2-5 options || single || <code>pcp_votes.v_choices</code> (CSV index)
|-
| Vote tag (multi-choice) || any subset of 2-5 options || single || <code>pcp_votes.v_choices</code> (CSV indices)
|-
|-
| Titration || +1 / −1 binary || single || <code>pcp_votes</code>
| Titration || +1 / −1 binary || single || <code>pcp_votes</code>
Line 103: Line 128:
|}
|}


Server-side aggregates: <code>n</code>, mean of the rating field, and (for interactions) <code>severe = (vmean ≤ −83.0)</code> (rescaled from the original ±3-scale −2.5).
Choice / multi vote elements expose per-option tallies via <code>tallyChoices()</code> on demand. Per-option bars render inline in the picker. The <code>results</code> attribute gates tally visibility:
* <code>live</code> (default) — tally always visible
* <code>after-vote</code> — tally hidden until viewer has voted
* <code>hidden</code> — tally never shown (only options + "thanks" on submit)
 
Server-side options-hash (<code>ve_options_h</code>) detects post-vote option-list edits; the API rejects new votes whose submitted hash doesn't match the live one. Voter identities are stored as HMAC-SHA256 (<code>v_voter_hash</code>) so admins reading the DB cannot map votes to user accounts without the HMAC secret.


Aggregates are recomputed and returned by every report-submit API call so the row re-renders in place without a page reload.
Server-side aggregates: <code>n</code>, mean of the rating field, and (for interactions) <code>severe = (vmean ≤ −83.0)</code> (rescaled from the original ±3-scale −2.5). Aggregates are recomputed and returned by every report-submit API call so the row re-renders in place without a page reload.


== Effect bucketing ==
== Effect bucketing ==
Line 130: Line 160:


<code>Special:MyProfile</code> is the user-facing editor for everything personal. Every block on it autosaves on a 800 ms debounce (see [[#Autosave infrastructure|Autosave infrastructure]]).
<code>Special:MyProfile</code> is the user-facing editor for everything personal. Every block on it autosaves on a 800 ms debounce (see [[#Autosave infrastructure|Autosave infrastructure]]).
Visible at top: a Privacy-mode panel toggling whether legacy public fallback applies (privacy-mode ON = only explicit share rules grant access; OFF = the field-level <code>pf_visibility</code> enum applies).
🔗 Share chips appear in each fieldset legend (Demographics, Diagnoses, Medicines) so the owner can scope a share-rule to that namespace via the modal Share dialog.


=== Block list ===
=== Block list ===
Line 155: Line 189:
* '''Height / weight''' unit toggle (Metric cm/kg or US ft+in/lb); stored canonically as cm/kg regardless
* '''Height / weight''' unit toggle (Metric cm/kg or US ft+in/lb); stored canonically as cm/kg regardless
* '''Handedness''' single-select (3 options)
* '''Handedness''' single-select (3 options)
* '''Smoking''' structured widget: status + cigs/day + years smoked + quit date; auto-computes pack-years
* '''Smoking''' structured widget: status + cigs/day + years smoked + quit date (PCPDatePicker); auto-computes pack-years
* '''Alcohol''' structured widget: drinks/week + typical drink type + max one occasion
* '''Alcohol''' structured widget: drinks/week + typical drink type + max one occasion
* '''Education''' bucketed highest-level + numeric years + free-text field of study
* '''Education''' bucketed highest-level + numeric years + free-text field of study
Line 165: Line 199:
* '''Number of children''' numeric
* '''Number of children''' numeric
* '''Time zone''' free text, auto-detects IANA TZ on first load if empty
* '''Time zone''' free text, auto-detects IANA TZ on first load if empty
* '''Chronotype / sleep schedule''' two time inputs (typical bedtime, typical wake)
* '''Chronotype / sleep schedule''' two PCPTimePicker widgets (typical bedtime, typical wake; fuzzy parsing accepts "10p", "bedtime", "quarter past 6")
* '''Political orientation''' two-axis compass sliders (economic ±100, social ±100)
* '''Political orientation''' two-axis compass sliders (economic ±100, social ±100)


Line 208: Line 242:
|}
|}


All assessment items submit raw responses to <code>pcp_user_profile_fields</code> under namespace <code>{key}_raw</code> (e.g. <code>cati_raw</code>); the computed scores live under namespace <code>{key}</code> with keys like <code>subscale_SOC</code>, <code>total</code>, plus a <code>taken_at</code> timestamp. Re-scoring happens automatically on every save (autosave fires `scoreResponses()` on the full raw set, skipping "unsure" rows).
All assessment items submit raw responses to <code>pcp_profile_fields</code> under namespace <code>{key}_raw</code> (e.g. <code>cati_raw</code>); the computed scores live under namespace <code>{key}</code> with keys like <code>subscale_SOC</code>, <code>total</code>, plus a <code>taken_at</code> timestamp. Re-scoring happens automatically on every save (autosave fires <code>scoreResponses()</code> on the full raw set, skipping "unsure" rows). Each completed assessment also auto-upserts a Life-story keyframe row (see [[#Life-story timeline|Life-story timeline]]) so trajectories plot over time.
 
== Life-story timeline ==
 
<code>Special:MyLifeStory</code> is the owner-facing editor + viewer for everything time-anchored about the user. Backed by <code>pcp_life_events</code> with extension columns for episode / observation / keyframe metadata.
 
=== Event types ===
 
{| class="wikitable"
! le_type !! Meaning !! Notes
|-
| 0 (TYPE_STORY) || Plain timeline entry || title, body, optional image
|-
| 1 (TYPE_IMAGE) || Image-primary entry || same shape, image is the main payload
|-
| 2 (TYPE_KEYFRAME) || Auto-created assessment snapshot || populated by <code>upsertAssessmentKeyframe</code> on every assessment save
|-
| 3 (TYPE_OBSERVATION) || Plain-text observation || created via the quick-add textarea; parser extracts date, polarity, refs
|-
| 4 (TYPE_EPISODE) || Time-bounded period || start + end (PCPDatePicker range mode); type / subtype / severity 0-100
|}
 
=== Quick-add observation ===
 
A textarea at the top of <code>Special:MyLifeStory</code> (and a 📝 modal trigger on <code>Special:MyProfile</code>) accepts plain text like:
 
<pre>
anxiety from bupropion in jan 2020
did not experience anxiety from bupropion in feb 2018
panic attack 2 months ago
depressed as a freshman
i had a manic episode sep 1 2020 till nov 15 2020
no insomnia while on melatonin in summer 2023
felt great on christmas 2020
happiest on my 30th birthday
</pre>
 
<code>ObservationParser::parse()</code> extracts:
 
* '''Date''' (point or range): ISO, MM/DD/YYYY, "Month D YYYY", "Month YYYY", "Season YYYY", "early/mid/late YYYY", bare year, decades ("2010s"); date RANGES via "X to Y", "X till Y", "from X to Y", "X - Y"; relative-to-now ("yesterday", "last week/month/year", "N months ago", "a few weeks ago"); holidays (christmas, halloween, new year's, valentine's, july 4th, thanksgiving with computed 4th-Thursday-of-Nov, MLK / Memorial / Labor day); age-relative ("7y8mo", "51.2yo", "at age 14", "ages 2-10"); life stages ("in childhood", "as a teen", "as a freshman", "junior year"); Nth birthday ("my 30th birthday")
* '''Polarity''' (negation detection): "not", "didn't", "did not experience", "never", "without", "denied" → polarity=0 (negative); else 1 (positive)
* '''Leading verbs stripped''': "I was diagnosed with", "I took", "started taking", "tried", "was on", "experienced", "felt" — so the subject is the noun, not the verb
* '''Adverbs stripped''': "briefly", "occasionally", "frequently", "sometimes", "always" — so the subject is the noun, not the modifier
* '''Role splitting''': "from / caused by / due to / while on" → role='cause'; "with / during / while" → role='context'
* '''Ref resolution''', in priority order:
*# User's meds (<code>pcp_user_meds</code>)
*# Wiki pages in Category:Medicines
*# Effects catalog (<code>pcp_effects</code>)
*# Problems catalog (<code>pcp_problem</code> + <code>pcp_problem_alias</code>)
*# User's diagnoses (<code>pcp_profile_diagnoses</code>)
*# Global ICD diagnosis abbreviations (<code>pcp_diagnosis_abbreviations</code>)
*# Free-text fallback (stored as type='free' for later upgrade)
* '''Episode-shape detection''': "manic episode" → type=mood, subtype=manic; "psychotic break", "panic attack", "anxiety attack", "trauma response" all route to <code>addEpisode</code> instead of <code>addObservation</code>. A date RANGE alone is enough to force <code>is_episode=true</code>.
 
Live preview chips appear under the textarea as you type. Submit routes to <code>pharmacopediaobservation</code> API which writes the row + refs via <code>setEventRefs()</code>.
 
=== Episode form ===
 
Click the "🌀 Episode" button (or the quick-add detects an episode shape) for the structured form:
 
* Type selector: mood / psychotic / anxiety / panic / trauma response / dissociative / substance use / eating / sleep disturbance / pain flare / migraine / medication adjustment / hospitalization / creative surge / spiritual / transcendent / relationship crisis / grief / somatic / other
* Subtype (text + datalist) — for mood: depressive / manic / hypomanic / mixed / dysphoric / euthymic
* Severity slider 0-100 (per precision doctrine)
* Date range via PCPDatePicker locked to range mode
* Title, body, single virus-scanned image, visibility (4-state)
 
=== Visual timeline ===
 
Top of <code>Special:MyLifeStory</code> has a tabbed view: '''Visual timeline''' (default) / '''Card list'''.
 
The visual timeline uses '''vis-timeline 7.7.3''' (vendored Apache-2.0 at <code>resources/vendor/vis-timeline/</code>) with these features:
 
* Swimlanes (toggleable chips): Episodes (range bars), Events, Observations, Keyframes (off by default), Derived
* '''Trait-trajectory overlay''': a synchronized <code>vis.Graph2d</code> draws smooth (centripetal Catmull-Rom interpolated) lines for every keyframe trait series (CATI subscales, PID-5-BF domains, CAT-Q, custom traits like "shyness") DIRECTLY on top of the timeline plot area. Same X-axis; the graph's data + time axes are CSS-hidden so only the lines + points show. Values are normalized to 0-100% within each series so different scales coexist.
* Toolbar: Visual/Card tabs, group toggle chips, magnifier + zoom +/- buttons, Fit visible / Fit everything
* Plain wheel = vertical scroll inside the timeline (520px fixed height); Ctrl+wheel = zoom; Shift+wheel = horizontal pan
* Click empty timeline area: prefills the quick-add textarea with "on YYYY-MM-DD" at that date + scrolls + triggers live preview
* Click an item: routes to its edit form (event / episode / observation, each has its own route)
* Collapsible "Trait series (N)" legend with chip toggles to show/hide individual series
 
=== Edit / delete / duplicate flows ===
 
Each event type has its own edit route under <code>Special:MyLifeStory</code>:
 
* <code>Special:MyLifeStory/edit-observation/&lt;id&gt;</code> — re-parses raw text on save; supports polarity override + date override
* <code>Special:MyLifeStory/edit-episode/&lt;id&gt;</code> — full episode form
* <code>Special:MyLifeStory?edit_event=&lt;id&gt;</code> — existing event / image / keyframe form
 
All three forms have side-by-side "Delete X" + "Duplicate X" buttons. Duplicate copies fields + refs + keyframe traits (NOT images) and redirects to the new row's edit form.
 
=== Upgrade-link UI ===
 
When the parser stored a free-text ref (because the entity wasn't in the user's data at the time), <code>Special:MyRefLinks</code> finds all such refs and offers one-click "link to {match}" buttons. Matches come from the same catalogs the parser checks at write time. A banner on <code>Special:MyLifeStory</code> ("📎 N free-text references could be linked → Review & link") appears when unmatched refs exist.
 
== Per-record sharing subsystem ==
 
A granular per-record sharing system layered on top of the legacy <code>pf_visibility</code> enum without removing it. Resolved by <code>VisibilityResolver</code>; the new model is additive (legacy fallback preserved unless Privacy mode is on).
 
=== Rule types (vr_rule_type) ===
 
* '''<code>private</code>''' — explicit deny (only owner)
* '''<code>public</code>''' — explicit allow (anyone)
* '''<code>users</code>''' — payload <code>{user_ids: [...]}</code>; allow if viewer in list
* '''<code>cohort</code>''' — payload <code>{cohort_id: N}</code>; allow if viewer in <code>pcp_cohort_members</code> for that cohort
* '''<code>link_token</code>''' — payload <code>{token: '...', uses_remaining: null|N}</code>; allow if URL <code>?pcpshare=TOKEN</code> matches
* '''<code>reciprocal</code>''' — allow if viewer has a matching <code>users</code> rule sharing the same shape back to owner
 
Rules scope at three levels: <code>(profile, namespace, key)</code>, <code>(profile, namespace, NULL)</code>, or <code>(profile, '*', NULL)</code>. Most-specific-first matching. Rules can have <code>vr_expires</code> (time-bounded) and <code>vr_revoked</code> (preserves audit trail).
 
=== Privacy mode ===
 
When Privacy mode is ON for a profile, a *-wide <code>private</code> rule is created. <code>VisibilityResolver::canView()</code> short-circuits to false instead of falling through to the legacy <code>pf_visibility</code> check, so only explicit share rules grant access. Default: OFF (back-compat).
 
=== Share dialog ===
 
Triggered by 🔗 chips on assessment reports, profile sections, life-story timeline. Modal with three tabs:
 
* '''People''': username autocomplete (↓/↑/Enter/Esc keyboard nav), per-shared-user pill with × to remove just that user, optional expires DatePicker, "Auto-share back" reciprocal toggle
* '''Link''': "Generate link" creates a <code>link_token</code> rule; copies the URL to clipboard with toast; optional max-uses + expires
* '''Cohorts''': dropdown of the owner's cohorts (managed at <code>Special:MyCohorts</code>); optional expires
 
=== Audit log ===
 
Every permitted view through a rule (not legacy) writes a row to <code>pcp_visibility_view_log</code>. <code>Special:MyShareLog</code> shows the last 200 views with timestamp, viewer (or anonymous IP masked to /24), namespace, key, rule id.
 
== Choice / multi voting ==
 
The <code>&lt;vote&gt;</code> tag's binary up/down mode is unchanged. With <code>type="single"</code> or <code>type="multi"</code> + <code>options="A; B; C"</code> (2-5 entries, semicolon separator), it renders a compact chart-icon chip that expands inline to a radio (single) or checkbox (multi) picker with per-option count bars.
 
Server-side storage:
* <code>pcp_votable_elements.ve_options</code> (JSON array of labels)
* <code>pcp_votable_elements.ve_options_h</code> (first 8 hex of sha256; drift hash)
* <code>pcp_votable_elements.ve_results_policy</code> (live / after-vote / hidden)
* <code>pcp_votes.v_choices</code> (CSV indices)
* <code>pcp_votes.v_options_h</code> (drift hash at vote time)
 
API route: same <code>pharmacopediavote</code> endpoint; presence of <code>choices</code> or <code>options_h</code> params routes to <code>castChoice()</code>. Response includes <code>tally</code> + <code>user_choices</code> (null for binary). Tally hidden per <code>results</code> policy.
 
Drift behavior: if the page editor changes the options list after votes exist, <code>ve_options_h</code> updates and new votes' <code>v_options_h</code> reflects the new value. Existing votes stay but their hash no longer matches (marked stale). New votes whose submitted hash mismatches the live one are rejected — protects against browser cache races. Tallies still aggregate by raw index, so RENAMING an option in place silently turns old votes into new-label votes; reordering is the dangerous case. Appending new options is safe.
 
== ClamAV scan rule (project standard) ==
 
Hard rule, set 2026-05-17: every server-accepted file upload (image, PDF, document, anything) MUST go through <code>VirusScanner::scanFile($path)</code> (<code>includes/VirusScanner.php</code>) BEFORE being moved to permanent storage. Fail-closed: if <code>/usr/bin/clamdscan</code> is unavailable or returns error, the upload is rejected.
 
Wired into:
* <code>LifeStoryStore::addImage()</code> — life events / episodes / observations
* <code>LiteratureStore::storeUploadedPdf()</code> — literature PDFs
* <code>ProviderAppStore::saveUploadedFile()</code> — provider verification documents
 
<code>AttachmentScanner</code> (used by feature-request attachments) is left alone — its status-return model is intentional for the queued-moderation flow there. <code>AntivirusHelper</code> (the old silent-no-op variant) was deleted; all callers consolidated onto <code>VirusScanner</code>.


== Autosave infrastructure ==
== Autosave infrastructure ==
Line 230: Line 413:
! Page !! Purpose
! Page !! Purpose
|-
|-
| <code>Special:MyProfile</code> || Edit your full profile (identity, demographics, personality, dx, meds). Autosave throughout.
| <code>Special:MyProfile</code> || Edit your full profile (identity, demographics, personality, dx, meds). Autosave throughout. Privacy mode toggle + share chips per fieldset.
|-
|-
| <code>Special:UserProfile/&lt;name&gt;</code> || Public profile view (filtered by per-field visibility)
| <code>Special:UserProfile/&lt;name&gt;</code> || Public profile view (filtered by per-field visibility + rule-based access). 🔗 Share chip in subtitle for self-view.
|-
|-
| <code>Special:MyAssessment</code> || Index of rich assessment reports
| <code>Special:MyAssessment</code> || Index of rich assessment reports
|-
|-
| <code>Special:MyAssessment/cati</code>, <code>/catq</code>, <code>/pid5bf</code>, <code>/mbti</code>, <code>/enneagram</code>, <code>/ocean</code> || Rich report per assessment
| <code>Special:MyAssessment/cati</code>, <code>/catq</code>, <code>/pid5bf</code>, <code>/mbti</code>, <code>/enneagram</code>, <code>/ocean</code> || Rich report per assessment; 🔗 Share chip in subtitle for owner
|-
| <code>Special:MyLifeStory</code> || Owner editor + visual timeline + card list. Quick-add observation textarea + Event/Episode buttons. 🔗 Share chip in subtitle. Privacy-mode + free-text-refs banners.
|-
| <code>Special:MyLifeStory/add-episode</code>, <code>/edit-episode/&lt;id&gt;</code> || Episode form (create / edit)
|-
| <code>Special:MyLifeStory/edit-observation/&lt;id&gt;</code> || Observation edit form (re-parses raw text)
|-
| <code>Special:LifeStory/&lt;name&gt;</code> || Public life-story view (read-only, visibility-filtered)
|-
| <code>Special:MyCohorts</code> || Owner-managed groups for share-with-cohort flows; create / rename / delete / add+remove members
|-
| <code>Special:MyShareLog</code> || Who has viewed your shared content (timestamp, viewer, namespace, rule id; anonymous IPs masked)
|-
| <code>Special:MyRefLinks</code> || Bulk linker: find free-text refs in observations that now match a structured entity; one-click upgrade or dismiss
|-
|-
| <code>Special:SaveProfileBlock</code> || AJAX endpoint for autosave (POST-only, JSON response)
| <code>Special:SaveProfileBlock</code> || AJAX endpoint for autosave (POST-only, JSON response)
Line 253: Line 450:
|-
|-
| <code>Special:DeletePharmaElement</code> || Sysop delete tool for any votable element
| <code>Special:DeletePharmaElement</code> || Sysop delete tool for any votable element
|-
| <code>Special:PCPCtrls</code> || Sysop ctrls dashboard (gated by <code>$wgSpecialPageLockdown</code>)
|}
|}


Line 260: Line 459:
! Action !! Purpose
! Action !! Purpose
|-
|-
| <code>pharmacopediavote</code> || Submit / clear binary vote on votable element
| <code>pharmacopediavote</code> || Binary OR choice/multi vote (routes by presence of <code>choices</code> param). Returns tally + user_choices for choice modes; gated by results-policy.
|-
|-
| <code>pharmacopedialikert</code> || Submit problem-efficacy likert (0–100 + −1 DK)
| <code>pharmacopedialikert</code> || Submit problem-efficacy likert (0–100 + −1 DK)
Line 283: Line 482:
|-
|-
| <code>pharmacopedialiteratureadd</code>, <code>literaturedelete</code> || Literature attachment ops
| <code>pharmacopedialiteratureadd</code>, <code>literaturedelete</code> || Literature attachment ops
|-
| <code>pharmacopediaobservation</code> || op=preview / op=submit for plain-text observations (routes to addObservation OR addEpisode)
|-
| <code>pharmacopediavisrules</code> || Visibility rule CRUD (list / create / update / revoke / newtoken)
|-
| <code>pharmacopediausersearch</code> || Username autocomplete for share-with-people picker
|-
| <code>pharmacopediacohorts</code> || Cohort CRUD + membership
|-
| <code>pharmacopediarefupgrade</code> || Free-text ref linker (op=candidates / apply / dismiss)
|}
|}


Line 331: Line 540:
! Table !! Purpose
! Table !! Purpose
|-
|-
| <code>pcp_votable_elements</code> || Stable per-(page, slug) handle reused by votes / likert / comments
| <code>pcp_votable_elements</code> || Stable per-(page, slug) handle reused by votes / likert / comments. Now also <code>ve_options</code> + <code>ve_options_h</code> + <code>ve_results_policy</code> for choice votes.
|-
|-
| <code>pcp_votes</code> || Binary +1/−1 votes
| <code>pcp_votes</code> || Binary +1/−1 votes (v_value) AND choice/multi votes (v_choices CSV + v_options_h drift hash)
|-
|-
| <code>pcp_likert_reports</code> || Problem efficacy (0–100 + −1 DK), TINYINT signed
| <code>pcp_likert_reports</code> || Problem efficacy (0–100 + −1 DK), TINYINT signed
Line 347: Line 556:
| <code>pcp_user_profiles</code> || Per-user profile meta (alias, attribution, voter hash)
| <code>pcp_user_profiles</code> || Per-user profile meta (alias, attribution, voter hash)
|-
|-
| <code>pcp_user_profile_fields</code> || Generic key-value field store: (namespace, key, num, text, visibility)
| <code>pcp_profile_fields</code> || Generic key-value field store: (namespace, key, num, text, visibility)
|-
|-
| <code>pcp_profile_diagnoses</code> || Per-user diagnoses (system, code, description, status, origin, severity 0–100, disability 0–100, dates, notes, visibility)
| <code>pcp_profile_diagnoses</code> || Per-user diagnoses (system, code, description, status, origin, severity 0–100, disability 0–100, dates, notes, visibility)
Line 359: Line 568:
| <code>pcp_experience_reports</code> || Experience reports (pending → approved); efficacy + burden 0–100; route + schedule; stop-reasons JSON; patient-count min + max
| <code>pcp_experience_reports</code> || Experience reports (pending → approved); efficacy + burden 0–100; route + schedule; stop-reasons JSON; patient-count min + max
|-
|-
| <code>pcp_life_events</code>, <code>pcp_life_traits</code>, <code>pcp_life_images</code> || Life Story keyframes (auto-generated from assessments, manually authorable)
| <code>pcp_life_events</code> || Timeline events. Columns: le_type (0=story, 1=image, 2=keyframe, 3=observation, 4=episode), le_polarity, le_raw_text (parser input), le_episode_type, le_episode_subtype, le_severity, le_date_struct (PCPDatePicker JSON; supports range)
|-
| <code>pcp_life_event_refs</code> || Join table linking events to entities (med / effect / problem / diagnosis / med_page / diagnosis_code / free); role (subject / cause / context / symptom / trigger / treatment)
|-
| <code>pcp_life_traits</code> || Keyframe trait values (assessment subscale snapshots → trajectory graph)
|-
| <code>pcp_life_images</code> || Image attachments (ClamAV-scanned)
|-
| <code>pcp_visibility_rules</code> || Per-(profile, namespace, key) sharing rules. Types: public / private / users / cohort / link_token / reciprocal. Optional vr_expires, vr_revoked, vr_attribution, vr_label.
|-
| <code>pcp_cohorts</code>, <code>pcp_cohort_members</code> || Owner-managed user groups for share-with-cohort flows
|-
| <code>pcp_visibility_view_log</code> || Audit trail of rule-permitted views (Special:MyShareLog)
|-
|-
| <code>pcp_literature</code> || Citation attachments
| <code>pcp_literature</code> || Citation attachments
Line 366: Line 587:
== Notable lessons learned ==
== Notable lessons learned ==


* '''Cargo string fields cap at ~300 chars.''' <code>structure</code> and <code>mechanism</code> on MedTemplate are short VARCHARs; long prose goes in MEDIUMBLOB sections (pharmacokinetics, pharmacodynamics, intro). Overruns silently lose Cargo data (MySQL Error 1406).
* '''Cargo string fields cap at ~300 chars.''' <code>structure</code> and <code>mechanism</code> on MedTemplate are short VARCHARs; long prose goes in MEDIUMBLOB sections. Overruns silently lose Cargo data (MySQL Error 1406).
* '''VARBINARY + LOWER() is a no-op.''' MariaDB's LOWER() returns binary types unchanged. <code>pcp_diagnosis_abbreviations</code> was migrated VARBINARY → VARCHAR/utf8mb4 (2026-05-17) so case-insensitive LIKE works natively without CONVERT() wrappers.
* '''VARBINARY + LOWER() is a no-op.''' MariaDB's LOWER() returns binary types unchanged. <code>pcp_diagnosis_abbreviations</code> was migrated VARBINARY → VARCHAR/utf8mb4 so case-insensitive LIKE works natively without CONVERT() wrappers.
* '''FlaggedRevs locks template inclusions by default.''' Config fix: <code>$wgFlaggedRevsHandleIncludes=0</code> + remove NS_TEMPLATE from <code>$wgFlaggedRevsNamespaces</code> via an extension-function callback (MW 1.46 array config concatenation behavior).
* '''FlaggedRevs locks template inclusions by default.''' Config fix: <code>$wgFlaggedRevsHandleIncludes=0</code> + remove NS_TEMPLATE from <code>$wgFlaggedRevsNamespaces</code> via an extension-function callback.
* '''CSP must allow Cloudflare Turnstile and any other 3rd-party widget script source.''' Symptom: generic "There are problems with some of your input" on form submit. Audit script-src / frame-src / connect-src / style-src whenever adding any 3rd-party JS widget.
* '''CSP must allow Cloudflare Turnstile and any other 3rd-party widget script source.''' Audit script-src / frame-src / connect-src / style-src whenever adding any 3rd-party JS widget.
* '''Sidebar cache must be purged after CLI <code>maintenance/edit.php</code> writes''' to <code>MediaWiki:Sidebar</code> or other chrome pages: <code>curl -X POST .../api.php?action=purge&titles=MediaWiki:Sidebar</code>.
* '''Sidebar cache must be purged after CLI <code>maintenance/edit.php</code> writes''' to <code>MediaWiki:Sidebar</code> or other chrome pages.
* '''CLI E_USER_DEPRECATED suppressed in LocalSettings.php''' (EmbedVideo / FlaggedRevs spam ~6 lines per maintenance/run.php call on MW 1.46). Web behavior unaffected.
* '''CLI E_USER_DEPRECATED suppressed in LocalSettings.php''' (EmbedVideo / FlaggedRevs spam on MW 1.46). Web behavior unaffected.
* '''MW ApiResult drops keys starting with <code>_</code>.''' Any field whose key starts with underscore in an ApiResult payload is treated as internal metadata and stripped. Rename to plain identifier. Bit Phase 2-4 of visibility-rules subsystem.
* '''MW API serializes int-keyed assoc arrays unreliably.''' <code>[23 => 'Alice']</code> may arrive as <code>{"23": "Alice"}</code> or worse. Always use list-of-objects (<code>[{"id": 23, "name": "Alice"}]</code>) for id-to-value maps across the API boundary.
* '''PHP single-quoted strings don't interpret <code>\xNN</code> byte escapes.''' <code>'\xF0\x9F\x94\x97'</code> is 16 literal chars, NOT the 4-byte UTF-8 for 🔗. Use literal Unicode char or double-quoted <code>\u{1F517}</code>. Bit three times in one session.
* '''PHP "0" is FALSY.''' <code>!"0"</code> evaluates to TRUE. So <code>if ( !$x )</code> silently treats <code>"0"</code> (string zero) as missing. Bit choice-vote tallying when voter picked option index 0; vote was IN the DB but invisible to readers. Use <code>=== null || === ''</code> for "missing or blank" intent. <code>empty()</code> has the same problem.
* '''<code>&lt;span&gt;</code> can't contain <code>&lt;p&gt;</code>.''' MediaWiki auto-wraps tag content in <code>&lt;p&gt;</code> when there are newlines; browsers auto-close any <code>&lt;span&gt;</code> before the <code>&lt;p&gt;</code>, scattering child elements into wrong DOM positions. Use <code>&lt;div&gt;</code> for parser-tag wrappers whose content can span paragraphs (choice votes, life-story cards).
* '''vis.Graph2d group <code>style</code> belongs at TOP LEVEL''' not nested in <code>options</code>. Nesting silently fails (no error, just invisible lines). Bit the trait-trajectory graph on first build.


== Hooks ==
== Hooks ==
Line 384: Line 611:


* <code>$wgPharmacopediaInteractionCategoryMarker</code> (default <code>Category:MedCategory</code>): only categories tagged with this marker appear in the add-interaction modal
* <code>$wgPharmacopediaInteractionCategoryMarker</code> (default <code>Category:MedCategory</code>): only categories tagged with this marker appear in the add-interaction modal
* <code>$wgPharmacopediaVoteHashSecret</code> (required): HMAC secret for <code>v_voter_hash</code> so vote rows can't be mapped back to user accounts by anyone reading the DB without the secret
* (Various permission-grant arrays via standard MW <code>$wgGroupPermissions</code>)
* (Various permission-grant arrays via standard MW <code>$wgGroupPermissions</code>)


Line 393: Line 621:
   |  |-- Hooks.php
   |  |-- Hooks.php
   |  |-- *Tag.php                  (parser tags: VoteTag, EffectTag, ProblemTag, ...)
   |  |-- *Tag.php                  (parser tags: VoteTag, EffectTag, ProblemTag, ...)
   |  |-- *Store.php                (data access: EffectStore, ProblemStore, ...)
   |  |-- *Store.php                (data access: EffectStore, ProblemStore, ElementStore, LifeStoryStore, ...)
   |  |-- UserProfileStore.php      (the big one; profile / dx / meds / abbreviations)
   |  |-- UserProfileStore.php      (the big one; profile / dx / meds / abbreviations)
  |  |-- VisibilityResolver.php    (per-record sharing decision engine)
  |  |-- VirusScanner.php          (ClamAV gate; fail-closed)
  |  |-- ObservationParser.php    (plain-text -> structured observation)
   |  |-- SpecialMyProfile.php      (the user profile editor; large)
   |  |-- SpecialMyProfile.php      (the user profile editor; large)
   |  |-- SpecialMyAssessment.php  (rich reports for all 6 assessments; large)
   |  |-- SpecialMyAssessment.php  (rich reports for all 6 assessments; large)
  |  |-- SpecialMyLifeStory.php    (life-story editor + visual timeline; large)
  |  |-- SpecialMyCohorts.php      (cohort CRUD UI)
  |  |-- SpecialMyShareLog.php    (audit log viewer)
  |  |-- SpecialMyRefLinks.php    (bulk free-text -> structured upgrader)
   |  |-- SpecialSaveProfileBlock.php (autosave AJAX endpoint)
   |  |-- SpecialSaveProfileBlock.php (autosave AJAX endpoint)
   |  |-- Special*.php              (other special pages)
   |  |-- Special*.php              (other special pages)
   |  |-- ProfileDatasets.php      (countries, languages, genders, religions, etc.)
   |  |-- ProfileDatasets.php      (countries, languages, genders, religions, etc.)
   |  |-- DatePicker.php            (range / possibility-mix date widget backend)
   |  |-- DatePicker.php            (range / possibility-mix date widget backend + injectBirthdayContextOnce)
   |  |-- Assessments/
   |  |-- Assessments/
   |  |  |-- Cati.php, CatiNorms.php
   |  |  |-- Cati.php, CatiNorms.php
Line 407: Line 642:
   |  |  |-- Mbti.php
   |  |  |-- Mbti.php
   |  |  |-- Enneagram.php
   |  |  |-- Enneagram.php
  |  |  |-- OceanNorms.php
   |  |  `-- Raadsr.php            (deprecated 2026-05-17 in favor of CATI, kept for archival reads)
   |  |  `-- Raadsr.php            (deprecated 2026-05-17 in favor of CATI, kept for archival reads)
   |  `-- Api/
   |  `-- Api/
   |      `-- (one class per API module)
  |      |-- VoteApi.php, EffectApi.php, ...
  |      |-- ObservationApi.php
  |      |-- VisibilityRulesApi.php, UserSearchApi.php, CohortsApi.php
   |      `-- RefUpgradeApi.php
   |-- resources/
   |-- resources/
   |  |-- ext.pharmacopedia.js         (single IIFE: chip-picker, dx autocomplete, BFI-10 compute, ...)
   |  |-- ext.pharmacopedia.js             (single IIFE: chip-picker, dx autocomplete, vote logic, ...)
   |  |-- ext.pharmacopedia.blocksave.js (debounced autosave per block)
   |  |-- ext.pharmacopedia.blocksave.js   (debounced autosave per block)
   |  |-- ext.pharmacopedia.css
   |  |-- ext.pharmacopedia.css
   |  |-- ext.pharmacopedia.datepicker.js + .css
   |  |-- ext.pharmacopedia.datepicker.js + .css
  |  |-- ext.pharmacopedia.timepicker.js + .css
  |  |-- ext.pharmacopedia.share.js + .css
  |  |-- ext.pharmacopedia.observation.js + .css
  |  |-- ext.pharmacopedia.refupgrade.js + .css
  |  |-- ext.pharmacopedia.lifetimeline.js + .css
  |  |-- ext.pharmacopedia.lifegraph.js + .css
  |  |-- ext.pharmacopedia.kitsync.js
  |  `-- vendor/vis-timeline/              (vis-timeline 7.7.3, Apache-2.0 + MIT)
  |      |-- vis-timeline-graph2d.min.js
  |      |-- vis-timeline-graph2d.min.css
  |      `-- LICENSE
   |-- sql/
   |-- sql/
   |  |-- (one .sql per table; patches as patch-*.sql)
   |  |-- (one .sql per table; patches as patch-*.sql)

Revision as of 04:05, 18 May 2026

Pharmacopedia extension specification

Version: 0.9.3 · Requires: MediaWiki >= 1.46.0 · PHP >= 8.5 Author: MDElliottMD · License: GPL-2.0-or-later Source: /var/www/mediawiki/extensions/Pharmacopedia/

The Pharmacopedia extension turns a MediaWiki install into a structured, community-edited medicine reference with rich user-profile, assessment, life-story, and visibility-sharing infrastructure. It adds parser tags, special pages, API modules, a chip-picker / autosave UI framework, a vis-timeline-based visual life timeline, a granular per-record sharing subsystem, and a database schema that together support:

  • Structured medicine pages via the {{MedTemplate}} template
  • Per-user rating on effects, problems, titration strategies, anecdotes, and drug-drug interactions (continuous 0–100 sliders, ±100 valence; no 0–5 likert anywhere)
  • Binary AND choice/multi voting on arbitrary content (type="single" / type="multi" with 2-5 options, results-visibility policy per element)
  • Two-perspective data capture (personal vs. provider) wherever clinically meaningful
  • User profile with dimensional personality / autism assessments (CATI, CAT-Q, MBTI, Enneagram, PID-5-BF, OCEAN/BFI-10) and rich auto-generated reports
  • Diagnosis autocomplete backed by ~41,500 ICD-10-CM, ICD-11, and DSM-5 codes
  • Life-story timeline: visual swimlanes (vis-timeline 7.7.3, vendored Apache-2.0) plus a synchronized trait-trajectory overlay (vis.Graph2d); card-list view alongside; quick-add free-text observation parser; range-date episode form with severity slider
  • Per-record sharing subsystem: rule types include public, private, users, cohort, link_token, reciprocal; time-bounded; audit log of who-viewed-what; bulk free-text → structured ref upgrader; privacy mode that disables legacy fallback
  • Chip-picker / autosave / slider-precise-input UI framework shared across editor surfaces
  • Date + time + range kit: PCPDatePicker (point/range/possibility), PCPTimePicker (fuzzy time-only: "4p", "noon", "quarter past 6")
  • Verified-provider role with document-based verification
  • Fail-closed ClamAV scan on every image / file upload (hard project rule)

Precision doctrine

A standing design rule (memorialised 2026-05-17) that shapes every storage / UI decision in the extension:

  • No bucketing where a number or free-text will do. Income is numeric + currency, not a 5-band dropdown. Education keeps the bucketed dropdown and adds numeric years of schooling + free-text field of study.
  • No single-select where multi-select reflects reality. Languages, gender identities, ethnicities, pronouns, religion, marital status, stop-reasons, all use chip-pickers (with optional severity per chip where relevant).
  • No forced category where a continuous score works. All assessments (Enneagram 9 type sliders, MBTI 4 dichotomy sliders, OCEAN 5 trait sliders, CATI/CAT-Q/PID-5-BF items) use continuous 0–100 sliders, never radio buttons or button rows. Valence is ±100, not ±3.
  • Storage in canonical form, UI converts at display. Heights stored cm regardless of user's preferred unit (cm or ft+in); ICD codes stored as ISO; date capture as range / possibility-mix JSON.
  • Browser auto-fill as suggestion only. Country chip pre-fills from navigator.language; languages from navigator.languages; time zone from Intl.DateTimeFormat().resolvedOptions().timeZone. User can always edit or remove.
  • Always allow custom free-text where the curated list might miss someone. Chip-pickers accept Enter-to-add custom chips for all picklists except ISO-coded ones (country, language).

High-level architecture

  • Backend (PHP): includes/, one class per parser tag, store, special page, or API module. Auto-loaded under MediaWiki\Extension\Pharmacopedia\. Assessment classes under includes/Assessments/. API modules under includes/Api/.
  • Frontend (JS): multiple ResourceModules per surface area:
    • ext.pharmacopedia: main IIFE (chip-picker, dx autocomplete, BFI-10 compute, vote logic for both binary and choice/multi)
    • ext.pharmacopedia.blocksave: debounced autosave per block (race-safe)
    • ext.pharmacopedia.datepicker: range / possibility-mix date widget
    • ext.pharmacopedia.timepicker: time-only widget (fuzzy parsing, extracted from DatePicker)
    • ext.pharmacopedia.share: per-record share dialog (People / Link / Cohorts tabs)
    • ext.pharmacopedia.observation: quick-add observation textarea + live preview
    • ext.pharmacopedia.refupgrade: bulk linker for free-text → structured refs
    • ext.pharmacopedia.vis-timeline-vendor: vis-timeline 7.7.3 (vendored)
    • ext.pharmacopedia.lifetimeline: visual life-story timeline + swimlanes + toolbar
    • ext.pharmacopedia.lifegraph: trait-trajectory overlay (vis.Graph2d) synced to timeline
    • ext.pharmacopedia.kitsync: glue that propagates kit-widget changes into legacy hidden inputs + drives privacy-mode toggle
  • Styles (CSS): shared dark-theme palette (black / dark-grey / purple / white primary; red / green / blue / teal sparingly for semantic distinction).
  • Schema: sql/, ~25 core tables plus migration patches. Picked up via LoadExtensionSchemaUpdates hook.

Parser tags

Registered via Hooks::onParserFirstCallInit:

Tag Purpose Class
<vote> after-vote\|hidden" for tally-visibility policy. VoteTag
<effect> Therapeutic or adverse effect; patient + provider perspectives; provider freq slider 0–100; shared valence slider ±100 EffectTag
<discuss> Threaded comment widget CommentTag
<effectsummary> Roll-up aggregate header EffectSummaryTag
<titration> Titration strategy card with up/down vote TitrationTag
<anecdote> Personal or provider story with up/down vote AnecdoteTag
<problem> A problem (formerly "indication") the medicine addresses; 0–100 efficacy likert slider + "don't know" toggle ProblemTag
<pharmaInteractions/> Self-closing; renders the Interactions section for the current page InteractionTag
<pharmaExperience/> Self-closing; renders the Experience report form (efficacy, burden, dose, route, schedule, stop-reasons) ExperienceTag

All non-self-closing tags take a slug argument and (where relevant) a title, label, author, ref, or perspective.

Tag wikitext examples

<problem slug="depression" title="Major depressive disorder"
  author="MDElliottMD">First-line for moderate to severe MDD.</problem>

<effect slug="nausea" label="Nausea"/>

<effect ref="hyperkalemia"/>   <!-- ref to global effect library -->

<titration slug="slow-start-elderly" title="Slow start (elderly)"
  author="MDElliottMD">Begin at 10 mg q AM; titrate by 10 mg every 14 days.</titration>

<anecdote slug="qi8sg2" perspective="provider"
  author="MDElliottMD">One patient developed serotonin syndrome at week 3...</anecdote>

<pharmaInteractions/>
<pharmaExperience/>

<vote slug="fav-color" type="single" options="Red; Blue; Green">
What's your favorite color?</vote>

<vote slug="side-effects" type="multi" results="after-vote"
  options="Dry mouth; Insomnia; Anxiety; Headache; None">
Which side effects did you experience?</vote>

Voting / rating semantics

Element Scale Perspectives Storage
Vote tag (binary) +1 / −1 binary single pcp_votes.v_value
Vote tag (single-choice) one of 2-5 options single pcp_votes.v_choices (CSV index)
Vote tag (multi-choice) any subset of 2-5 options single pcp_votes.v_choices (CSV indices)
Titration +1 / −1 binary single pcp_votes
Anecdote +1 / −1 binary single (perspective is metadata) pcp_votes
Problem (efficacy likert) 0–100 continuous slider, optional "Don't know" (-1) single pcp_likert_reports
Effect (patient) experienced ∈ {yes, no, unsure} + valence ±100 slider patient pcp_effect_reports (perspective=1)
Effect (provider) frequency 0–100 continuous slider + "Don't know" (-1) + valence ±100 slider provider pcp_effect_reports (perspective=2)
Interaction experience 1–5 + valence ±100 slider + optional note user + provider, separate aggregates pcp_interaction_reports

Choice / multi vote elements expose per-option tallies via tallyChoices() on demand. Per-option bars render inline in the picker. The results attribute gates tally visibility:

  • live (default) — tally always visible
  • after-vote — tally hidden until viewer has voted
  • hidden — tally never shown (only options + "thanks" on submit)

Server-side options-hash (ve_options_h) detects post-vote option-list edits; the API rejects new votes whose submitted hash doesn't match the live one. Voter identities are stored as HMAC-SHA256 (v_voter_hash) so admins reading the DB cannot map votes to user accounts without the HMAC secret.

Server-side aggregates: n, mean of the rating field, and (for interactions) severe = (vmean ≤ −83.0) (rescaled from the original ±3-scale −2.5). Aggregates are recomputed and returned by every report-submit API call so the row re-renders in place without a page reload.

Effect bucketing

When a wiki <ul> contains only <effect> cards, JavaScript groups them into buckets by the provider frequency mean (data-fmean):

Bucket fmean band Default state
Common > 20 expanded, always visible
Uncommon > 5 and ≤ 20 collapsed
Rare ≤ 5, provider vmean > −83 collapsed
Rare but Severe ≤ 5 and vmean ≤ −83 expanded by default, red highlight
Not yet rated no provider data (n=0) collapsed, only renders if non-empty

The vmean ≤ −83 threshold is also the trip-wire for the "severe" red treatment on interaction rows.

User profile

Special:MyProfile is the user-facing editor for everything personal. Every block on it autosaves on a 800 ms debounce (see Autosave infrastructure).

Visible at top: a Privacy-mode panel toggling whether legacy public fallback applies (privacy-mode ON = only explicit share rules grant access; OFF = the field-level pf_visibility enum applies).

🔗 Share chips appear in each fieldset legend (Demographics, Diagnoses, Medicines) so the owner can scope a share-rule to that namespace via the modal Share dialog.

Block list

  • Identity (display alias, default attribution, experience-report visibility)
  • Demographics (full chip-picker rebuild, see below)
  • Personality (Big Five OCEAN sliders + collapsible assessments)
  • Enneagram (9 type sliders + 45-item screening test)
  • MBTI (4 dichotomy sliders + 32-item OEJTS test)
  • Personality / autism assessments (PID-5-BF, CATI, CAT-Q, each as a collapsible inline test)
  • Diagnoses (multi-row with ICD-10-CM + ICD-11 autocomplete, severity slider 0–100, disability slider 0–100, status, origin, dates, notes)
  • Medicines I have tried (multi-row with med-name autocomplete, dose, route 16-option dropdown, schedule with datalist suggestions, efficacy + burden sliders 0–100, periods via date-picker)

Demographics (chip-picker / structured-widget rebuild)

All categorical demographics use the chip-picker widget (single or multi, with optional primary marker, optional custom free-text). All quantitative demographics use numeric inputs or structured composite widgets.

  • Birthday DatePicker (single / range / possibility-mix)
  • Sex assigned at birth single-select (clinical category)
  • Gender identity multi-select chip-picker, 27 common terms + custom
  • Pronouns multi-select chip-picker, 17 common sets + custom
  • Ethnicity / race multi-select chip-picker, 23 broad categories + custom
  • Country of residence chip-picker single-value, ISO 3166 list (~100 entries), auto-suggested from navigator.language
  • Languages chip-picker multi-select with ★ primary marker, ISO 639-1 list (~70 entries with endonyms), auto-suggested from navigator.languages
  • Height / weight unit toggle (Metric cm/kg or US ft+in/lb); stored canonically as cm/kg regardless
  • Handedness single-select (3 options)
  • Smoking structured widget: status + cigs/day + years smoked + quit date (PCPDatePicker); auto-computes pack-years
  • Alcohol structured widget: drinks/week + typical drink type + max one occasion
  • Education bucketed highest-level + numeric years + free-text field of study
  • Employment bucketed status + free-text occupation + numeric hours/week
  • Income numeric amount + currency selector (20 options) + individual/household scope
  • Marital / relationship status chip-picker single + custom
  • Religion / spirituality chip-picker single + custom, 36 traditions + secular stances
  • Housing chip-picker single + custom
  • Number of children numeric
  • Time zone free text, auto-detects IANA TZ on first load if empty
  • Chronotype / sleep schedule two PCPTimePicker widgets (typical bedtime, typical wake; fuzzy parsing accepts "10p", "bedtime", "quarter past 6")
  • Political orientation two-axis compass sliders (economic ±100, social ±100)

Diagnosis subsystem

Diagnoses are stored in pcp_profile_diagnoses with autocomplete backed by pcp_diagnosis_abbreviations (~41,500 rows):

System Rows Notes
ICD-10-CM 25,542 CMS FY2026 valid-codes file, chapters A B C D E F G H I J K L M N O P Q R U + 553 friendly-alias rows (mdd, adhd, stroke, htn, etc.)
ICD-11 15,823 WHO MMS Apr 2026 linearization, chapters 01–24 except billing (22) / external causes (23) / extension modifiers (X) / functioning assessment (V) + 361 friendly-alias rows
DSM-5 32 Legacy hand-seed for codes without ICD equivalents
Other 34 somatic, unofficial, ICD-10 (WHO), instrument

Skipped intentionally: ICD-10-CM S/T (injury body) ~41k codes, V/W/X/Y (external causes) ~7.5k codes; ICD-11 chapter 22 (injury), 23 (external causes), 25 (special purposes), V (functioning scales), X (17.7k extension modifiers). These are billing scaffolding, not diagnoses.

Autocomplete via action=pharmacopediadxsearch, multi-token AND search (e.g. "ADHD inattentive" matches the F90.0 row that contains both substrings); ORDER BY FIELD(da_system, 'ICD-10-CM', 'ICD-11', 'DSM-5', ...) so ICD-10-CM leads, then ICD-11, then everything else.

Personality / autism assessments

Six assessments, all with continuous-slider items, "Not sure" toggle per item, auto-computed subscale + total scores, and a rich auto-generated report at Special:MyAssessment/{key}:

Assessment Items Score range Cutoffs / threshold Report
CATI 42 (6 subscales) 1–5 per item, sum per subscale 148 / 139 / 141 / 156 (English 2025 gender-specific) gender-specific scoring against English 2025 normative tables (12,253-row CatiNorms.php from OSF supplementary)
CAT-Q 25 (3 subscales) 1–7 per item, sum per subscale Total ≥ 110 + per-subscale cutoffs (NeurodivUrgent recalibration) subscale narratives, top-item analysis
PID-5-BF 25 (5 domains) 0–3 per item, mean per domain mean ≥ 2.0 per domain domain narratives, cross-system mapping (DSM-5 AMPD ↔ ICD-11 PD ↔ Big Five)
MBTI 32 OEJTS items + 4 direct dichotomy sliders ±2 per axis none (dimensional treatment, no forced categorisation) 4-axis Position column with letter + strength + bar, cognitive function stack, Big Five mapping, top-item analysis
Enneagram 45 (5 per type × 9 types) 0–100 per type none (no clinical cutoffs for typology) hero banner (primary + wing + tritype), 9-bar profile, primary deep-dive, wing analysis, centers, Hornevian + Harmonic groups, stress / growth lines, cross-system map (Big Five, MBTI)
OCEAN (Big Five) 5 direct sliders + optional BFI-10 (10 items) 0–100 per trait none (personality, not pathology) trait deep-dives (high / mid / low at your score), BFI-10 item table, cross-system mapping (MBTI ↔ Enneagram ↔ PID-5-BF) pulling live data from the profile

All assessment items submit raw responses to pcp_profile_fields under namespace {key}_raw (e.g. cati_raw); the computed scores live under namespace {key} with keys like subscale_SOC, total, plus a taken_at timestamp. Re-scoring happens automatically on every save (autosave fires scoreResponses() on the full raw set, skipping "unsure" rows). Each completed assessment also auto-upserts a Life-story keyframe row (see Life-story timeline) so trajectories plot over time.

Life-story timeline

Special:MyLifeStory is the owner-facing editor + viewer for everything time-anchored about the user. Backed by pcp_life_events with extension columns for episode / observation / keyframe metadata.

Event types

le_type Meaning Notes
0 (TYPE_STORY) Plain timeline entry title, body, optional image
1 (TYPE_IMAGE) Image-primary entry same shape, image is the main payload
2 (TYPE_KEYFRAME) Auto-created assessment snapshot populated by upsertAssessmentKeyframe on every assessment save
3 (TYPE_OBSERVATION) Plain-text observation created via the quick-add textarea; parser extracts date, polarity, refs
4 (TYPE_EPISODE) Time-bounded period start + end (PCPDatePicker range mode); type / subtype / severity 0-100

Quick-add observation

A textarea at the top of Special:MyLifeStory (and a 📝 modal trigger on Special:MyProfile) accepts plain text like:

anxiety from bupropion in jan 2020
did not experience anxiety from bupropion in feb 2018
panic attack 2 months ago
depressed as a freshman
i had a manic episode sep 1 2020 till nov 15 2020
no insomnia while on melatonin in summer 2023
felt great on christmas 2020
happiest on my 30th birthday

ObservationParser::parse() extracts:

  • Date (point or range): ISO, MM/DD/YYYY, "Month D YYYY", "Month YYYY", "Season YYYY", "early/mid/late YYYY", bare year, decades ("2010s"); date RANGES via "X to Y", "X till Y", "from X to Y", "X - Y"; relative-to-now ("yesterday", "last week/month/year", "N months ago", "a few weeks ago"); holidays (christmas, halloween, new year's, valentine's, july 4th, thanksgiving with computed 4th-Thursday-of-Nov, MLK / Memorial / Labor day); age-relative ("7y8mo", "51.2yo", "at age 14", "ages 2-10"); life stages ("in childhood", "as a teen", "as a freshman", "junior year"); Nth birthday ("my 30th birthday")
  • Polarity (negation detection): "not", "didn't", "did not experience", "never", "without", "denied" → polarity=0 (negative); else 1 (positive)
  • Leading verbs stripped: "I was diagnosed with", "I took", "started taking", "tried", "was on", "experienced", "felt" — so the subject is the noun, not the verb
  • Adverbs stripped: "briefly", "occasionally", "frequently", "sometimes", "always" — so the subject is the noun, not the modifier
  • Role splitting: "from / caused by / due to / while on" → role='cause'; "with / during / while" → role='context'
  • Ref resolution, in priority order:
    1. User's meds (pcp_user_meds)
    2. Wiki pages in Category:Medicines
    3. Effects catalog (pcp_effects)
    4. Problems catalog (pcp_problem + pcp_problem_alias)
    5. User's diagnoses (pcp_profile_diagnoses)
    6. Global ICD diagnosis abbreviations (pcp_diagnosis_abbreviations)
    7. Free-text fallback (stored as type='free' for later upgrade)
  • Episode-shape detection: "manic episode" → type=mood, subtype=manic; "psychotic break", "panic attack", "anxiety attack", "trauma response" all route to addEpisode instead of addObservation. A date RANGE alone is enough to force is_episode=true.

Live preview chips appear under the textarea as you type. Submit routes to pharmacopediaobservation API which writes the row + refs via setEventRefs().

Episode form

Click the "🌀 Episode" button (or the quick-add detects an episode shape) for the structured form:

  • Type selector: mood / psychotic / anxiety / panic / trauma response / dissociative / substance use / eating / sleep disturbance / pain flare / migraine / medication adjustment / hospitalization / creative surge / spiritual / transcendent / relationship crisis / grief / somatic / other
  • Subtype (text + datalist) — for mood: depressive / manic / hypomanic / mixed / dysphoric / euthymic
  • Severity slider 0-100 (per precision doctrine)
  • Date range via PCPDatePicker locked to range mode
  • Title, body, single virus-scanned image, visibility (4-state)

Visual timeline

Top of Special:MyLifeStory has a tabbed view: Visual timeline (default) / Card list.

The visual timeline uses vis-timeline 7.7.3 (vendored Apache-2.0 at resources/vendor/vis-timeline/) with these features:

  • Swimlanes (toggleable chips): Episodes (range bars), Events, Observations, Keyframes (off by default), Derived
  • Trait-trajectory overlay: a synchronized vis.Graph2d draws smooth (centripetal Catmull-Rom interpolated) lines for every keyframe trait series (CATI subscales, PID-5-BF domains, CAT-Q, custom traits like "shyness") DIRECTLY on top of the timeline plot area. Same X-axis; the graph's data + time axes are CSS-hidden so only the lines + points show. Values are normalized to 0-100% within each series so different scales coexist.
  • Toolbar: Visual/Card tabs, group toggle chips, magnifier + zoom +/- buttons, Fit visible / Fit everything
  • Plain wheel = vertical scroll inside the timeline (520px fixed height); Ctrl+wheel = zoom; Shift+wheel = horizontal pan
  • Click empty timeline area: prefills the quick-add textarea with "on YYYY-MM-DD" at that date + scrolls + triggers live preview
  • Click an item: routes to its edit form (event / episode / observation, each has its own route)
  • Collapsible "Trait series (N)" legend with chip toggles to show/hide individual series

Edit / delete / duplicate flows

Each event type has its own edit route under Special:MyLifeStory:

  • Special:MyLifeStory/edit-observation/<id> — re-parses raw text on save; supports polarity override + date override
  • Special:MyLifeStory/edit-episode/<id> — full episode form
  • Special:MyLifeStory?edit_event=<id> — existing event / image / keyframe form

All three forms have side-by-side "Delete X" + "Duplicate X" buttons. Duplicate copies fields + refs + keyframe traits (NOT images) and redirects to the new row's edit form.

When the parser stored a free-text ref (because the entity wasn't in the user's data at the time), Special:MyRefLinks finds all such refs and offers one-click "link to {match}" buttons. Matches come from the same catalogs the parser checks at write time. A banner on Special:MyLifeStory ("📎 N free-text references could be linked → Review & link") appears when unmatched refs exist.

Per-record sharing subsystem

A granular per-record sharing system layered on top of the legacy pf_visibility enum without removing it. Resolved by VisibilityResolver; the new model is additive (legacy fallback preserved unless Privacy mode is on).

Rule types (vr_rule_type)

  • private — explicit deny (only owner)
  • public — explicit allow (anyone)
  • users — payload {user_ids: [...]}; allow if viewer in list
  • cohort — payload {cohort_id: N}; allow if viewer in pcp_cohort_members for that cohort
  • link_token — payload {token: '...', uses_remaining: null|N}; allow if URL ?pcpshare=TOKEN matches
  • reciprocal — allow if viewer has a matching users rule sharing the same shape back to owner

Rules scope at three levels: (profile, namespace, key), (profile, namespace, NULL), or (profile, '*', NULL). Most-specific-first matching. Rules can have vr_expires (time-bounded) and vr_revoked (preserves audit trail).

Privacy mode

When Privacy mode is ON for a profile, a *-wide private rule is created. VisibilityResolver::canView() short-circuits to false instead of falling through to the legacy pf_visibility check, so only explicit share rules grant access. Default: OFF (back-compat).

Share dialog

Triggered by 🔗 chips on assessment reports, profile sections, life-story timeline. Modal with three tabs:

  • People: username autocomplete (↓/↑/Enter/Esc keyboard nav), per-shared-user pill with × to remove just that user, optional expires DatePicker, "Auto-share back" reciprocal toggle
  • Link: "Generate link" creates a link_token rule; copies the URL to clipboard with toast; optional max-uses + expires
  • Cohorts: dropdown of the owner's cohorts (managed at Special:MyCohorts); optional expires

Audit log

Every permitted view through a rule (not legacy) writes a row to pcp_visibility_view_log. Special:MyShareLog shows the last 200 views with timestamp, viewer (or anonymous IP masked to /24), namespace, key, rule id.

Choice / multi voting

The <vote> tag's binary up/down mode is unchanged. With type="single" or type="multi" + options="A; B; C" (2-5 entries, semicolon separator), it renders a compact chart-icon chip that expands inline to a radio (single) or checkbox (multi) picker with per-option count bars.

Server-side storage:

  • pcp_votable_elements.ve_options (JSON array of labels)
  • pcp_votable_elements.ve_options_h (first 8 hex of sha256; drift hash)
  • pcp_votable_elements.ve_results_policy (live / after-vote / hidden)
  • pcp_votes.v_choices (CSV indices)
  • pcp_votes.v_options_h (drift hash at vote time)

API route: same pharmacopediavote endpoint; presence of choices or options_h params routes to castChoice(). Response includes tally + user_choices (null for binary). Tally hidden per results policy.

Drift behavior: if the page editor changes the options list after votes exist, ve_options_h updates and new votes' v_options_h reflects the new value. Existing votes stay but their hash no longer matches (marked stale). New votes whose submitted hash mismatches the live one are rejected — protects against browser cache races. Tallies still aggregate by raw index, so RENAMING an option in place silently turns old votes into new-label votes; reordering is the dangerous case. Appending new options is safe.

ClamAV scan rule (project standard)

Hard rule, set 2026-05-17: every server-accepted file upload (image, PDF, document, anything) MUST go through VirusScanner::scanFile($path) (includes/VirusScanner.php) BEFORE being moved to permanent storage. Fail-closed: if /usr/bin/clamdscan is unavailable or returns error, the upload is rejected.

Wired into:

  • LifeStoryStore::addImage() — life events / episodes / observations
  • LiteratureStore::storeUploadedPdf() — literature PDFs
  • ProviderAppStore::saveUploadedFile() — provider verification documents

AttachmentScanner (used by feature-request attachments) is left alone — its status-return model is intentional for the queued-moderation flow there. AntivirusHelper (the old silent-no-op variant) was deleted; all callers consolidated onto VirusScanner.

Autosave infrastructure

Every block on Special:MyProfile is wrapped in <div data-pcp-save-block="block-name">. The blocksave.js library:

  1. Listens for input and change events on every input inside any save-block
  2. 800 ms after the last event, POSTs the block's serialized form data to Special:SaveProfileBlock with block=block-name
  3. Shows a transient chip (top-right and bottom of the block): pending… → saving… → ✓ saved (fades after 1.2 s) or ✗ error (sticks, clickable to retry)
  4. Race-safe: if user keeps typing during an in-flight save, the in-flight save records what it sent; newer changes mark the block dirty again and schedule another save when the response returns
  5. Diagnosis + medicines "Add a row" slots are exempted from autosave (would create duplicates); they require an explicit + Add button
  6. Programmatic widgets (chip-pickers, units, smoking, alcohol, chronotype) fire change events on their hidden fields so the listener notices

Slider numbers are also clickable: a single delegated handler on every <output> next to a range slider swaps it for a number input on click, accepts a precise typed value (clamps to the slider's min/max), commits with Enter, cancels with Escape.

Scroll position is preserved across the rare reloads (delete operations on diagnoses / medicines / experience reports, and the auto-reload after a new diagnosis or medicine is added) via sessionStorage.

Special pages

Page Purpose
Special:MyProfile Edit your full profile (identity, demographics, personality, dx, meds). Autosave throughout. Privacy mode toggle + share chips per fieldset.
Special:UserProfile/<name> Public profile view (filtered by per-field visibility + rule-based access). 🔗 Share chip in subtitle for self-view.
Special:MyAssessment Index of rich assessment reports
Special:MyAssessment/cati, /catq, /pid5bf, /mbti, /enneagram, /ocean Rich report per assessment; 🔗 Share chip in subtitle for owner
Special:MyLifeStory Owner editor + visual timeline + card list. Quick-add observation textarea + Event/Episode buttons. 🔗 Share chip in subtitle. Privacy-mode + free-text-refs banners.
Special:MyLifeStory/add-episode, /edit-episode/<id> Episode form (create / edit)
Special:MyLifeStory/edit-observation/<id> Observation edit form (re-parses raw text)
Special:LifeStory/<name> Public life-story view (read-only, visibility-filtered)
Special:MyCohorts Owner-managed groups for share-with-cohort flows; create / rename / delete / add+remove members
Special:MyShareLog Who has viewed your shared content (timestamp, viewer, namespace, rule id; anonymous IPs masked)
Special:MyRefLinks Bulk linker: find free-text refs in observations that now match a structured entity; one-click upgrade or dismiss
Special:SaveProfileBlock AJAX endpoint for autosave (POST-only, JSON response)
Special:Problems Browse the problems repository (165+ entries, 18 categories)
Special:Problem/<slug> Individual problem page
Special:SuggestProblem User-facing form to suggest a new problem
Special:ManageProblems Sysop tool for problem-repository moderation
Special:ReviewExperience Sysop queue for pending experience reports
Special:ManageInteractions Sysop bulk-edit interaction reports
Special:DeletePharmaElement Sysop delete tool for any votable element
Special:PCPCtrls Sysop ctrls dashboard (gated by $wgSpecialPageLockdown)

API modules

Action Purpose
pharmacopediavote Binary OR choice/multi vote (routes by presence of choices param). Returns tally + user_choices for choice modes; gated by results-policy.
pharmacopedialikert Submit problem-efficacy likert (0–100 + −1 DK)
pharmacopediaeffect Submit effect report (patient or provider perspective)
pharmacopediainteractionreport Submit interaction report
pharmacopediainteractionadd Create a new interaction edge
pharmacopediacomment Threaded discussion ops (add / edit / delete / reply)
pharmacopediaexperiencesubmit Submit experience report (multi-field form)
pharmacopediaexperiencereview Sysop approve / reject experience report
pharmacopediadxsearch Diagnosis autocomplete against the 41k-row abbreviation table
pharmacopediaproblemsearch Problem-repository autocomplete
pharmacopediaeffectslookup, indicationslookup Pickers used by the experience-submit form
pharmacopedialiteratureadd, literaturedelete Literature attachment ops
pharmacopediaobservation op=preview / op=submit for plain-text observations (routes to addObservation OR addEpisode)
pharmacopediavisrules Visibility rule CRUD (list / create / update / revoke / newtoken)
pharmacopediausersearch Username autocomplete for share-with-people picker
pharmacopediacohorts Cohort CRUD + membership
pharmacopediarefupgrade Free-text ref linker (op=candidates / apply / dismiss)

Interactions feature

The Interactions section is rendered by placing <pharmaInteractions/> anywhere in the wikitext of a med article (NS_MAIN) or a Category page (NS_CATEGORY).

Entity model

An interaction is an undirected edge between two endpoints. Each endpoint has a type (med or category) and a slug (DB-key form of the page title). Pairs are stored in canonical order: smaller (type, slug) tuple on the left.

Rendering rules

  • On a med page M, list:
    • Direct edges: rows where M is one side.
    • Transitive edges: rows where one side is a category C that M is itself a member of (via MW's categorylinks).
  • Direct wins: if the same counterparty is reachable both directly and transitively, drop the transitive duplicate.
  • On a Category page, list direct edges only (no transitive walk).
  • Sort: pooled valence_mean ascending (most negative on top). Nulls sink. Tiebreakers: n desc, then alphabetic.
  • Severe (any of pooled / user / provider vmean ≤ −83.0): red 4 px left border + red-tinted background + "severe" pill + counterparty title in red.

Add-interaction modal

Triggered by the + Add interaction button at the bottom of the section. Two-stage UX: search → click Use → confirm with Add interaction → POST to pharmacopediainteractionadd. Categories appear in the modal only if tagged with the marker category (default Category:MedCategory, configurable via $wgPharmacopediaInteractionCategoryMarker).

Experience reports

User-submitted reports of personal or clinical experience with a medicine, via <pharmaExperience/> on a med page. Stored pending in pcp_experience_reports; visible publicly only after sysop approval through Special:ReviewExperience.

Captured fields:

  • Perspective (personal / clinical)
  • Currently taking it (yes / no, stopped)
  • Duration (value + unit)
  • Dose (mg, decimal-precise)
  • Route (16-option dropdown: PO, IV, IM, SC, SL, buccal, inhaled, intranasal, topical, transdermal, PR, ophthalmic, otic, vaginal, insufflated, other)
  • Schedule (free text with datalist of QD / BID / TID / QID / q4h / q6h / q8h / q12h / qHS / qAM / qPM / PRN)
  • Patient count (clinical only): min + optional max for ranges
  • Efficacy (0–100 slider)
  • Side-effect burden (0–100 slider)
  • Stop reasons (personal + stopped only): JSON multi-select with optional severity slider per reason — codes: side_effects / ineffective / cost / no_longer_needed / clinician_advised / other
  • Free-text anecdote
  • Problems addressed (multi-pick with per-problem efficacy)
  • Effects experienced (multi-pick with per-effect valence + frequency)

Storage tables (selected)

Table Purpose
pcp_votable_elements Stable per-(page, slug) handle reused by votes / likert / comments. Now also ve_options + ve_options_h + ve_results_policy for choice votes.
pcp_votes Binary +1/−1 votes (v_value) AND choice/multi votes (v_choices CSV + v_options_h drift hash)
pcp_likert_reports Problem efficacy (0–100 + −1 DK), TINYINT signed
pcp_effect_reports Effect ratings: experienced / frequency / valence (±100); perspective 1/2
pcp_interaction_reports Interaction ratings: experience 1–5 / valence ±100 / note; perspective 1/2
pcp_interactions Interaction edges (canonical-ordered pair)
pcp_comments Threaded discussions
pcp_user_profiles Per-user profile meta (alias, attribution, voter hash)
pcp_profile_fields Generic key-value field store: (namespace, key, num, text, visibility)
pcp_profile_diagnoses Per-user diagnoses (system, code, description, status, origin, severity 0–100, disability 0–100, dates, notes, visibility)
pcp_user_meds Per-user medicines (name, page_id, efficacy 0–100, burden 0–100, dose_mg, route, schedule, duration, periods JSON, current, notes, visibility)
pcp_diagnosis_abbreviations ICD-10-CM + ICD-11 + DSM-5 + aliases (~41,500 rows, VARCHAR/utf8mb4 for native case-insensitive search)
pcp_problem, pcp_problem_alias Problems repository + alias lookup
pcp_experience_reports Experience reports (pending → approved); efficacy + burden 0–100; route + schedule; stop-reasons JSON; patient-count min + max
pcp_life_events Timeline events. Columns: le_type (0=story, 1=image, 2=keyframe, 3=observation, 4=episode), le_polarity, le_raw_text (parser input), le_episode_type, le_episode_subtype, le_severity, le_date_struct (PCPDatePicker JSON; supports range)
pcp_life_event_refs Join table linking events to entities (med / effect / problem / diagnosis / med_page / diagnosis_code / free); role (subject / cause / context / symptom / trigger / treatment)
pcp_life_traits Keyframe trait values (assessment subscale snapshots → trajectory graph)
pcp_life_images Image attachments (ClamAV-scanned)
pcp_visibility_rules Per-(profile, namespace, key) sharing rules. Types: public / private / users / cohort / link_token / reciprocal. Optional vr_expires, vr_revoked, vr_attribution, vr_label.
pcp_cohorts, pcp_cohort_members Owner-managed user groups for share-with-cohort flows
pcp_visibility_view_log Audit trail of rule-permitted views (Special:MyShareLog)
pcp_literature Citation attachments

Notable lessons learned

  • Cargo string fields cap at ~300 chars. structure and mechanism on MedTemplate are short VARCHARs; long prose goes in MEDIUMBLOB sections. Overruns silently lose Cargo data (MySQL Error 1406).
  • VARBINARY + LOWER() is a no-op. MariaDB's LOWER() returns binary types unchanged. pcp_diagnosis_abbreviations was migrated VARBINARY → VARCHAR/utf8mb4 so case-insensitive LIKE works natively without CONVERT() wrappers.
  • FlaggedRevs locks template inclusions by default. Config fix: $wgFlaggedRevsHandleIncludes=0 + remove NS_TEMPLATE from $wgFlaggedRevsNamespaces via an extension-function callback.
  • CSP must allow Cloudflare Turnstile and any other 3rd-party widget script source. Audit script-src / frame-src / connect-src / style-src whenever adding any 3rd-party JS widget.
  • Sidebar cache must be purged after CLI maintenance/edit.php writes to MediaWiki:Sidebar or other chrome pages.
  • CLI E_USER_DEPRECATED suppressed in LocalSettings.php (EmbedVideo / FlaggedRevs spam on MW 1.46). Web behavior unaffected.
  • MW ApiResult drops keys starting with _. Any field whose key starts with underscore in an ApiResult payload is treated as internal metadata and stripped. Rename to plain identifier. Bit Phase 2-4 of visibility-rules subsystem.
  • MW API serializes int-keyed assoc arrays unreliably. [23 => 'Alice'] may arrive as {"23": "Alice"} or worse. Always use list-of-objects ([{"id": 23, "name": "Alice"}]) for id-to-value maps across the API boundary.
  • PHP single-quoted strings don't interpret \xNN byte escapes. '\xF0\x9F\x94\x97' is 16 literal chars, NOT the 4-byte UTF-8 for 🔗. Use literal Unicode char or double-quoted \u{1F517}. Bit three times in one session.
  • PHP "0" is FALSY. !"0" evaluates to TRUE. So if ( !$x ) silently treats "0" (string zero) as missing. Bit choice-vote tallying when voter picked option index 0; vote was IN the DB but invisible to readers. Use === null || === for "missing or blank" intent. empty() has the same problem.
  • <span> can't contain <p>. MediaWiki auto-wraps tag content in <p> when there are newlines; browsers auto-close any <span> before the <p>, scattering child elements into wrong DOM positions. Use <div> for parser-tag wrappers whose content can span paragraphs (choice votes, life-story cards).
  • vis.Graph2d group style belongs at TOP LEVEL not nested in options. Nesting silently fails (no error, just invisible lines). Bit the trait-trajectory graph on first build.

Hooks

  • ParserFirstCallInit: register all parser tags
  • LoadExtensionSchemaUpdates: install / migrate schema (the sql/ directory + patches)
  • BeforePageDisplay: inject the ext.pharmacopedia.* ResourceLoader modules
  • UserGetRights + UserEffectiveGroups: verified-provider role wiring
  • Various special-page registrations via SpecialPage_initList

Configuration globals

  • $wgPharmacopediaInteractionCategoryMarker (default Category:MedCategory): only categories tagged with this marker appear in the add-interaction modal
  • $wgPharmacopediaVoteHashSecret (required): HMAC secret for v_voter_hash so vote rows can't be mapped back to user accounts by anyone reading the DB without the secret
  • (Various permission-grant arrays via standard MW $wgGroupPermissions)

Source layout

 extensions/Pharmacopedia/
  |-- extension.json
  |-- includes/
  |   |-- Hooks.php
  |   |-- *Tag.php                  (parser tags: VoteTag, EffectTag, ProblemTag, ...)
  |   |-- *Store.php                (data access: EffectStore, ProblemStore, ElementStore, LifeStoryStore, ...)
  |   |-- UserProfileStore.php      (the big one; profile / dx / meds / abbreviations)
  |   |-- VisibilityResolver.php    (per-record sharing decision engine)
  |   |-- VirusScanner.php          (ClamAV gate; fail-closed)
  |   |-- ObservationParser.php     (plain-text -> structured observation)
  |   |-- SpecialMyProfile.php      (the user profile editor; large)
  |   |-- SpecialMyAssessment.php   (rich reports for all 6 assessments; large)
  |   |-- SpecialMyLifeStory.php    (life-story editor + visual timeline; large)
  |   |-- SpecialMyCohorts.php      (cohort CRUD UI)
  |   |-- SpecialMyShareLog.php     (audit log viewer)
  |   |-- SpecialMyRefLinks.php     (bulk free-text -> structured upgrader)
  |   |-- SpecialSaveProfileBlock.php (autosave AJAX endpoint)
  |   |-- Special*.php              (other special pages)
  |   |-- ProfileDatasets.php       (countries, languages, genders, religions, etc.)
  |   |-- DatePicker.php            (range / possibility-mix date widget backend + injectBirthdayContextOnce)
  |   |-- Assessments/
  |   |   |-- Cati.php, CatiNorms.php
  |   |   |-- Catq.php
  |   |   |-- Pid5bf.php
  |   |   |-- Mbti.php
  |   |   |-- Enneagram.php
  |   |   |-- OceanNorms.php
  |   |   `-- Raadsr.php            (deprecated 2026-05-17 in favor of CATI, kept for archival reads)
  |   `-- Api/
  |       |-- VoteApi.php, EffectApi.php, ...
  |       |-- ObservationApi.php
  |       |-- VisibilityRulesApi.php, UserSearchApi.php, CohortsApi.php
  |       `-- RefUpgradeApi.php
  |-- resources/
  |   |-- ext.pharmacopedia.js              (single IIFE: chip-picker, dx autocomplete, vote logic, ...)
  |   |-- ext.pharmacopedia.blocksave.js    (debounced autosave per block)
  |   |-- ext.pharmacopedia.css
  |   |-- ext.pharmacopedia.datepicker.js + .css
  |   |-- ext.pharmacopedia.timepicker.js + .css
  |   |-- ext.pharmacopedia.share.js + .css
  |   |-- ext.pharmacopedia.observation.js + .css
  |   |-- ext.pharmacopedia.refupgrade.js + .css
  |   |-- ext.pharmacopedia.lifetimeline.js + .css
  |   |-- ext.pharmacopedia.lifegraph.js + .css
  |   |-- ext.pharmacopedia.kitsync.js
  |   `-- vendor/vis-timeline/              (vis-timeline 7.7.3, Apache-2.0 + MIT)
  |       |-- vis-timeline-graph2d.min.js
  |       |-- vis-timeline-graph2d.min.css
  |       `-- LICENSE
  |-- sql/
  |   |-- (one .sql per table; patches as patch-*.sql)
  `-- i18n/
      `-- en.json