About:Pharmacopedia.ext
More actions
Pharmacopedia extension specification
Version: 0.9.7.0 · 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
- ADHD screening (ASRS and AMAAS-SR) plus a Formal testing log of standardized-test scores, every score field carrying its own visibility
- 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)
- Per-user research_id (stable 10-char hex, opaque, never reassigned) for de-identified research participation
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): a dark-theme palette across two skins, the default pharmaceutical skin and an earth-toned plants skin applied to plant-origin pages by content category. Self-hosted Geist, Newsreader and Source Serif web fonts. The assessment card family (the verdict card and the featured radar card) and the Shulgin's Corner literary-quote component are components in
ext.pharmacopedia.css. - 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 "problem") 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, read-only Research ID badge)
- 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)
- ADHD screening (ASRS adult-ADHD screener and AMAAS-SR attention self-report, each a collapsible inline test)
- Formal testing (a log of standardized-test scores; raw score, percentile and pass/fail each carry their own privacy setting)
- 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). Setting (or changing) the birthday auto-syncs a TYPE_STORY life event tagged
auto-birthtitled 'Born!' on the life-story timeline. Subsequent birthday edits move ONLY the event's date; user edits to title, body, tags, images are preserved. - 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.
ADHD screening
Two attention / ADHD instruments sit alongside the dimensional assessments, each a collapsible inline test on Special:MyProfile and a card render on the public profile:
- ASRS (Adult ADHD Self-Report Scale, Part A): a 6-item binary screener. The card counts how many of the 6 cardinal items fall in the screening range; 4 or more is a positive screen. The public profile renders it as a verdict card, a screen-positive or screen-negative result word with a 6-cell cardinal-item strip and a screening-result detail line.
- AMAAS-SR (a 30-item experimental attention self-report): three symptom subscales, inattention, hyperactivity and impulsivity, each scored as a percentage of the subscale maximum. The public profile renders it as a featured radar card, a 3-axis radar carrying a deliberately arbitrary 66.66% threshold triangle that is labelled experimental and not a validated cutoff. AMAAS has no validated norms; the card discloses this in plain sight rather than presenting a clinical cutoff.
Both instruments store responses in pcp_profile_fields under the asrs and amaas namespaces, the same pattern as the dimensional assessments.
Formal testing
The Formal testing block on Special:MyProfile is a log of standardized tests the user has taken (entrance exams, AP exams, IQ tests, and the like; retakes are welcome, and the year disambiguates them). Each entry resolves against a catalog (pcp_formal_tests) or is a custom free-text test, and records up to three score fields, raw score, percentile and pass/fail, each with an optional estimate flag.
Every score field has its own per-field visibility. Raw score, percentile and pass/fail each carry a separate privacy setting (uts_vis_raw, uts_vis_pct, uts_vis_passfail), so a user can publish a percentile while keeping the raw score private. The Special:MyProfile editor shows three privacy toggles per entry; the public Special:UserProfile gates each score line independently by its own field visibility. Scores are stored in pcp_user_test_scores, managed through the pharmacopediaformaltest API.
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 (
- Auto-promote unrecognized subjects to custom-trait keyframes: when the subject doesn't match an existing entity but the input has BOTH a numeric value AND a date (e.g.
my neuroticism was 5 at 10yo), the subject is treated as a NEW custom trait (namespace='custom', key derived from subject text). Creates a TYPE_KEYFRAME event with the value + a trajectory point. Future inputs with the same subject auto-append to that series. - Flexible range separators:
X to Y,X through Y,X thru Y,X until Y,X till Y, em-dash, en-dash, flexible-whitespace hyphens (digit-aware so2026-05-31ISO dates aren't split),../...ellipsis. - Age-range phrases without literal "ages":
when I was 11-13,from 11 to 13,between ages 5 and 12,aged 10 to 14. Negative lookahead prevents false matches on dosages (10-20 mg), durations (5-10 years ago), measurements (cm/mm/ft/lb/etc). - 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.
Research ID
Every user profile carries a research_id: a 10-character hex string (bin2hex(random_bytes(5)) = 40 bits), generated once at profile create, stored UNIQUE in pcp_user_profiles.prof_research_id, and never reassigned.
Purpose: provides a stable opaque identifier for de-identified research participation. It does not reveal the user's wiki username, user_id, or HMAC voter_hash; it survives username changes; and it stays constant across the user's lifetime on the wiki. Users can find theirs in the Public identity fieldset on Special:MyProfile (single-click to select-and-copy).
Backfilled retroactively for all pre-existing profiles on 2026-05-18 (v0.9.4).
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 |
Picker 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) |
pharmacopediaformaltest |
Formal-testing score operations (list / add / update / delete), with per-field visibility |
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, prof_research_id) |
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 |
pcp_formal_tests |
Catalog of standardized tests (abbrev, full name, category, score format) |
pcp_user_test_scores |
Per-user formal-test scores; raw score / percentile / pass-fail, each with its own visibility (uts_vis_raw / uts_vis_pct / uts_vis_passfail) and an estimate flag |
Notable lessons learned
- Synthetic Event needs bubbles:true to trigger delegated listeners.
new Event('input')defaults tobubbles:false, so listeners on parent wrappers never see programmatic dispatches. Native input/change events bubble by default; only JS-fired ones don't. Pass{ bubbles: true }explicitly. Bit DatePicker calendar-cell clicks 2026-05-18, typing in the text field autosaved fine, but picking a date from the calendar didn't, because blocksave's wrapper listener never received the event. - MediaWiki form-field names collide with reserved URL params. A form
<input name="title">orname="action">with user-controlled value silently HIJACKS MW's dispatch when POSTed (body param overrides URL param). Symptom: form submits but lands on a wiki article named whatever the user typed, with URL bar still showing the special page. Bit the episode form 2026-05-18 (user typed 'Fake one' as title → got a 404 'create article: Fake one' page). Fix: prefix ALL custom form inputs withpcp_(both the inputname="..."AND the matching$request->getVal('...', ...)read). Self-referential hidden inputs (name="title" value="<getPageTitle()>") are safe.
- 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