About:Pharmacopedia.ext
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 fromnavigator.languages; time zone fromIntl.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 underMediaWiki\Extension\Pharmacopedia\. Assessment classes underincludes/Assessments/. API modules underincludes/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 widgetext.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 previewext.pharmacopedia.refupgrade: bulk linker for free-text → structured refsext.pharmacopedia.vis-timeline-vendor: vis-timeline 7.7.3 (vendored)ext.pharmacopedia.lifetimeline: visual life-story timeline + swimlanes + toolbarext.pharmacopedia.lifegraph: trait-trajectory overlay (vis.Graph2d) synced to timelineext.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 viaLoadExtensionSchemaUpdateshook.
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 visibleafter-vote— tally hidden until viewer has votedhidden— 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:
- User's meds (
pcp_user_meds) - Wiki pages in Category:Medicines
- Effects catalog (
pcp_effects) - Problems catalog (
pcp_problem+pcp_problem_alias) - User's diagnoses (
pcp_profile_diagnoses) - Global ICD diagnosis abbreviations (
pcp_diagnosis_abbreviations) - Free-text fallback (stored as type='free' for later upgrade)
- User's meds (
- Episode-shape detection: "manic episode" → type=mood, subtype=manic; "psychotic break", "panic attack", "anxiety attack", "trauma response" all route to
addEpisodeinstead ofaddObservation. A date RANGE alone is enough to forceis_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.Graph2ddraws 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 overrideSpecial:MyLifeStory/edit-episode/<id>— full episode formSpecial: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.
Upgrade-link UI
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 listcohort— payload{cohort_id: N}; allow if viewer inpcp_cohort_membersfor that cohortlink_token— payload{token: '...', uses_remaining: null|N}; allow if URL?pcpshare=TOKENmatchesreciprocal— allow if viewer has a matchingusersrule 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_tokenrule; 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 / observationsLiteratureStore::storeUploadedPdf()— literature PDFsProviderAppStore::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:
- Listens for
inputandchangeevents on every input inside any save-block - 800 ms after the last event, POSTs the block's serialized form data to
Special:SaveProfileBlockwithblock=block-name - 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)
- 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
- Diagnosis + medicines "Add a row" slots are exempted from autosave (would create duplicates); they require an explicit + Add button
- Programmatic widgets (chip-pickers, units, smoking, alcohol, chronotype) fire
changeevents 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_meanascending (most negative on top). Nulls sink. Tiebreakers:ndesc, 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.
structureandmechanismon 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_abbreviationswas 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$wgFlaggedRevsNamespacesvia 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.phpwrites toMediaWiki:Sidebaror 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
\xNNbyte 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. Soif ( !$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
stylebelongs at TOP LEVEL not nested inoptions. Nesting silently fails (no error, just invisible lines). Bit the trait-trajectory graph on first build.
Hooks
ParserFirstCallInit: register all parser tagsLoadExtensionSchemaUpdates: install / migrate schema (the sql/ directory + patches)BeforePageDisplay: inject the ext.pharmacopedia.* ResourceLoader modulesUserGetRights+UserEffectiveGroups: verified-provider role wiring- Various special-page registrations via
SpecialPage_initList
Configuration globals
$wgPharmacopediaInteractionCategoryMarker(defaultCategory:MedCategory): only categories tagged with this marker appear in the add-interaction modal$wgPharmacopediaVoteHashSecret(required): HMAC secret forv_voter_hashso 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