Label Time Machine source
Every file that ships, readable right here. MIT licence. Prefer it offline? Download the .zip.
README.md67 lines
# Label Time MachineSearch a US prescription drug label on DailyMed, then scrub through every version it has published. New indications, boxed warnings, contraindications and warnings appear as they happened, highlighted word by word.Live at [mafflab.xyz/apps/label-time-machine/](https://mafflab.xyz/apps/label-time-machine/). Installable, no cookies, no tracking, no generative AI.## What it shows- **The label over time:** words per section for every version, with markers on key changes. Drag across the chart, use the slider or press Play.- **What changed:** for the selected version, the key changes (new indication, boxed warning added or updated, contraindications changed, new warning), the label's own "Recent major changes" list, and words added and removed per section.- **Read the changes:** word-level differences for any section, between consecutive versions or any two you choose.- **Milestones:** the label's key changes in date order, copyable, plus a CSV change log for every version.## How it works```browser ──► labels.php (on mafflab.xyz) ──► DailyMed web services (NLM) │ ?q= search /services/v2/spls.json │ ?history= versions /services/v2/spls/{setid}/history.json │ ?setid=&v= one version /getFile.cfm?type=zip&setid=…&version=… (SPL XML inside) └─ caches parsed public versions in ../../storage/label-cache/```- `labels.php` (PHP 7.4+) whitelists and validates every parameter, calls one fixed host, sends no visitor headers upstream and keeps no logs. It unzips each version (ZipArchive, or a built-in reader on zlib), reads the SPL XML and returns its sections as plain text.- A published version never changes, so parsed versions are cached on the server: DailyMed is asked once per version, not once per visitor. The cache holds public label text only. Delete the folder at any time.- `engine.js` groups sections by LOINC code (titles as a fallback for older labels), diffs paragraphs and then words (Myers), and detects milestones such as new indication or warning subsections.- `demo.json` is the history of DEMOLARA, a **fictional** medicine, so the app works offline and in previews.After uploading, open `labels.php?check=1` on your site: it reports the PHP extensions, whether the cache folder is writable and whether DailyMed is reachable.## Files```label-time-machine/├── index.html, app.js, style.css, mafflab-theme.css├── engine.js grouping, diffs, milestones (pure functions, also runs in Node)├── labels.php DailyMed relay├── demo.json fictional demo label history (generated)├── manifest.webmanifest, sw.js, .htaccess, icons/, fonts/└── tests/ ├── engine.test.js diff correctness and milestone detection ├── test_relay.py labels.php end to end against a mock DailyMed (both ZIP readers) ├── mock_dailymed.py stand-in for the three DailyMed resources └── make_demo.py builds demo.json```## Tests```node tests/engine.test.js [folder of v-*.json files from labels.php]python3 tests/test_relay.py FIXTURES_DIR````FIXTURES_DIR` holds two real SPL files from the openFDA repository ([github.com/FDA/openfda](https://github.com/FDA/openfda), `openfda/spl/tests/data/humira.xml` and `lipitor-orig.xml`), saved as `humira-2013.xml` and `lipitor-2014.xml`.## Limits- DailyMed publication dates follow company submissions; they are not approval dates. The history starts with the label's first electronic version.- Brands, generics and repackagers each have their own label: pick the right one.- Change detection is rule-based: renumbered or renamed subsections can show as removed and added. Tables are compared as text rows.- Labels with more than 150 versions are sampled evenly, always keeping the first and the latest.- An aid for monitoring and teaching, not a regulatory record. Check the official label on DailyMed before relying on any detail.## LicenceMIT. © 2026 Aitor Martínez Rosell. Label content comes from DailyMed (U.S. National Library of Medicine). Label Time Machine is independent and not endorsed by NLM or FDA.labels.php320 lines
<?php/** * Label Time Machine relay for DailyMed (U.S. National Library of Medicine). PHP 7.4 or newer. * * GET endpoints * ?q=<drug name> search human prescription labels -> DailyMed /spls.json * ?history=<set id> every published version of one label -> /spls/{setid}/history.json * ?setid=<set id>&v=<n> one version, parsed into its sections -> getFile.cfm ZIP (SPL XML) * ?check=1 server self-check: extensions, cache folder, DailyMed reachable * * Privacy * - One fixed upstream host and whitelisted, validated parameters. * - Sends no visitor IP, cookie, referrer or browser header upstream. * - Keeps no logs. It caches public label data only (a published version never changes) in * ../../storage/label-cache/, so DailyMed is asked once per version, not once per visitor. * Nothing about who asked is stored. Delete the folder at any time; it refills on demand. */declare(strict_types=1);const UA = 'mafflab Label Time Machine (self-hosted relay; +https://mafflab.xyz/lab/label-time-machine/)';const MAX_ZIP = 40 * 1024 * 1024; // bytes; label ZIPs include imagesconst MAX_JSON = 4 * 1024 * 1024;const TTL_LIST = 6 * 3600; // search results and histories: refresh after 6 hoursconst RATE_LIMIT = 150; // upstream calls per minute for the whole site, to be polite to NLM$UP = 'https://dailymed.nlm.nih.gov/dailymed/';$CACHE = dirname(__DIR__, 2) . '/storage/label-cache';if (PHP_SAPI === 'cli-server') { // local testing only: never true on a production host if (getenv('LTM_UPSTREAM')) $UP = rtrim((string) getenv('LTM_UPSTREAM'), '/') . '/'; if (getenv('LTM_CACHE')) $CACHE = (string) getenv('LTM_CACHE');}header('Content-Type: application/json; charset=utf-8');header('X-Content-Type-Options: nosniff');header('Referrer-Policy: no-referrer');function out(int $code, array $data, int $maxAge = 0): void { http_response_code($code); header($maxAge > 0 ? "Cache-Control: public, max-age=$maxAge" : 'Cache-Control: no-store'); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); exit;}function fail(int $code, string $msg): void { out($code, ['error' => $msg]); }if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') fail(405, 'Only GET is supported.');if (($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '') === 'cross-site') fail(403, 'This relay only serves the Label Time Machine page.');/* ------------------------------------------------------------------ cache */function cache_dir(): ?string { global $CACHE; static $ok = null; if ($ok === null) $ok = (is_dir($CACHE) || @mkdir($CACHE, 0750, true)) && is_writable($CACHE); return $ok ? $CACHE : null;}function cache_get(string $key, int $ttl): ?string { $d = cache_dir(); if (!$d) return null; $f = "$d/$key.json.gz"; if (!is_file($f) || ($ttl > 0 && filemtime($f) < time() - $ttl)) return null; $z = @file_get_contents($f); $s = $z === false ? false : @gzdecode($z); return $s === false ? null : $s;}function cache_put(string $key, string $json): void { $d = cache_dir(); if (!$d) return; $tmp = "$d/.$key." . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, gzencode($json, 6)) !== false) @rename($tmp, "$d/$key.json.gz");}/* ------------------------------------------------------------------ upstream */function rate_ok(): bool { $d = cache_dir(); if (!$d) return true; $fh = @fopen("$d/.rate", 'c+'); if (!$fh) return true; flock($fh, LOCK_EX); $raw = stream_get_contents($fh); [$min, $n] = array_map('intval', explode(':', $raw ?: '0:0') + [0, 0]); $now = intdiv(time(), 60); $n = $min === $now ? $n + 1 : 1; ftruncate($fh, 0); rewind($fh); fwrite($fh, "$now:$n"); flock($fh, LOCK_UN); fclose($fh); return $n <= RATE_LIMIT;}/** @return array{0:int,1:string} status and body */function fetch(string $url, int $max): array { if (!rate_ok()) fail(429, 'The lab is reading a lot of labels right now. Try again in a minute.'); $headers = ['Accept: application/json, application/zip, */*']; if (function_exists('curl_init')) { $buf = ''; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 45, CURLOPT_HTTPHEADER => $headers, CURLOPT_USERAGENT => UA, CURLOPT_ENCODING => '', CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, $max) { $buf .= $chunk; return strlen($buf) > $max ? 0 : strlen($chunk); }, ]); $okRun = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $final = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); $err = curl_errno($ch); curl_close($ch); if ($err === CURLE_WRITE_ERROR) fail(502, 'This label file is too large to process.'); if ($okRun === false || $status === 0) fail(502, 'DailyMed did not respond. Try again in a moment.'); global $UP; if (parse_url($final, PHP_URL_HOST) !== parse_url($UP, PHP_URL_HOST)) fail(502, 'DailyMed redirected somewhere unexpected.'); return [$status, $buf]; } if (!ini_get('allow_url_fopen')) fail(501, 'This server cannot make outbound requests (enable cURL or allow_url_fopen).'); $ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => 45, 'ignore_errors' => true, 'follow_location' => 1, 'max_redirects' => 3, 'header' => implode("\r\n", $headers) . "\r\nUser-Agent: " . UA . "\r\n"]]); $body = @file_get_contents($url, false, $ctx, 0, $max + 1); $status = 0; foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s(\d{3})#', $h, $m)) $status = (int) $m[1]; if ($body === false || $status === 0) fail(502, 'DailyMed did not respond. Try again in a moment.'); if (strlen($body) > $max) fail(502, 'This label file is too large to process.'); return [$status, $body];}function fetch_json(string $url): array { [$status, $body] = fetch($url, MAX_JSON); if ($status === 404) fail(404, 'DailyMed has no record for that.'); if ($status >= 400) fail(502, "DailyMed answered with an error ($status)."); $j = json_decode($body, true); if (!is_array($j)) fail(502, 'DailyMed sent something that is not JSON.'); return $j;}function iso_date(?string $s): string { if (!$s) return ''; if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $s, $m)) return "$m[1]-$m[2]-$m[3]"; $t = strtotime($s); return $t ? gmdate('Y-m-d', $t) : '';}/* ------------------------------------------------------------------ ZIP: ZipArchive, or a small reader on zlib */function xml_from_zip(string $zip): ?string { if (class_exists('ZipArchive') && !getenv('LTM_NO_ZIPARCHIVE')) { $tmp = tempnam(sys_get_temp_dir(), 'ltm'); if ($tmp !== false && file_put_contents($tmp, $zip) !== false) { $za = new ZipArchive(); $best = null; $bestSize = -1; if ($za->open($tmp) === true) { for ($i = 0; $i < $za->numFiles; $i++) { $st = $za->statIndex($i); if ($st && preg_match('/\.xml$/i', $st['name']) && $st['size'] > $bestSize) { $best = $i; $bestSize = $st['size']; } } $xml = $best === null ? null : $za->getFromIndex($best); $za->close(); @unlink($tmp); return $xml === false ? null : $xml; } @unlink($tmp); } } return xml_from_zip_raw($zip);}function xml_from_zip_raw(string $z): ?string { $eocd = strrpos($z, "PK\x05\x06"); if ($eocd === false || strlen($z) < $eocd + 22) return null; $cd = unpack('vdisk/vcdisk/ventries/vtotal/Vsize/Voffset', substr($z, $eocd + 4, 16)); $p = $cd['offset']; $best = null; for ($i = 0; $i < $cd['total']; $i++) { if (substr($z, $p, 4) !== "PK\x01\x02") break; $h = unpack('vmade/vneed/vflags/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/velen/vclen/vdisk/viattr/Vxattr/Voff', substr($z, $p + 4, 42)); $name = substr($z, $p + 46, $h['nlen']); if (preg_match('/\.xml$/i', $name) && (!$best || $h['usize'] > $best['usize'])) $best = $h; $p += 46 + $h['nlen'] + $h['elen'] + $h['clen']; } if (!$best || substr($z, $best['off'], 4) !== "PK\x03\x04") return null; $lh = unpack('vnlen/velen', substr($z, $best['off'] + 26, 4)); $data = substr($z, $best['off'] + 30 + $lh['nlen'] + $lh['elen'], $best['csize']); if ($best['method'] === 0) return $data; if ($best['method'] === 8) { $x = @gzinflate($data); return $x === false ? null : $x; } return null;}/* ------------------------------------------------------------------ SPL XML -> sections */const SKIP_SECTIONS = ['48780-1', '51945-4']; // product data elements, package label panelfunction clean(string $s): string { $s = str_replace(["\r", "\t", "\u{00A0}", "\u{2009}", "\u{200B}"], [' ', ' ', ' ', ' ', ''], $s); $lines = array_map(function ($l) { return trim(preg_replace('/ {2,}/', ' ', $l)); }, explode("\n", $s)); return implode("\n", array_values(array_filter($lines, function ($l) { return $l !== '' && $l !== '•'; })));}function render(DOMNode $n): string { $o = ''; foreach ($n->childNodes as $c) { if ($c instanceof DOMText) { $o .= preg_replace('/\s+/', ' ', $c->nodeValue); continue; } if (!$c instanceof DOMElement) continue; switch ($c->localName) { case 'paragraph': case 'caption': case 'list': $o .= "\n" . render($c) . "\n"; break; case 'item': $o .= "\n• " . render($c) . "\n"; break; case 'br': $o .= "\n"; break; case 'table': $o .= "\n" . render_table($c) . "\n"; break; case 'renderMultiMedia': case 'observationMedia': break; case 'footnote': $o .= ' [' . trim(render($c)) . ']'; break; default: $o .= render($c); } } return $o;}function render_table(DOMElement $t): string { $rows = []; foreach ($t->getElementsByTagNameNS('urn:hl7-org:v3', 'caption') as $cap) $rows[] = trim(preg_replace('/\s+/', ' ', render($cap))); foreach ($t->getElementsByTagNameNS('urn:hl7-org:v3', 'tr') as $tr) { $cells = []; foreach ($tr->childNodes as $td) { if ($td instanceof DOMElement && ($td->localName === 'td' || $td->localName === 'th')) $cells[] = trim(preg_replace('/\s+/', ' ', render($td))); } if (array_filter($cells, 'strlen')) $rows[] = implode(' | ', $cells); } return implode("\n", $rows);}function parse_spl(string $xml): array { libxml_use_internal_errors(true); $dom = new DOMDocument(); if (!$dom->loadXML($xml, LIBXML_NONET | LIBXML_COMPACT | LIBXML_PARSEHUGE)) fail(502, 'The label file could not be read.'); $xp = new DOMXPath($dom); $xp->registerNamespace('v', 'urn:hl7-org:v3'); $str = function (string $q, ?DOMNode $ctx = null) use ($xp) { return trim((string) $xp->evaluate("string($q)", $ctx)); }; $titleNode = $xp->query('/v:document/v:title')->item(0); $out = [ 'setid' => $str('/v:document/v:setId/@root'), 'v' => (int) $str('/v:document/v:versionNumber/@value'), 'effective' => iso_date($str('/v:document/v:effectiveTime/@value')), 'doctype' => $str('/v:document/v:code/@displayName'), 'title' => $titleNode ? clean(render($titleNode)) : '', 'labeler' => $str('(/v:document/v:author//v:representedOrganization/v:name)[1]'), 'sections' => [], ]; $walk = function (DOMElement $sec, int $depth, int $parent) use (&$walk, &$out, $xp, $str) { $code = $str('v:code/@code', $sec); if (in_array($code, SKIP_SECTIONS, true)) return; $t = $xp->query('v:title', $sec)->item(0); $x = $xp->query('v:text', $sec)->item(0); if (!$x && $code === '43683-2') $x = $xp->query('v:excerpt/v:highlight/v:text', $sec)->item(0); // Recent Major Changes lives in the highlights $idx = count($out['sections']); $out['sections'][] = ['c' => $code, 't' => $t ? clean(render($t)) : '', 'd' => $depth, 'p' => $parent, 'x' => $x ? clean(render($x)) : '']; foreach ($xp->query('v:component/v:section', $sec) as $child) $walk($child, $depth + 1, $idx); }; foreach ($xp->query('/v:document/v:component/v:structuredBody/v:component/v:section') as $sec) $walk($sec, 0, -1); return $out;}/* ------------------------------------------------------------------ routes */$uuid = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';if (isset($_GET['check'])) { $r = ['php' => PHP_VERSION, 'curl' => function_exists('curl_init'), 'allow_url_fopen' => (bool) ini_get('allow_url_fopen'), 'zip' => class_exists('ZipArchive') && !getenv('LTM_NO_ZIPARCHIVE') ? 'ZipArchive' : (function_exists('gzinflate') ? 'built-in reader (zlib)' : false), 'dom' => class_exists('DOMDocument'), 'cache' => cache_dir() ? 'writable' : 'not available (works, but slower)']; [$s, $b] = fetch($UP . 'services/v2/spls.json?drug_name=aspirin&pagesize=1', MAX_JSON); $r['dailymed'] = $s === 200 && is_array(json_decode($b, true)) ? 'reachable' : "error $s"; $r['ok'] = $r['dom'] && $r['zip'] && ($r['curl'] || $r['allow_url_fopen']) && $r['dailymed'] === 'reachable'; out(200, $r);}if (isset($_GET['q'])) { $q = trim(preg_replace('/\s+/', ' ', (string) $_GET['q'])); if (!preg_match('/^[\p{L}\p{N} .,\'+\/-]{2,60}$/u', $q)) fail(400, 'Type 2 to 60 letters or numbers.'); $key = 'q-' . md5(strtolower($q)); if (($c = cache_get($key, TTL_LIST)) !== null) { echo $c; exit; } $base = $UP . 'services/v2/spls.json?pagesize=100&drug_name=' . rawurlencode($q); $j = fetch_json($base . '&doctype=34391-3'); // human prescription drug labels if (empty($j['data'])) $j = fetch_json($base); // some labels are typed differently $res = []; foreach ($j['data'] ?? [] as $d) { if (!isset($d['setid']) || !preg_match($uuid, (string) $d['setid'])) continue; $res[] = ['setid' => $d['setid'], 'title' => (string) ($d['title'] ?? ''), 'published' => iso_date($d['published_date'] ?? ''), 'version' => (int) ($d['spl_version'] ?? 0)]; } usort($res, function ($a, $b) { return strcmp($b['published'], $a['published']); }); $json = json_encode(['q' => $q, 'total' => (int) ($j['metadata']['total_elements'] ?? count($res)), 'results' => $res], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); cache_put($key, $json); header('Cache-Control: no-store'); echo $json; exit;}if (isset($_GET['history'])) { $id = strtolower((string) $_GET['history']); if (!preg_match($uuid, $id)) fail(400, 'That is not a DailyMed set ID.'); $key = 'h-' . $id; if (($c = cache_get($key, TTL_LIST)) !== null) { echo $c; exit; } $versions = []; $title = ''; for ($page = 1; $page <= 10; $page++) { $j = fetch_json($UP . "services/v2/spls/$id/history.json?pagesize=100&page=$page"); $title = $title ?: (string) ($j['data']['spl']['title'] ?? ''); foreach ($j['data']['history'] ?? [] as $h) $versions[] = ['v' => (int) $h['spl_version'], 'date' => iso_date($h['published_date'] ?? '')]; $next = (string) ($j['metadata']['next_page'] ?? 'null'); if ($next === '' || $next === 'null') break; } if (!$versions) fail(404, 'DailyMed lists no versions for this label.'); usort($versions, function ($a, $b) { return $a['v'] <=> $b['v']; }); $json = json_encode(['setid' => $id, 'title' => $title, 'versions' => $versions], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); cache_put($key, $json); echo $json; exit;}if (isset($_GET['setid'], $_GET['v'])) { $id = strtolower((string) $_GET['setid']); $v = (int) $_GET['v']; if (!preg_match($uuid, $id) || $v < 1 || $v > 100000) fail(400, 'Ask for a set ID and a version number.'); $key = "v-$id-$v"; header('Cache-Control: public, max-age=31536000, immutable'); if (($c = cache_get($key, 0)) !== null) { echo $c; exit; } [$status, $zip] = fetch($UP . "getFile.cfm?type=zip&setid=$id&version=$v", MAX_ZIP); if ($status === 404 || substr($zip, 0, 2) !== 'PK') fail(404, "DailyMed has no file for version $v of this label."); if ($status >= 400) fail(502, "DailyMed answered with an error ($status)."); $xml = xml_from_zip($zip); if ($xml === null) fail(502, 'The label file has no readable XML inside.'); $doc = parse_spl($xml); $doc['v'] = $doc['v'] ?: $v; $json = json_encode($doc, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); cache_put($key, $json); echo $json; exit;}fail(400, 'Use ?q=, ?history= or ?setid=&v=.');engine.js206 lines
/* Label Time Machine engine: section grouping, version models, paragraph and word diffs, change summaries and milestones. Pure functions, no DOM; also runs in Node for the tests. MIT. */(function (root) { 'use strict'; /* ================================================================ section groups */ // Matched on the top-level section's LOINC code first, then on its title. const GROUPS = [ { k: 'bw', name: 'Boxed warning', codes: ['34066-1'], re: /BOXED WARNING|^WARNING:/i }, { k: 'ind', name: 'Indications', codes: ['34067-9'], re: /^(\d+\s+)?INDICATIONS/i }, { k: 'dos', name: 'Dosage', codes: ['34068-7', '43678-2'], re: /DOSAGE/i }, { k: 'ci', name: 'Contraindications', codes: ['34070-3'], re: /CONTRAINDICATIONS/i }, { k: 'wp', name: 'Warnings and precautions', codes: ['43685-7', '34071-1', '42232-9'], re: /^(\d+\s+)?(WARNINGS|PRECAUTIONS)/i }, { k: 'ar', name: 'Adverse reactions', codes: ['34084-4'], re: /ADVERSE REACTIONS/i }, { k: 'di', name: 'Drug interactions', codes: ['34073-7'], re: /INTERACTIONS/i }, { k: 'pop', name: 'Specific populations', codes: ['43684-0', '42228-7', '34080-2', '34081-0', '34082-8', '77290-5'], re: /SPECIFIC POPULATIONS|PREGNANCY|NURSING|LACTATION|PEDIATRIC|GERIATRIC/i }, { k: 'cs', name: 'Clinical studies', codes: ['34092-7'], re: /CLINICAL STUDIES/i }, { k: 'pk', name: 'Pharmacology', codes: ['34090-1', '43680-8', '34089-3', '34088-5'], re: /PHARMACOLOGY|DESCRIPTION|TOXICOLOGY|OVERDOSAGE/i }, { k: 'pt', name: 'Patient information', codes: ['34076-0', '42231-1', '42230-3', '59845-8'], re: /MEDICATION GUIDE|PATIENT (COUNSELING|INFORMATION)|INSTRUCTIONS FOR USE/i }, { k: 'oth', name: 'Everything else', codes: [], re: null }, ]; const RMC = '43683-2'; const MATCH_ORDER = ['pt', 'ci', 'bw', 'ind', 'dos', 'wp', 'ar', 'di', 'pop', 'cs', 'pk']; const byK = Object.fromEntries(GROUPS.map(g => [g.k, g])); function groupOf(sec) { for (const g of GROUPS) if (g.codes.includes(sec.c)) return g.k; const t = (sec.t || '').replace(/\s+/g, ' ').trim(); for (const k of MATCH_ORDER) if (byK[k].re.test(t)) return k; return 'oth'; } // "5.3 Ketoacidosis" -> { num: '5.3', name: 'Ketoacidosis', norm: 'ketoacidosis' } function splitTitle(t) { const s = (t || '').replace(/\s+/g, ' ').trim(); const m = s.match(/^(\d+(?:\.\d+)*)\.?\s+(.*)$/); const name = m ? m[2] : s; return { num: m ? m[1] : '', name, norm: name.toLowerCase().replace(/[’']/g, "'").replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim() }; } const words = s => (s.replace(/^## /, '').match(/\S+/g) || []).length; /* ================================================================ version model */ // raw = relay JSON for one version: { v, effective, title, labeler, sections: [{c,t,d,p,x}] } function buildVersion(raw, date) { const secs = raw.sections || []; const top = new Array(secs.length); secs.forEach((s, i) => { top[i] = s.p < 0 || s.d === 0 ? i : top[s.p]; }); const groups = {}; GROUPS.forEach(g => { groups[g.k] = { paras: [], words: 0, subs: [] }; }); let rmc = '', plr = false; secs.forEach((s, i) => { if (s.c === RMC) { rmc = s.x || ''; return; } if (s.c === '43685-7') plr = true; const k = groupOf(secs[top[i]]), G = groups[k]; if (s.t) { G.paras.push('## ' + s.t.replace(/\s+/g, ' ').trim()); if (s.d > 0) { const st = splitTitle(s.t); if (st.norm) G.subs.push(st); } } (s.x || '').split('\n').forEach(line => { const l = line.trim(); if (l) G.paras.push(l); }); }); let total = 0; GROUPS.forEach(g => { const G = groups[g.k]; G.words = G.paras.reduce((n, p) => n + (p.startsWith('## ') ? 0 : words(p)), 0); total += G.words; }); const bwText = groups.bw.paras.filter(p => !p.startsWith('## ')).join(' '); return { v: raw.v, date: date || raw.effective || '', effective: raw.effective || '', title: raw.title || '', labeler: raw.labeler || '', groups, total, rmc, plr, hasBW: groups.bw.words > 0, bwTitle: (groups.bw.paras.find(p => p.startsWith('## ')) || '').slice(3), bwText }; } /* ================================================================ diff */ // Myers O(ND) diff. Returns ops: [['=', i, j] | ['-', i] | ['+', j]]; null if more than maxD edits. function myers(a, b, maxD) { const n = a.length, m = b.length, max = n + m, off = max + 1; maxD = Math.min(max, maxD == null ? 4000 : maxD); let v = new Int32Array(2 * max + 3); const trace = []; let found = -1; for (let d = 0; d <= maxD; d++) { trace.push(v.slice(off - d - 1, off + d + 2)); for (let k = -d; k <= d; k += 2) { let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1; let y = x - k; while (x < n && y < m && a[x] === b[y]) { x++; y++; } v[off + k] = x; if (x >= n && y >= m) { found = d; break; } } if (found >= 0) break; } if (found < 0) return null; const ops = []; let x = n, y = m; for (let d = found; d > 0; d--) { const V = trace[d], at = k => V[k + d + 1]; const k = x - y; const prevK = (k === -d || (k !== d && at(k - 1) < at(k + 1))) ? k + 1 : k - 1; const px = at(prevK), py = px - prevK; while (x > px && y > py) { ops.push(['=', --x, --y]); } if (x === px) ops.push(['+', --y]); else ops.push(['-', --x]); } while (x > 0 && y > 0) ops.push(['=', --x, --y]); return ops.reverse(); } const tokens = s => s.match(/\s+|[\w’'.%/-]+|[^\s\w]/g) || []; function wordDiff(a, b) { const ta = tokens(a), tb = tokens(b), ops = myers(ta, tb, 600); if (!ops) return [{ t: '-', s: a }, { t: ' ', s: ' ' }, { t: '+', s: b }]; const out = []; const push = (t, s) => { const last = out[out.length - 1]; if (last && last.t === t) last.s += s; else out.push({ t, s }); }; ops.forEach(([o, i, j]) => { if (o === '=') push('=', ta[i]); else if (o === '-') push('-', ta[i]); else push('+', tb[i]); }); // whitespace-only edits read as noise: fold them back into the surrounding text return out.map(p => (p.t !== '=' && !p.s.trim()) ? { t: '=', s: p.t === '-' ? '' : p.s } : p).filter(p => p.s !== ''); } function similarity(a, b) { const A = new Set(a.toLowerCase().match(/\w+/g) || []), B = new Set(b.toLowerCase().match(/\w+/g) || []); if (!A.size || !B.size) return 0; let inter = 0; A.forEach(w => { if (B.has(w)) inter++; }); return inter / (A.size + B.size - inter); } /* Paragraph diff, then word diff inside paragraphs that were edited rather than replaced. Blocks: {t:'=', s} {t:'+', s} {t:'-', s} {t:'~', parts:[{t,s}]}; counts in words. */ function diffParas(A, B) { if (A.length === B.length && A.every((p, i) => p === B[i])) return { blocks: A.map(s => ({ t: '=', s })), added: 0, removed: 0, changed: false }; let ops = myers(A, B, 1500); if (!ops) ops = A.map((_, i) => ['-', i]).concat(B.map((_, j) => ['+', j])); const blocks = []; let added = 0, removed = 0, i = 0; while (i < ops.length) { if (ops[i][0] === '=') { blocks.push({ t: '=', s: A[ops[i][1]] }); i++; continue; } const del = [], ins = []; while (i < ops.length && ops[i][0] !== '=') { if (ops[i][0] === '-') del.push(A[ops[i][1]]); else ins.push(B[ops[i][1]]); i++; } // pair edited paragraphs in order let di = 0; ins.forEach(p => { let best = -1, bs = 0.35; for (let q = di; q < Math.min(del.length, di + 6); q++) { const s = similarity(del[q], p); if (s > bs) { bs = s; best = q; } } if (best >= 0) { for (; di < best; di++) { blocks.push({ t: '-', s: del[di] }); removed += words(del[di]); } const parts = wordDiff(del[di], p); di++; parts.forEach(x => { if (x.t === '+') added += words(x.s); if (x.t === '-') removed += words(x.s); }); blocks.push({ t: '~', parts }); } else { blocks.push({ t: '+', s: p }); added += words(p); } }); for (; di < del.length; di++) { blocks.push({ t: '-', s: del[di] }); removed += words(del[di]); } } return { blocks, added, removed, changed: added + removed > 0 }; } /* ================================================================ change summary and milestones */ // The added paragraph least like anything in the old text: the genuinely new content. function novelAdded(diff, oldParas) { let best = null, score = 2; diff.blocks.forEach(b => { if (b.t !== '+' || b.s.startsWith('## ')) return; const s = oldParas.reduce((m, p) => Math.max(m, similarity(p, b.s)), 0); if (s < score) { score = s; best = b.s; } }); return best; } const firstSentence = s => { const t = s.replace(/^## /, '').replace(/\s+/g, ' ').trim(); const m = t.match(/^(.{20,220}?[.;:])(\s|$)/); return m ? m[1] : t.slice(0, 200); }; function compareVersions(P, C) { const groups = {}; let added = 0, removed = 0; GROUPS.forEach(g => { const a = P.groups[g.k], b = C.groups[g.k], d = diffParas(a.paras, b.paras); const pn = new Set(a.subs.map(s => s.norm)), cn = new Set(b.subs.map(s => s.norm)); const newSubs = b.subs.filter(s => !pn.has(s.norm)), goneSubs = a.subs.filter(s => !cn.has(s.norm)); groups[g.k] = { added: d.added, removed: d.removed, changed: d.changed, newSubs, goneSubs, diff: d }; added += d.added; removed += d.removed; }); const ms = []; const formatChange = !P.plr && C.plr; if (formatChange) ms.push({ kind: 'fmt', major: true, text: 'Label rewritten in the current FDA format (PLR): most sections were restructured' }); if (!P.hasBW && C.hasBW) ms.push({ kind: 'bw', major: true, text: `Boxed warning added${C.bwTitle ? ': ' + C.bwTitle : ''}` }); else if (P.hasBW && !C.hasBW) ms.push({ kind: 'bw', major: true, text: 'Boxed warning removed' }); else if (C.hasBW && groups.bw.changed) ms.push({ kind: 'bw', major: true, text: `Boxed warning updated${P.bwTitle !== C.bwTitle && C.bwTitle ? ': now "' + C.bwTitle + '"' : ''} (+${groups.bw.added} / −${groups.bw.removed} words)` }); if (!formatChange) { const gi = groups.ind; gi.newSubs.forEach(s => ms.push({ kind: 'ind', major: true, text: `New indication: ${s.name}` })); gi.goneSubs.forEach(s => ms.push({ kind: 'ind', major: true, text: `Indication removed: ${s.name}` })); if (gi.changed && !gi.newSubs.length && !gi.goneSubs.length) { const nov = novelAdded(gi.diff, P.groups.ind.paras), edit = gi.diff.blocks.find(b => b.t === '~'); const snippet = nov ? firstSentence(nov) : edit ? edit.parts.filter(p => p.t === '+').map(p => p.s.trim()).filter(Boolean).join(' … ').slice(0, 160) : ''; ms.push({ kind: 'ind', major: gi.added + gi.removed >= 8, text: `Indications text changed (+${gi.added} / −${gi.removed} words)${snippet ? ': "' + snippet.trim() + '"' : ''}` }); } const gc = groups.ci; if (gc.changed) { const nov = novelAdded(gc.diff, P.groups.ci.paras); ms.push({ kind: 'ci', major: true, text: `Contraindications changed (+${gc.added} / −${gc.removed} words)${nov ? ': "' + firstSentence(nov.replace(/^•\s*/, '')) + '"' : ''}` }); } groups.wp.newSubs.forEach(s => ms.push({ kind: 'wp', major: true, text: `New warning: ${s.name}` })); groups.wp.goneSubs.forEach(s => ms.push({ kind: 'wp', major: false, text: `Warning removed or renamed: ${s.name}` })); groups.dos.newSubs.forEach(s => ms.push({ kind: 'dos', major: false, text: `New dosing section: ${s.name}` })); groups.pop.newSubs.forEach(s => ms.push({ kind: 'pop', major: false, text: `New specific-population section: ${s.name}` })); } return { groups, added, removed, milestones: ms }; } // Ranked list of what changed, for the "what changed" panel. function changeList(cmp) { return GROUPS.map(g => Object.assign({ k: g.k, name: g.name }, cmp.groups[g.k])).filter(x => x.changed) .sort((a, b) => GROUPS.findIndex(g => g.k === a.k) - GROUPS.findIndex(g => g.k === b.k)); } function csvEscape(s) { s = String(s == null ? '' : s); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } const API = { GROUPS, RMC, groupOf, splitTitle, buildVersion, myers, wordDiff, diffParas, compareVersions, changeList, words, csvEscape, similarity }; root.LTM = API; if (typeof module !== 'undefined' && module.exports) module.exports = API;})(typeof self !== 'undefined' ? self : this);app.js526 lines
/* Label Time Machine: interface. Label logic lives in engine.js; DailyMed is reached through labels.php. MIT. */'use strict';(() => { const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const L = window.LTM; const COL = { bw: '#FF2A85', ind: '#00F0FF', dos: '#3B82F6', ci: '#F4FF5A', wp: '#B57BFF', ar: '#FB923C', di: '#34D399', pop: '#A3E635', cs: '#38BDF8', pk: '#64748B', pt: '#E2E8F0', oth: '#334155' }; const STACK = ['bw', 'ind', 'ci', 'wp', 'ar', 'dos', 'di', 'pop', 'cs', 'pk', 'pt', 'oth']; const PRIORITY = ['bw', 'ind', 'ci', 'wp', 'dos', 'ar', 'pop', 'di', 'cs', 'pk', 'pt', 'oth']; const NAME = Object.fromEntries(L.GROUPS.map(g => [g.k, g.name])); const FONT = '11px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace'; const MAX_VERSIONS = 150, CONCURRENCY = 3, PLAY_MS = 1100; const REDUCED = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const int = v => Math.round(v).toLocaleString('en-US'); const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; let LBL = null, playT = null, rafQ = false; const D = { g: null, a: null, b: null, pinned: false, open: new Set() }; /* ================================================================ data */ async function api(qs) { let r; try { r = await fetch('labels.php?' + qs, { headers: { Accept: 'application/json' } }); } catch (e) { throw new Error(navigator.onLine ? 'The lab server could not be reached. Try again in a moment.' : 'You are offline. The fictional demo still works.'); } if (!(r.headers.get('content-type') || '').includes('json')) { const e = new Error('relay'); e.relay = true; throw e; } const j = await r.json(); if (!r.ok) throw new Error(j.error || `Error ${r.status}`); return j; } let demoData = null; async function demo() { if (!demoData) demoData = await (await fetch('demo.json')).json(); return demoData; } const splitName = t => { const m = String(t || '').match(/^(.*?)\s*\[([^\]]+)\]\s*$/); return m ? { name: m[1], labeler: m[2] } : { name: t || '', labeler: '' }; }; const prevLoaded = i => { for (let j = i - 1; j >= 0; j--) if (LBL.models[j]) return j; return -1; }; const nextLoaded = i => { for (let j = i + 1; j < LBL.versions.length; j++) if (LBL.models[j]) return j; return -1; }; function getCmp(i) { const j = prevLoaded(i); if (j < 0 || !LBL.models[i]) return null; const c = LBL.cmps[i]; if (c && c.from === j) return c; return (LBL.cmps[i] = Object.assign({ from: j }, L.compareVersions(LBL.models[j], LBL.models[i]))); } /* ================================================================ search */ const results = $('#results'); function relayMsg() { return `<div class="msg">Live label search runs on the mafflab server, and this copy of the app cannot reach it. The fictional demo works anywhere.<br><button class="btn btn-s" type="button" data-demo>Open the demo</button></div>`; } async function search(q) { q = q.trim(); if (q.length < 2) { $('#q').focus(); return; } showFind(); $('#q').value = q; results.innerHTML = '<p class="rcount">Searching DailyMed…</p>'; try { const j = await api('q=' + encodeURIComponent(q)); renderResults(j); } catch (e) { results.innerHTML = e.relay ? relayMsg() : `<div class="msg">${esc(e.message)}</div>`; } } function renderResults(j) { const rs = j.results || []; if (!rs.length) { results.innerHTML = `<div class="msg">No prescription labels found for “${esc(j.q)}”. Try the generic name, or check the spelling.</div>`; return; } const more = j.total > rs.length ? ` (the ${rs.length} most recently updated of ${int(j.total)})` : ''; results.innerHTML = `<div class="results"> <p class="rcount">${int(rs.length)} label${rs.length === 1 ? '' : 's'}${more}. Brands, generics and repackagers each have their own label: pick the one you mean.</p> ${rs.length > 8 ? '<input class="input filter" id="r-filter" type="search" placeholder="Filter by name or company" aria-label="Filter results">' : ''} <div class="rlist" id="r-list">${rs.map(r => { const t = splitName(r.title); return `<button class="ritem" type="button" data-setid="${esc(r.setid)}" data-title="${esc(r.title)}"><b>${esc(t.name)}</b><small>${esc(t.labeler)}</small><time>${esc(r.published)}</time></button>`; }).join('')}</div></div>`; const f = $('#r-filter'); if (f) f.addEventListener('input', () => { const v = f.value.toLowerCase(); $$('#r-list .ritem').forEach(b => { b.hidden = !b.dataset.title.toLowerCase().includes(v); }); }); } /* ================================================================ open a label */ function showFind() { stopPlay(); $('#find').hidden = false; $('#label').hidden = true; $('#share').hidden = true; } function showLabel() { $('#find').hidden = true; $('#label').hidden = false; $('#share').hidden = false; window.scrollTo(0, 0); } function newLabel(setid, title, isDemo) { stopPlay(); LBL = { setid, title, demo: !!isDemo, versions: [], models: [], cmps: [], failed: new Set(), cur: 0, loading: true, sampled: false }; D.g = null; D.pinned = false; D.open.clear(); showLabel(); renderAll(); } async function openLabel(setid, title, wantV) { newLabel(setid, title, false); const me = LBL; progress(0, 'Asking DailyMed for every published version…'); let h; try { h = await api('history=' + encodeURIComponent(setid)); } catch (e) { if (LBL !== me) return; LBL.loading = false; progress(null); $('#c-body').innerHTML = e.relay ? relayMsg() : `<div class="msg">${esc(e.message)}</div>`; renderHead(); return; } if (LBL !== me) return; let vs = (h.versions || []).filter(v => v.v > 0); if (vs.length > MAX_VERSIONS) { const keep = new Set([0, vs.length - 1]); for (let k = 0; k < MAX_VERSIONS; k++) keep.add(Math.round(k * (vs.length - 1) / (MAX_VERSIONS - 1))); vs = vs.filter((_, i) => keep.has(i)); LBL.sampled = h.versions.length; } LBL.versions = vs; LBL.title = LBL.title || h.title || ''; LBL.want = wantV; renderAll(); await scan(me); } async function openDemo(wantV) { newLabel('demo', 'DEMOLARA (demonstrafin) tablets [EXAMPLE THERAPEUTICS (FICTIONAL)]', true); const me = LBL, d = await demo(); if (LBL !== me) return; LBL.versions = d.history; LBL.want = wantV; renderAll(); // the demo "reads" its versions with a short delay so the timeline builds the way live data does for (let i = 0; i < d.versions.length; i++) { if (LBL !== me) return; LBL.models[i] = L.buildVersion(d.versions[i], d.history[i].date); loaded(me, i + 1); if (!REDUCED) await new Promise(r => setTimeout(r, 90)); } finish(me); } async function scan(me) { const n = me.versions.length; let next = 0, done = 0; const work = async () => { while (next < n && LBL === me) { const i = next++; try { const raw = await api(`setid=${encodeURIComponent(me.setid)}&v=${me.versions[i].v}`); if (LBL !== me) return; me.models[i] = L.buildVersion(raw, me.versions[i].date); } catch (e) { me.failed.add(i); if (e.relay) { next = n; } } loaded(me, ++done); } }; await Promise.all(Array.from({ length: CONCURRENCY }, work)); if (LBL === me) finish(me); } function loaded(me, done) { const n = me.versions.length; let f = -1; for (let i = 0; i < n; i++) { if (me.models[i]) f = i; else if (!me.failed.has(i)) break; } if (f >= 0 && !me.userMoved) me.cur = f; const v = me.versions[Math.min(n - 1, done - 1)]; progress(done / n, `Reading version ${int(done)} of ${int(n)}${v ? ' · ' + v.date : ''}`); queueRender(); } function finish(me) { me.loading = false; progress(null); let i = -1; if (me.userMoved && me.models[me.cur]) i = me.cur; else if (me.want != null) i = me.versions.findIndex(v => v.v === me.want); if (i < 0 || !me.models[i]) { i = me.versions.length - 1; while (i > 0 && !me.models[i]) i--; } me.cur = Math.max(0, i); if (!me.models.some(Boolean)) { $('#c-body').innerHTML = '<div class="msg">None of the versions could be read. DailyMed may be busy: try again in a few minutes.</div>'; } $('#l-csv').disabled = !me.models.some(Boolean); renderAll(); setHash(); } function progress(f, text) { const el = $('#l-prog'); if (f == null) { el.hidden = true; return; } el.hidden = false; $('#l-pbar').style.width = `${Math.round(f * 100)}%`; $('#l-ptext').textContent = text; } /* ================================================================ rendering */ function queueRender() { if (rafQ) return; rafQ = true; requestAnimationFrame(() => { rafQ = false; renderAll(); }); } function renderAll() { if (!LBL) return; renderHead(); drawStrata(); renderPlayer(); renderChanges(); renderDiff(); renderMilestones(); } function latestModel() { for (let i = LBL.versions.length - 1; i >= 0; i--) if (LBL.models[i]) return LBL.models[i]; return null; } function renderHead() { const t = splitName(LBL.title), m = latestModel(), n = LBL.versions.length; $('#l-eyebrow').textContent = LBL.demo ? 'Fictional demo label' : 'DailyMed label'; $('#l-title').textContent = t.name || 'Label'; $('#l-sub').textContent = [t.labeler || (m && m.labeler), LBL.demo ? '' : `Set ID ${LBL.setid}`].filter(Boolean).join(' · '); $('#l-fiction').hidden = !LBL.demo; const dm = $('#l-dm'); dm.hidden = LBL.demo; dm.href = `https://dailymed.nlm.nih.gov/dailymed/drugInfo.cfm?setid=${encodeURIComponent(LBL.setid)}`; const kv = []; if (n) kv.push(`<span><b>${int(n)}</b> versions${LBL.sampled ? ` (sampled from ${int(LBL.sampled)})` : ''}</span>`, `<span>${esc(LBL.versions[0].date.slice(0, 4))} → <b>${esc(LBL.versions[n - 1].date.slice(0, 4))}</b></span>`); if (m) { const appr = (m.title.match(/Initial U\.?S\.? Approval:?\s*(\d{4})/i) || [])[1]; if (appr) kv.push(`<span>Initial US approval <b>${esc(appr)}</b></span>`); kv.push(`<span>Latest label <b>${int(m.total)}</b> words</span>`, `<span>Boxed warning <b>${m.hasBW ? 'yes' : 'no'}</b></span>`); } if (LBL.failed.size) kv.push(`<span>${int(LBL.failed.size)} version${LBL.failed.size === 1 ? '' : 's'} could not be read</span>`); $('#l-kv').innerHTML = kv.join(''); } /* ---------- canvas helpers */ function prep(cv) { const dpr = Math.min(2.5, window.devicePixelRatio || 1); if (!cv.dataset.h) cv.dataset.h = cv.getAttribute('height'); const h = +cv.dataset.h * (innerWidth < 640 ? 0.8 : 1), w = Math.max(240, cv.clientWidth || cv.parentNode.clientWidth || 300); cv.style.height = h + 'px'; const W = Math.round(w * dpr), H = Math.round(h * dpr); if (cv.width !== W) cv.width = W; if (cv.height !== H) cv.height = H; const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); return { ctx, w, h }; } const lin = (d0, d1, r0, r1) => v => r0 + (v - d0) / (d1 - d0) * (r1 - r0); function nice(max, n) { const s0 = max / n, mag = Math.pow(10, Math.floor(Math.log10(s0))), e = s0 / mag; const st = (e >= 7.5 ? 10 : e >= 3.5 ? 5 : e >= 1.5 ? 2 : 1) * mag; const o = []; for (let v = 0; v <= max + 1e-9; v += st) o.push(v); return o; } const kfmt = v => v >= 1000 ? (v / 1000).toFixed(v % 1000 ? 1 : 0).replace(/\.0$/, '') + 'k' : String(v); let geo = null; // x of each version, for clicks function drawStrata() { const cv = $('#c-strata'), { ctx, w, h } = prep(cv); const vs = LBL.versions, ms = LBL.models, n = vs.length; geo = null; if (!n) return; const B = { l: 48, r: w - 14, t: 30, b: h - 30 }; const T = vs.map(v => Date.parse(v.date) || 0); let t0 = Math.min(...T), t1 = Math.max(...T); if (t1 - t0 < 864e5 * 120) { t0 -= 864e5 * 200; t1 += 864e5 * 200; } const span = t1 - t0, xs = lin(t0 - span * 0.01, t1 + span * 0.05, B.l, B.r); let ymax = 200; ms.forEach(m => { if (m) ymax = Math.max(ymax, m.total); }); ymax *= 1.18; const ys = lin(0, ymax, B.b, B.t); // grid and axes ctx.font = FONT; ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(203,213,225,.08)'; ctx.fillStyle = '#64748B'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; nice(ymax, 4).forEach(v => { const y = Math.round(ys(v)) + 0.5; ctx.beginPath(); ctx.moveTo(B.l, y); ctx.lineTo(B.r, y); ctx.stroke(); ctx.fillText(kfmt(v), B.l - 8, y); }); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const y0 = new Date(t0).getUTCFullYear(), y1 = new Date(t1).getUTCFullYear(), ystep = Math.max(1, Math.ceil((y1 - y0 + 1) / (w < 560 ? 5 : 10))); for (let y = y0; y <= y1 + 1; y += ystep) { const x = xs(Date.UTC(y, 0, 1)); if (x < B.l || x > B.r) continue; ctx.beginPath(); ctx.moveTo(Math.round(x) + 0.5, B.t); ctx.lineTo(Math.round(x) + 0.5, B.b); ctx.stroke(); ctx.fillText(String(y), x, B.b + 8); } ctx.fillStyle = '#CBD5E1'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillText('words', 4, B.t - 10); // stacked steps const idx = []; for (let i = 0; i < n; i++) if (ms[i]) idx.push(i); const xEnd = xs(t1 + span * 0.05); geo = { xs: T.map(t => xs(t)), B }; idx.forEach((i, k) => { const m = ms[i], x0 = xs(T[i]), x1 = k + 1 < idx.length ? xs(T[idx[k + 1]]) : (LBL.loading ? x0 + 4 : xEnd); let cum = 0; STACK.forEach(g => { const v = m.groups[g].words; if (!v) return; ctx.fillStyle = COL[g]; ctx.globalAlpha = g === 'oth' ? 0.5 : g === 'pt' ? 0.28 : 0.62; ctx.fillRect(x0, ys(cum + v), Math.max(1, x1 - x0), ys(cum) - ys(cum + v)); cum += v; }); }); ctx.globalAlpha = 1; // outline of the total ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,.75)'; ctx.lineWidth = 1.4; ctx.shadowColor = '#FF2A85'; ctx.shadowBlur = 8; ctx.beginPath(); idx.forEach((i, k) => { const x0 = xs(T[i]), x1 = k + 1 < idx.length ? xs(T[idx[k + 1]]) : (LBL.loading ? x0 + 4 : xEnd), y = ys(ms[i].total); if (!k) ctx.moveTo(x0, y); else ctx.lineTo(x0, y); ctx.lineTo(x1, y); }); ctx.stroke(); ctx.restore(); // version ticks for (let i = 0; i < n; i++) { ctx.fillStyle = ms[i] ? 'rgba(0,240,255,.8)' : LBL.failed.has(i) ? '#FF2A85' : 'rgba(100,116,139,.6)'; ctx.fillRect(Math.round(xs(T[i])), B.b + 1, 1.5, 5); } // milestone markers idx.forEach(i => { const c = getCmp(i); if (!c) return; const major = c.milestones.filter(x => x.major); if (!major.length) return; const kinds = [...new Set(major.map(x => x.kind))].sort((a, b) => PRIORITY.indexOf(a) - PRIORITY.indexOf(b)); const x = xs(T[i]), top = ys(ms[i].total); kinds.slice(0, 3).forEach((kd, j) => { const y = top - 10 - j * 11, s = 4.2; ctx.save(); ctx.fillStyle = COL[kd] || '#CBD5E1'; ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 8; ctx.beginPath(); ctx.moveTo(x, y - s); ctx.lineTo(x + s, y); ctx.lineTo(x, y + s); ctx.lineTo(x - s, y); ctx.closePath(); ctx.fill(); ctx.restore(); }); }); // playhead const cur = LBL.cur, m = ms[cur]; if (m) { const x = xs(T[cur]) + 0.5; ctx.save(); ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 1.5; ctx.shadowColor = '#FFFFFF'; ctx.shadowBlur = 10; ctx.beginPath(); ctx.moveTo(x, B.t - 4); ctx.lineTo(x, B.b); ctx.stroke(); ctx.restore(); const label = `v${m.v} · ${vs[cur].date}`; ctx.font = FONT; const tw = ctx.measureText(label).width + 12; let bx = x + 8; if (bx + tw > B.r) bx = x - 8 - tw; ctx.fillStyle = 'rgba(13,13,18,.92)'; ctx.strokeStyle = '#FF2A85'; ctx.fillRect(bx, B.t - 2, tw, 20); ctx.strokeRect(bx + 0.5, B.t - 1.5, tw - 1, 19); ctx.fillStyle = '#FFFFFF'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(label, bx + 6, B.t + 8); } if (LBL.loading && idx.length) { const x = xs(T[idx[idx.length - 1]]); ctx.save(); ctx.strokeStyle = '#00F0FF'; ctx.shadowColor = '#00F0FF'; ctx.shadowBlur = 14; ctx.globalAlpha = 0.8; ctx.beginPath(); ctx.moveTo(x + 4, B.t); ctx.lineTo(x + 4, B.b); ctx.stroke(); ctx.restore(); } // legend: groups that appear in the latest version, in stack order (top of the stack first) const lm = latestModel(); if (lm) $('#s-legend').innerHTML = STACK.slice().reverse().filter(g => lm.groups[g].words || ms.some(x => x && x.groups[g].words)).map(g => `<span><i class="g-${g}"></i>${esc(NAME[g])}</span>`).join('') + '<span class="kc">◆ key change</span>'; } /* ---------- player */ function renderPlayer() { const n = LBL.versions.length, s = $('#p-scrub'); s.max = Math.max(0, n - 1); s.value = LBL.cur; s.disabled = n < 2; const m = LBL.models[LBL.cur], v = LBL.versions[LBL.cur]; $('#p-now').textContent = v ? `v${v.v} · ${v.date} · ${int(LBL.cur + 1)} of ${int(n)}` : ''; $('#p-play').textContent = playT ? 'Pause' : 'Play'; $('#p-prev').disabled = !m || prevLoaded(LBL.cur) < 0; $('#p-next').disabled = !m || nextLoaded(LBL.cur) < 0; } function setCur(i, opts) { if (!LBL) return; LBL.userMoved = true; const n = LBL.versions.length; i = Math.max(0, Math.min(n - 1, i)); if (!LBL.models[i]) { const a = prevLoaded(i), b = nextLoaded(i); i = b >= 0 && (a < 0 || b - i < i - a) ? b : a; if (i < 0) return; } LBL.cur = i; if (!(opts && opts.keepCompare)) D.pinned = false; D.open.clear(); if (!(opts && opts.fromPlay)) stopPlay(); renderAll(); setHash(); } function play() { if (playT) { stopPlay(); return; } if (nextLoaded(LBL.cur) < 0) { const f = LBL.models.findIndex(Boolean); if (f >= 0) setCur(f, { fromPlay: true }); } playT = setInterval(() => { const nx = nextLoaded(LBL.cur); if (nx < 0) { stopPlay(); return; } setCur(nx, { fromPlay: true }); }, PLAY_MS); renderPlayer(); } function stopPlay() { if (playT) { clearInterval(playT); playT = null; } if (LBL) renderPlayer(); } /* ---------- what changed */ function renderChanges() { const i = LBL.cur, m = LBL.models[i], body = $('#c-body'), v = LBL.versions[i]; if (!m) { $('#c-title').textContent = 'What changed'; if (LBL.loading) body.innerHTML = '<p class="quiet">Reading the label…</p>'; return; } $('#c-title').textContent = `What changed in version ${m.v}`; const j = prevLoaded(i), c = getCmp(i); let h = ''; if (j < 0) { h += `<p class="vline">Published <b>${esc(v.date)}</b> · the first version on DailyMed · ${int(m.total)} words</p>`; h += '<ul class="ms"><li><span class="kind k-fmt">start</span><span>The history starts here. Changes made before a label\'s first electronic version are not included.</span></li></ul>'; } else { const p = LBL.versions[j]; h += `<p class="vline">Published <b>${esc(v.date)}</b> · compared with v${esc(p.v)} (${esc(p.date)}) · <b>+${int(c.added)}</b> / <b>−${int(c.removed)}</b> words</p>`; if (c.milestones.length) h += `<ul class="ms">${c.milestones.map(x => `<li><span class="kind k-${x.kind}">${esc(kindName(x.kind))}</span><span>${esc(x.text)}</span></li>`).join('')}</ul>`; } if (m.rmc) h += `<div class="rmc"><h3>Recent major changes, as this version lists them</h3>${m.rmc.split('\n').map(l => `<p>${esc(l.replace(/\s*\|\s*/g, ' · '))}</p>`).join('')}</div>`; if (c) { const list = L.changeList(c); h += list.length ? `<div class="gbars">${list.map(x => `<button class="gbar" type="button" data-g="${x.k}" aria-pressed="${D.g === x.k}"><span class="nm"><i class="g-${x.k}"></i><span>${esc(x.name)}</span></span><span class="p">+${int(x.added)}</span><span class="m">−${int(x.removed)}</span></button>`).join('')}</div>` : '<p class="quiet">No text changes in the label sections. Versions like this usually update packaging, product codes or formatting.</p>'; } else { h += `<div class="gbars">${PRIORITY.filter(g => m.groups[g].words).map(g => `<button class="gbar" type="button" data-g="${g}" aria-pressed="${D.g === g}"><span class="nm"><i class="g-${g}"></i><span>${esc(NAME[g])}</span></span><span class="p">${int(m.groups[g].words)}</span><span class="m"></span></button>`).join('')}</div>`; } body.innerHTML = h; } const kindName = k => ({ bw: 'boxed', ind: 'indication', ci: 'contra', wp: 'warning', dos: 'dosing', pop: 'populations', fmt: 'format' }[k] || k); /* ---------- diff */ function chooseGroup(c, m) { if (D.g && ((c && c.groups[D.g].changed) || (!c && m.groups[D.g].words))) return D.g; if (c) { const f = PRIORITY.find(g => c.groups[g].changed); if (f) return f; } if (D.g && m.groups[D.g].words) return D.g; return PRIORITY.find(g => m.groups[g].words) || 'ind'; } function renderDiff() { const body = $('#d-body'), i = LBL.cur, m = LBL.models[i]; const selA = $('#d-a'), selB = $('#d-b'); if (!m) { body.innerHTML = ''; $('#d-groups').innerHTML = ''; selA.innerHTML = selB.innerHTML = ''; return; } if (!D.pinned) { D.b = i; D.a = prevLoaded(i); if (D.a < 0) D.a = i; } const A = LBL.models[D.a], Bm = LBL.models[D.b]; if (!A || !Bm) { D.pinned = false; return; } const same = D.a === D.b; const c = !same && D.a === prevLoaded(D.b) ? getCmp(D.b) : null; const g = D.g = chooseGroup(c, Bm); // group tabs const changedSet = new Set(); let diffs = null; if (!same) { if (c) L.GROUPS.forEach(x => { if (c.groups[x.k].changed) changedSet.add(x.k); }); else { diffs = {}; L.GROUPS.forEach(x => { diffs[x.k] = L.diffParas(A.groups[x.k].paras, Bm.groups[x.k].paras); if (diffs[x.k].changed) changedSet.add(x.k); }); } } $('#d-groups').innerHTML = PRIORITY.filter(k => A.groups[k].paras.length || Bm.groups[k].paras.length) .map(k => `<button type="button" data-dg="${k}" aria-pressed="${k === g}" class="${changedSet.has(k) ? 'has' : ''}">${esc(NAME[k])}</button>`).join(''); // compare selects const opts = LBL.versions.map((v, k) => LBL.models[k] ? `<option value="${k}">v${esc(v.v)} · ${esc(v.date)}</option>` : '').join(''); if (selA.dataset.n !== String(LBL.models.filter(Boolean).length)) { selA.innerHTML = opts; selB.innerHTML = opts; selA.dataset.n = String(LBL.models.filter(Boolean).length); } selA.value = D.a; selB.value = D.b; // body const ga = A.groups[g].paras, gb = Bm.groups[g].paras; if (!ga.length && !gb.length) { body.innerHTML = `<p class="quiet">This label has no ${esc(NAME[g].toLowerCase())} section in these versions.</p>`; return; } const d = same ? { blocks: gb.map(s => ({ t: '=', s })), changed: false } : c ? c.groups[g].diff : diffs[g]; const showAll = $('#d-all').checked || !d.changed; let h = ''; if (same) h += `<p class="quiet">Showing version ${esc(Bm.v)} as published.</p>`; else if (!d.changed) h += `<p class="quiet">No changes to ${esc(NAME[g].toLowerCase())} between v${esc(A.v)} and v${esc(Bm.v)}. The section as published:</p>`; const blocks = d.blocks, keep = new Array(blocks.length).fill(showAll); if (!showAll) blocks.forEach((b, k) => { if (b.t === '=') return; for (let q = Math.max(0, k - 1); q <= Math.min(blocks.length - 1, k + 1); q++) keep[q] = true; for (let q = k - 1; q >= 0; q--) if (blocks[q].t === '=' && blocks[q].s.startsWith('## ')) { keep[q] = true; break; } // the subsection it sits in }); for (let k = 0; k < blocks.length;) { if (keep[k]) { h += blockHTML(blocks[k]); k++; continue; } let e = k; while (e < blocks.length && !keep[e]) e++; const id = 'g' + k; if (D.open.has(id)) for (let q = k; q < e; q++) h += blockHTML(blocks[q]); else h += `<div class="gap">${int(e - k)} unchanged paragraph${e - k === 1 ? '' : 's'} <button type="button" data-gap="${id}">show</button></div>`; k = e; } body.innerHTML = h; } function blockHTML(b) { const head = s => s.startsWith('## '); if (b.t === '~') { const inner = b.parts.map(p => p.t === '+' ? `<ins>${esc(p.s)}</ins>` : p.t === '-' ? `<del>${esc(p.s)}</del>` : esc(p.s)).join(''); return head(b.parts.map(p => p.s).join('')) ? `<h4>${inner.replace(/^## /, '')}</h4>` : `<p>${inner}</p>`; } const cls = b.t === '+' ? ' class="add"' : b.t === '-' ? ' class="del"' : ''; if (head(b.s)) return `<h4${cls}>${esc(b.s.slice(3))}</h4>`; return `<p${cls}>${esc(b.s)}</p>`; } /* ---------- milestones */ function allMilestones(minor) { const out = []; const f = LBL.models.findIndex(Boolean); if (f >= 0) out.push({ i: f, kind: 'fmt', text: `First version on DailyMed (${int(LBL.models[f].total)} words)` }); for (let i = f + 1; i < LBL.versions.length; i++) { const c = getCmp(i); if (!c) continue; const ms = c.milestones.filter(x => x.major || minor); ms.forEach(x => out.push({ i, kind: x.kind, text: x.text })); if (minor && !ms.length) { const list = L.changeList(c); out.push({ i, kind: 'fmt', text: list.length ? 'Edits: ' + list.map(x => `${x.name} +${x.added}/−${x.removed}`).join(', ') : 'No text changes (packaging, codes or formatting)' }); } } return out; } function renderMilestones() { const minor = $('#m-minor').checked, list = allMilestones(minor); $('#m-list').innerHTML = list.length ? list.map(x => `<li class="${x.i === LBL.cur ? 'cur' : ''}"><button type="button" data-v="${x.i}"><time>${esc(LBL.versions[x.i].date)}</time><span class="kind k-${x.kind}">${esc(kindName(x.kind))}</span><span>${esc(x.text)}</span></button></li>`).join('') : `<li class="quiet">${LBL.loading ? 'Milestones appear as versions are read.' : 'No key changes found.'}</li>`; } /* ================================================================ exports, links */ function csv() { const rows = [['version', 'published', 'words_total', 'words_added', 'words_removed', 'boxed_warning', 'key_changes', 'sections_changed', 'recent_major_changes']]; LBL.versions.forEach((v, i) => { const m = LBL.models[i]; if (!m) { rows.push([v.v, v.date, '', '', '', '', 'could not be read', '', '']); return; } const c = getCmp(i); rows.push([v.v, v.date, m.total, c ? c.added : '', c ? c.removed : '', m.hasBW ? 'yes' : 'no', c ? c.milestones.map(x => x.text).join(' | ') : 'first version', c ? L.changeList(c).map(x => `${x.name} +${x.added}/-${x.removed}`).join('; ') : '', (m.rmc || '').replace(/\n/g, ' | ')]); }); const text = rows.map(r => r.map(L.csvEscape).join(',')).join('\r\n'); const blob = new Blob(['' + text], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `label-history-${(splitName(LBL.title).name || 'label').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)}.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast('Change log downloaded'); } function setHash() { if (!LBL) return; const v = LBL.versions[LBL.cur]; const h = LBL.demo ? `#demo${v ? '&v=' + v.v : ''}` : `#setid=${LBL.setid}${v ? '&v=' + v.v : ''}`; try { history.replaceState(null, '', location.pathname + location.search + h); } catch (e) { } } function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2200); } async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { const ta = document.createElement('textarea'); ta.value = text; ta.className = 'sr-only'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e2) { } ta.remove(); return ok; } } /* ================================================================ events */ $('#f-search').addEventListener('submit', e => { e.preventDefault(); search($('#q').value); }); document.addEventListener('click', e => { const q = e.target.closest('[data-q]'); if (q) { search(q.dataset.q); return; } if (e.target.closest('[data-demo]')) { openDemo(); return; } const r = e.target.closest('.ritem'); if (r) { openLabel(r.dataset.setid, r.dataset.title); return; } const gb = e.target.closest('[data-g]'); if (gb) { D.g = gb.dataset.g; renderChanges(); renderDiff(); if (innerWidth < 960) $('.diffcard').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' }); return; } const dg = e.target.closest('[data-dg]'); if (dg) { D.g = dg.dataset.dg; D.open.clear(); renderChanges(); renderDiff(); return; } const gp = e.target.closest('[data-gap]'); if (gp) { D.open.add(gp.dataset.gap); renderDiff(); return; } const mv = e.target.closest('#m-list [data-v]'); if (mv) { setCur(+mv.dataset.v); $('#c-strata').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' }); } }); $('#p-play').addEventListener('click', play); $('#p-prev').addEventListener('click', () => { const p = prevLoaded(LBL.cur); if (p >= 0) setCur(p); }); $('#p-next').addEventListener('click', () => { const p = nextLoaded(LBL.cur); if (p >= 0) setCur(p); }); $('#p-scrub').addEventListener('input', e => setCur(+e.target.value)); $('#d-a').addEventListener('change', e => { D.a = +e.target.value; D.pinned = true; D.open.clear(); renderDiff(); }); $('#d-b').addEventListener('change', e => { D.b = +e.target.value; D.pinned = true; D.open.clear(); renderDiff(); }); $('#d-all').addEventListener('change', renderDiff); $('#m-minor').addEventListener('change', renderMilestones); $('#m-copy').addEventListener('click', async () => { const t = splitName(LBL.title).name + '\n' + allMilestones($('#m-minor').checked).map(x => `${LBL.versions[x.i].date} (v${LBL.versions[x.i].v}) ${x.text}`).join('\n') + `\nSource: DailyMed${LBL.demo ? ' (fictional demo)' : ', set ID ' + LBL.setid}`; toast(await copyText(t) ? 'Milestones copied' : 'Select the text and copy it'); }); $('#l-csv').addEventListener('click', csv); $('#l-new').addEventListener('click', () => { LBL = null; try { history.replaceState(null, '', location.pathname); } catch (e) { } showFind(); $('#q').focus(); }); $('#share').addEventListener('click', async () => { setHash(); toast(await copyText(location.href) ? 'Link copied: it opens this label at this version' : 'Copy the address bar to share'); }); // scrub on the chart const cv = $('#c-strata'); let dragging = false; const pick = e => { if (!geo || !LBL) return; const r = cv.getBoundingClientRect(), x = e.clientX - r.left; let best = 0; geo.xs.forEach((gx, i) => { if (LBL.models[i] && gx <= x + 3) best = i; }); if (best !== LBL.cur) setCur(best); }; cv.addEventListener('pointerdown', e => { dragging = true; cv.setPointerCapture(e.pointerId); pick(e); }); cv.addEventListener('pointermove', e => { if (dragging) pick(e); }); cv.addEventListener('pointerup', () => { dragging = false; }); cv.addEventListener('pointercancel', () => { dragging = false; }); document.addEventListener('keydown', e => { if (!LBL || $('#label').hidden || /INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)) return; if (e.key === 'ArrowRight') { const p = nextLoaded(LBL.cur); if (p >= 0) { e.preventDefault(); setCur(p); } } if (e.key === 'ArrowLeft') { const p = prevLoaded(LBL.cur); if (p >= 0) { e.preventDefault(); setCur(p); } } if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); play(); } }); let rzT, lastW = innerWidth; addEventListener('resize', () => { if (Math.abs(innerWidth - lastW) < 2) return; lastW = innerWidth; clearTimeout(rzT); rzT = setTimeout(() => { if (LBL) drawStrata(); }, 100); }); /* ================================================================ boot */ function route() { const h = location.hash.slice(1), p = new URLSearchParams(h.replace(/^demo&?/, 'demo=1&')); const v = p.get('v') ? parseInt(p.get('v'), 10) : null; if (h.startsWith('demo')) { openDemo(v); return true; } const s = p.get('setid'); if (s && UUID.test(s)) { openLabel(s.toLowerCase(), '', v); return true; } return false; } if (!route()) { showFind(); } /* ---------- PWA: offline + install ---------- */ const off = $('#offline'); const net = () => { off.hidden = navigator.onLine; }; addEventListener('online', net); addEventListener('offline', net); net(); if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('sw.js', { scope: './' }).catch(() => { }); let prompt = null; const ib = $('#install'); addEventListener('beforeinstallprompt', e => { e.preventDefault(); prompt = e; ib.hidden = false; }); ib.addEventListener('click', async () => { if (!prompt) return; prompt.prompt(); try { await prompt.userChoice; } catch (e) { } prompt = null; ib.hidden = true; }); addEventListener('appinstalled', () => { ib.hidden = true; });})();index.html117 lines
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'"><meta name="referrer" content="no-referrer"><title>Label Time Machine · every version of a US drug label</title><meta name="description" content="Search a US prescription drug label on DailyMed and scrub through every published version: new indications, boxed warnings, contraindications and warnings, highlighted word by word."><meta name="theme-color" content="#0D0D12"><link rel="manifest" href="manifest.webmanifest"><link rel="icon" href="icons/icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icons/apple-touch-icon.png"><link rel="stylesheet" href="mafflab-theme.css"><link rel="stylesheet" href="style.css"><script src="engine.js" defer></script><script src="app.js" defer></script></head><body><a class="skip" href="#main">Skip to content</a><header class="app-head"> <span class="lt-logo" aria-hidden="true"><svg viewBox="0 0 32 32"><circle cx="16" cy="16" r="12.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M16 8.5V16l5 3" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M3.5 12.5 1.5 9M3.5 12.5l3.6-1.6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg></span> <a class="app-name home" href="./">Label Time Machine</a> <span class="app-tag">Every version of a US drug label, in order</span> <span class="spacer"></span> <span class="offline" id="offline" hidden>Offline: only the demo works</span> <button class="btn btn-s" id="share" type="button" hidden title="Copy a link to this label and version">Copy link</button> <button class="btn btn-s" id="install" type="button" hidden>Install</button></header><main id="main" class="lt-main"> <section class="find" id="find" aria-labelledby="find-title"> <div class="find-copy"> <h1 id="find-title">Watch a drug label change over the years</h1> <p>Search a US prescription label on DailyMed, then scrub through every version it has published. New indications, boxed warnings, contraindications and warnings appear as they happened, word by word.</p> </div> <form class="search" id="f-search" role="search" autocomplete="off"> <label class="sr-only" for="q">Brand or generic name</label> <input class="input" id="q" type="search" maxlength="60" placeholder="Brand or generic name, e.g. adalimumab" enterkeyhint="search"> <button class="btn btn-mag" type="submit">Search DailyMed</button> </form> <div class="chips" aria-label="Examples"> <span>Try</span> <button type="button" data-q="adalimumab">adalimumab</button> <button type="button" data-q="pembrolizumab">pembrolizumab</button> <button type="button" data-q="semaglutide">semaglutide</button> <button type="button" data-q="atorvastatin">atorvastatin</button> <button type="button" class="demo" data-demo>Fictional demo</button> </div> <div id="results" aria-live="polite"></div> </section> <section class="label" id="label" hidden aria-labelledby="l-title"> <div class="card lbl-head"> <div class="lbl-top"> <div> <p class="eyebrow" id="l-eyebrow">DailyMed label</p> <h1 id="l-title"></h1> <p class="lbl-sub" id="l-sub"></p> </div> <div class="lbl-actions"> <a class="btn btn-s" id="l-dm" href="#" target="_blank" rel="noopener noreferrer">Open on DailyMed</a> <button class="btn btn-s" id="l-csv" type="button" disabled>Change log (CSV)</button> <button class="btn btn-s" id="l-new" type="button">New search</button> </div> </div> <div class="kv" id="l-kv"></div> <div class="fiction" id="l-fiction" hidden>Fictional demo: DEMOLARA is not a real medicine. Its label history is invented to show how the tool works offline.</div> <div class="progress" id="l-prog" hidden><div class="pbar"><i id="l-pbar"></i></div><span id="l-ptext"></span></div> </div> <div class="card strata"> <div class="bar"><h2 class="h2s">The label over time</h2><span class="hint-r" id="s-hint">words per section, version by version</span></div> <canvas id="c-strata" class="chart" height="330" role="img" aria-label="Size of each label section across versions"></canvas> <div class="legend" id="s-legend"></div> <div class="player"> <button class="btn btn-s" id="p-prev" type="button" aria-label="Previous version">←</button> <button class="btn btn-mag btn-s play" id="p-play" type="button">Play</button> <button class="btn btn-s" id="p-next" type="button" aria-label="Next version">→</button> <input type="range" id="p-scrub" min="0" max="0" value="0" aria-label="Version"> <span class="now" id="p-now" aria-live="polite"></span> </div> </div> <div class="cols2"> <div class="card" id="changes" aria-live="polite"> <h2 class="h2s" id="c-title">What changed</h2> <div id="c-body"></div> </div> <div class="card diffcard"> <div class="bar"><h2 class="h2s">Read the changes</h2> <label class="toggle small-t"><input type="checkbox" id="d-all"> Whole section</label></div> <div class="seg gtabs" id="d-groups" role="group" aria-label="Section"></div> <div class="cmp"> <label for="d-a">From</label><select class="input" id="d-a"></select> <label for="d-b">to</label><select class="input" id="d-b"></select> </div> <div class="diff" id="d-body"></div> </div> </div> <div class="card"> <div class="bar"><h2 class="h2s">Milestones</h2> <div class="bar-r"><label class="toggle small-t"><input type="checkbox" id="m-minor"> Minor changes too</label><button class="btn btn-s" type="button" id="m-copy">Copy</button></div></div> <ol class="mlist" id="m-list"></ol> </div> </section></main><footer class="app-foot"> <span>Label data: <a href="https://dailymed.nlm.nih.gov/dailymed/" rel="noopener noreferrer" target="_blank">DailyMed</a>, U.S. National Library of Medicine. Label Time Machine is independent and not endorsed by NLM or FDA.</span> <span>Labels as published on DailyMed, which follows company submissions: dates are publication dates, not approval dates. Part of <a href="https://mafflab.xyz/" rel="noopener">mafflab</a> · MIT.</span></footer><div class="toast" id="toast" role="status" aria-live="polite"></div></body></html>style.css133 lines
/* Label Time Machine: layout and components on top of mafflab-theme.css */:root{--g-bw:#FF2A85;--g-ind:#00F0FF;--g-dos:#3B82F6;--g-ci:#F4FF5A;--g-wp:#B57BFF;--g-ar:#FB923C;--g-di:#34D399;--g-pop:#A3E635;--g-cs:#38BDF8;--g-pk:#64748B;--g-oth:#334155}body{overflow-x:hidden}.skip{position:absolute;left:-999px;top:8px;z-index:50;background:var(--bg-2);color:var(--white);padding:8px 12px;border:1px solid var(--cyan);border-radius:6px}.skip:focus{left:8px}.app-head{flex-wrap:wrap;gap:10px 14px;padding-top:12px;padding-bottom:12px}.app-name.home{text-decoration:none}.lt-logo{width:30px;height:30px;color:var(--cyan);filter:drop-shadow(0 0 6px rgba(0,240,255,.6))}.lt-logo svg{width:30px;height:30px}.lt-main{max-width:1280px;margin:0 auto;padding:clamp(18px,3vw,34px) var(--pad) 50px;display:grid;gap:20px}.card:hover{box-shadow:none}.h2s{margin:0}.bar{margin-bottom:12px}.bar-r{display:flex;align-items:center;gap:12px}.hint-r{font-size:.76rem;color:var(--slate)}/* search */.find{display:grid;gap:18px;padding:clamp(10px,4vw,40px) 0 10px}.find-copy{max-width:760px}.find h1{font-size:clamp(1.8rem,5vw,3.2rem);margin-bottom:12px;text-shadow:-2px 0 rgba(255,42,133,.5),2px 0 rgba(0,240,255,.4)}.find-copy p{font-size:1.05rem}.label[hidden]+.x{display:none}.search{display:grid;grid-template-columns:1fr auto;gap:10px;max-width:760px}.search .input{font-size:1rem;padding:14px 16px}.chips{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:.8rem;color:var(--slate)}.chips button{font-family:var(--f-mono);font-size:.76rem;padding:7px 11px;border-radius:999px;border:1px solid var(--line-c);background:rgba(13,13,18,.6);color:var(--silver);cursor:pointer}.chips button:hover{color:var(--white);border-color:var(--cyan)}.chips button.demo{border-color:var(--line-m);color:var(--mag)}.results{display:grid;gap:8px;max-width:900px}.results .filter{max-width:360px;padding:9px 12px;font-size:.88rem}.rcount{font-size:.8rem;color:var(--slate)}.rlist{display:grid;gap:6px;max-height:min(62vh,640px);overflow:auto;padding-right:4px}.ritem{display:grid;grid-template-columns:1fr auto;gap:4px 14px;align-items:center;text-align:left;width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:var(--r);background:rgba(18,18,26,.7);color:var(--silver);font:inherit;cursor:pointer}.ritem:hover,.ritem:focus-visible{border-color:var(--cyan);background:rgba(0,240,255,.06)}.ritem b{color:var(--white);font-family:var(--f-display);font-weight:600;font-size:.95rem}.ritem small{grid-column:1;font-size:.76rem;color:var(--slate)}.ritem time{grid-row:1 / span 2;grid-column:2;font-family:var(--f-mono);font-size:.72rem;color:var(--cyan)}.msg{padding:14px 16px;border:1px solid var(--line-m);border-radius:var(--r);background:rgba(255,42,133,.07);color:var(--white);font-size:.9rem;max-width:760px}.msg button{margin-top:10px}/* label header */.lbl-head{display:grid;gap:14px}.lbl-top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:14px}.eyebrow{font-family:var(--f-mono);font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;color:var(--cyan)}#l-title{font-size:clamp(1.3rem,3.4vw,2.1rem);margin:4px 0 6px;overflow-wrap:anywhere}.lbl-sub{font-size:.86rem;color:var(--silver)}.lbl-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start}.kv{display:flex;flex-wrap:wrap;gap:8px}.kv span{font-family:var(--f-mono);font-size:.74rem;padding:5px 9px;border:1px solid var(--line);border-radius:4px;background:rgba(13,13,18,.5)}.kv b{color:var(--white);font-weight:600}.fiction{padding:10px 12px;border:1px dashed var(--warn);border-radius:var(--r);color:var(--warn);font-size:.85rem}.progress{display:grid;gap:6px;font-family:var(--f-mono);font-size:.74rem;color:var(--cyan)}.pbar{height:4px;border-radius:2px;background:rgba(0,240,255,.12);overflow:hidden}.pbar i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--purple),var(--mag),var(--cyan));box-shadow:0 0 10px var(--mag);transition:width .25s}/* chart + player */.chart{display:block;width:100%;touch-action:pan-y;cursor:pointer}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:10px;font-size:.74rem;color:var(--silver)}.legend span{display:inline-flex;align-items:center;gap:6px}.legend i{width:10px;height:10px;border-radius:2px;display:inline-block}.player{display:grid;grid-template-columns:auto auto auto 1fr auto;gap:10px;align-items:center;margin-top:16px}.player .play{min-width:78px}.now{font-family:var(--f-mono);font-size:.8rem;color:var(--white);white-space:nowrap}input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:30px;background:none;margin:0;cursor:pointer}input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:linear-gradient(90deg,var(--purple),var(--mag))}input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:linear-gradient(90deg,var(--purple),var(--mag))}input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;margin-top:-8px;border-radius:50%;background:var(--white);border:2px solid var(--mag);box-shadow:0 0 12px rgba(255,42,133,.75)}input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:var(--white);border:2px solid var(--mag);box-shadow:0 0 12px rgba(255,42,133,.75)}/* changes + diff */.cols2{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:20px;align-items:start}.vline{font-family:var(--f-mono);font-size:.78rem;color:var(--slate);margin:6px 0 14px}.vline b{color:var(--white)}.ms{display:grid;gap:8px;margin:0 0 16px;padding:0;list-style:none}.ms li{display:grid;grid-template-columns:auto 1fr;gap:10px;align-items:start;font-size:.88rem;color:var(--white)}.kind{font-family:var(--f-mono);font-size:.64rem;letter-spacing:.06em;text-transform:uppercase;padding:3px 7px;border-radius:3px;border:1px solid currentColor;white-space:nowrap;margin-top:1px}.k-bw{color:var(--g-bw)}.k-ind{color:var(--g-ind)}.k-ci{color:var(--g-ci)}.k-wp{color:var(--g-wp)}.k-dos{color:#7FB2FF}.k-pop{color:var(--g-pop)}.k-fmt{color:var(--silver)}.rmc{margin:0 0 16px;padding:12px 14px;border-left:2px solid var(--cyan);background:rgba(0,240,255,.05);font-size:.82rem}.rmc h3{font-family:var(--f-mono);font-size:.66rem;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--cyan);margin-bottom:6px}.rmc p{margin:2px 0}.gbars{display:grid;gap:6px}.gbar{display:grid;grid-template-columns:minmax(0,1fr) 64px 64px;gap:10px;align-items:center;width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:rgba(13,13,18,.5);color:var(--silver);font:inherit;font-size:.84rem;text-align:left;cursor:pointer}.gbar:hover,.gbar[aria-pressed="true"]{border-color:var(--cyan)}.gbar .nm{display:flex;align-items:center;gap:8px;min-width:0}.gbar .nm i{width:8px;height:8px;border-radius:2px;flex:none}.gbar .nm span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gbar .p{font-family:var(--f-mono);font-size:.74rem;color:var(--ok);text-align:right}.gbar .m{font-family:var(--f-mono);font-size:.74rem;color:var(--mag);text-align:right}.quiet{font-size:.86rem;color:var(--slate)}.gtabs{display:flex;flex-wrap:wrap;width:100%;margin-bottom:12px}.gtabs button{flex:0 1 auto;white-space:nowrap;padding:6px 9px}.gtabs button.has::after{content:"";display:inline-block;width:6px;height:6px;margin-left:6px;border-radius:50%;background:var(--mag);vertical-align:middle;box-shadow:0 0 6px var(--mag)}.cmp{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:14px;font-size:.8rem}.cmp select{width:auto;min-width:0;padding:7px 10px;font-family:var(--f-mono);font-size:.76rem}.diff{max-height:min(70vh,760px);overflow:auto;padding:4px 6px 4px 0;font-size:.9rem;line-height:1.65;color:var(--silver)}.diff p{margin:0 0 10px}.diff h4{font-family:var(--f-display);font-size:.95rem;color:var(--white);margin:16px 0 6px}.diff .add{color:var(--white);background:rgba(59,255,158,.08);border-left:2px solid var(--ok);padding-left:8px}.diff .del{color:#ffb3d2;background:rgba(255,42,133,.07);border-left:2px solid var(--mag);padding-left:8px;text-decoration:line-through;text-decoration-color:rgba(255,42,133,.6)}.diff ins{text-decoration:none;color:var(--white);background:rgba(59,255,158,.18);border-radius:2px;padding:0 1px}.diff del{color:#ffb3d2;background:rgba(255,42,133,.16);text-decoration:line-through;border-radius:2px;padding:0 1px}.diff .gap{font-family:var(--f-mono);font-size:.72rem;color:var(--slate);margin:6px 0 12px;padding:4px 0;border-top:1px dashed var(--line);border-bottom:1px dashed var(--line)}.diff .gap button{background:none;border:0;color:var(--cyan);font:inherit;cursor:pointer;text-decoration:underline;padding:0}.toggle{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:.8rem;color:var(--silver);font-family:var(--f-body);font-weight:400}.toggle input{width:16px;height:16px;accent-color:var(--mag)}/* milestones */.mlist{list-style:none;margin:0;padding:0;display:grid;gap:2px}.mlist li button{display:grid;grid-template-columns:96px auto 1fr;gap:12px;align-items:start;width:100%;padding:9px 8px;border:0;border-bottom:1px solid var(--line);background:none;color:var(--white);font:inherit;font-size:.88rem;text-align:left;cursor:pointer}.mlist li button:hover{background:rgba(0,240,255,.05)}.mlist time{font-family:var(--f-mono);font-size:.74rem;color:var(--slate);padding-top:2px}.mlist li.cur button{background:rgba(255,42,133,.08)}.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);opacity:0;pointer-events:none;z-index:60;padding:10px 16px;border:1px solid var(--line-c);border-radius:6px;background:rgba(13,13,18,.95);color:var(--white);font-family:var(--f-mono);font-size:.8rem;transition:opacity .2s,transform .2s}.toast.on{opacity:1;transform:translate(-50%,0)}.app-foot{display:flex;flex-wrap:wrap;gap:6px 24px;justify-content:space-between;max-width:1280px}.btn:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}@media (max-width:960px){.cols2{grid-template-columns:1fr}}@media (max-width:640px){ .app-tag{display:none} .search{grid-template-columns:1fr} .player{grid-template-columns:auto auto auto 1fr} .player .now{grid-column:1 / -1} .mlist li button{grid-template-columns:78px 1fr} .mlist li button .kind{grid-column:2;justify-self:start} .mlist li button span:last-child{grid-column:1 / -1} .gbar{grid-template-columns:minmax(0,1fr) 52px 52px}}@media (pointer:coarse){.card,.app-head{backdrop-filter:none;-webkit-backdrop-filter:none}.search .input{font-size:16px}}.g-bw{background:var(--g-bw)}.g-ind{background:var(--g-ind)}.g-dos{background:var(--g-dos)}.g-ci{background:var(--g-ci)}.g-wp{background:var(--g-wp)}.g-ar{background:var(--g-ar)}.g-di{background:var(--g-di)}.g-pop{background:var(--g-pop)}.g-cs{background:var(--g-cs)}.g-pk{background:var(--g-pk)}.g-pt{background:#E2E8F0;opacity:.6}.g-oth{background:var(--g-oth)}.legend .kc{color:var(--mag)}sw.js22 lines
/* Label Time Machine service worker: the app shell and the fictional demo work offline. Label data from labels.php always goes to the network and is never stored here, so nothing about the labels you look at is kept on the device. Change VERSION on every release. */const VERSION = 'label-time-machine-1.0.0';const SHELL = ['./', './index.html', './app.js', './engine.js', './style.css', './mafflab-theme.css', './demo.json', './manifest.webmanifest', './fonts/space-grotesk.woff2', './fonts/inter.woff2', './icons/icon.svg', './icons/icon-192.png', './icons/icon-512.png', './icons/maskable-512.png', './icons/apple-touch-icon.png'];const PREFIX = 'label-time-machine-';self.addEventListener('install', e => e.waitUntil(caches.open(VERSION).then(c => c.addAll(SHELL)).then(() => self.skipWaiting())));self.addEventListener('activate', e => e.waitUntil( caches.keys().then(ks => Promise.all(ks.filter(k => k.startsWith(PREFIX) && k !== VERSION).map(k => caches.delete(k)))).then(() => self.clients.claim())));self.addEventListener('fetch', e => { const url = new URL(e.request.url); if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.endsWith('.php')) return; if (e.request.mode === 'navigate') { e.respondWith(fetch(e.request).then(r => { const c = r.clone(); caches.open(VERSION).then(x => x.put('./index.html', c)); return r; }).catch(() => caches.match('./index.html'))); return; } e.respondWith(caches.match(e.request, { ignoreSearch: true }).then(hit => hit || fetch(e.request)));});manifest.webmanifest21 lines
{ "id": "./", "name": "Label Time Machine: every version of a US drug label", "short_name": "Label TM", "description": "Scrub through every published version of a US prescription drug label on DailyMed, with changes highlighted word by word.", "start_url": "./", "scope": "./", "display": "standalone", "orientation": "any", "background_color": "#0D0D12", "theme_color": "#0D0D12", "lang": "en", "categories": ["medical", "productivity", "reference"], "icons": [ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } ]}tests/engine.test.js58 lines
/* Engine tests: diff correctness on random edits, plus summaries on real parsed labels if given. node tests/engine.test.js [folder with v-*.json files from labels.php] */'use strict';const L = require('../engine.js'), fs = require('fs'), path = require('path');let fails = 0, n = 0;const ok = (c, label, d) => { n++; if (!c) fails++; console.log(`${c ? 'PASS' : 'FAIL'} ${label}${d ? ' ' + d : ''}`); };// 1. Myers diff reconstructs both sides, on 300 random caseslet seed = 7; const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;const words = 'the patient may experience serious infections dose daily tablets with food liver renal heart'.split(' ');let good = 0;for (let t = 0; t < 300; t++) { const a = Array.from({ length: Math.floor(rnd() * 40) }, () => words[Math.floor(rnd() * words.length)]); const b = a.slice(); for (let e = Math.floor(rnd() * 8); e > 0; e--) { const i = Math.floor(rnd() * (b.length + 1)); if (rnd() < 0.5 && b.length) b.splice(Math.min(i, b.length - 1), 1); else b.splice(i, 0, words[Math.floor(rnd() * words.length)]); } const ops = L.myers(a, b); const ra = ops.filter(o => o[0] !== '+').map(o => a[o[1]]), rb = ops.filter(o => o[0] !== '-').map(o => o[0] === '=' ? a[o[1]] : b[o[1]]); const eqOk = ops.filter(o => o[0] === '=').every(o => a[o[1]] === b[o[2]]); if (ra.join(' ') === a.join(' ') && rb.join(' ') === b.join(' ') && eqOk) good++;}ok(good === 300, 'Myers diff reconstructs both sides (300 random cases)', `${good}/300`);// minimality on a known caseok(L.myers('ABCABBA'.split(''), 'CBABAC'.split('')).filter(o => o[0] !== '=').length === 5, 'Myers finds the minimal edit script (classic ABCABBA / CBABAC: 5 edits)');// word diffconst wd = L.wordDiff('Patients should take 10 mg once daily with food.', 'Patients should take 25 mg once daily, with or without food.');const back = s => wd.filter(p => p.t !== s).map(p => p.s).join('');ok(back('+') === 'Patients should take 10 mg once daily with food.' && back('-') === 'Patients should take 25 mg once daily, with or without food.', 'word diff rebuilds both sentences');ok(wd.some(p => p.t === '-' && p.s.includes('10')) && wd.some(p => p.t === '+' && p.s.includes('25')), 'word diff isolates the changed dose');// paragraph diff with edits, insertions and deletionsconst A = ['## 5.1 Serious Infections', 'Serious infections leading to hospitalization have occurred.', 'Monitor patients closely.', '## 5.2 Malignancies', 'Lymphoma has been reported.'];const B = ['## 5.1 Serious Infections', 'Serious infections leading to hospitalization or death have occurred.', 'Monitor patients closely.', '## 5.2 Malignancies', 'Lymphoma has been reported.', '## 5.3 Thrombosis', 'Thrombosis has occurred in patients treated with the drug.'];const d = L.diffParas(A, B);ok(d.blocks.some(b => b.t === '~') && d.blocks.filter(b => b.t === '+').length === 2, 'paragraph diff pairs the edited sentence and adds the new section', JSON.stringify(d.blocks.map(b => b.t)));ok(d.added === 2 + 2 + 9 && d.removed === 0, 'added and removed word counts', `+${d.added} −${d.removed}`);ok(L.groupOf({ c: '42229-5', t: '4 CONTRAINDICATIONS' }) === 'ci' && L.groupOf({ c: '42229-5', t: 'WARNINGS AND PRECAUTIONS' }) === 'wp' && L.groupOf({ c: '42229-5', t: 'WARNING: SERIOUS INFECTIONS' }) === 'bw', 'title fallback groups old, uncoded sections');ok(L.splitTitle('5.10 Immunizations').norm === 'immunizations' && L.splitTitle('5.10 Immunizations').num === '5.10', 'subsection numbers split from names');// 2. real labels parsed by labels.phpconst dir = process.argv[2];if (dir && fs.existsSync(dir)) { const raw = fs.readdirSync(dir).filter(f => /^v-.*\.json$/.test(f)).sort().map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); const V = raw.map(r => L.buildVersion(r)); ok(V.length >= 3, `built ${V.length} versions from real SPL`, V.map(v => `v${v.v}: ${v.total} words`).join(', ')); ok(V[0].groups.ind.subs.length >= 6 && V[0].hasBW, 'indication subsections and boxed warning found', V[0].groups.ind.subs.map(s => s.name).join('; ')); const t0 = Date.now(), c1 = L.compareVersions(V[0], V[1]), c2 = L.compareVersions(V[1], V[2]), c3 = L.compareVersions(V[2], V[3]); ok(c1.milestones.some(m => m.text === 'New indication: Ulcerative Colitis'), 'detects a new indication', c1.milestones.map(m => m.text).join(' | ')); ok(c1.milestones.some(m => m.kind === 'bw'), 'detects the boxed warning title change'); ok(c2.milestones.some(m => m.text === 'New warning: Use with Abatacept'), 'detects a new warning', c2.milestones.map(m => m.text).join(' | ')); ok(c3.groups.ind.changed && c3.groups.ind.added === 1 && c3.groups.ind.removed === 0, 'one-word edit in indications', `+${c3.groups.ind.added} −${c3.groups.ind.removed}`); ok(!c3.groups.wp.changed && !c3.groups.ar.changed, 'unchanged sections stay unchanged'); console.log(` three comparisons in ${Date.now() - t0} ms`);}console.log(`\n${n - fails}/${n} passed`);process.exit(fails ? 1 : 0);tests/test_relay.py102 lines
"""End-to-end test of labels.php against tests/mock_dailymed.py. Needs php (7.4+) and python3. python3 tests/test_relay.py FIXTURES_DIRFIXTURES_DIR holds humira-2013.xml and lipitor-2014.xml, copied from github.com/FDA/openfda(openfda/spl/tests/data/humira.xml and lipitor-orig.xml)."""import json, os, shutil, subprocess, sys, tempfile, time, urllib.request, urllib.errorfrom pathlib import PathAPP = Path(__file__).resolve().parent.parentFIX = Path(sys.argv[1]).resolve()HUMIRA = '608d4f0d-b19f-46d3-749a-7159aa5f933d'fails = 0def check(ok, label, detail=''): global fails fails += 0 if ok else 1 print(f"{'PASS' if ok else 'FAIL'} {label}{(' ' + detail) if detail else ''}")def get(port, qs, headers=None): req = urllib.request.Request(f'http://127.0.0.1:{port}/labels.php?{qs}', headers=headers or {}) try: with urllib.request.urlopen(req, timeout=60) as r: return r.status, json.loads(r.read().decode()), dict(r.headers) except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode() or '{}'), dict(e.headers)def wait(port): for _ in range(50): try: urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=1) return except urllib.error.HTTPError: return except Exception: time.sleep(0.1)mock = subprocess.Popen([sys.executable, str(APP / 'tests' / 'mock_dailymed.py'), str(FIX), '8901'])procs = [mock]try: wait(8901) for mode, port in (('ZipArchive', 8902), ('built-in ZIP reader', 8903)): cache = tempfile.mkdtemp(prefix='ltm-cache-') env = dict(os.environ, LTM_UPSTREAM='http://127.0.0.1:8901/dailymed/', LTM_CACHE=cache) if port == 8903: env['LTM_NO_ZIPARCHIVE'] = '1' php = subprocess.Popen(['php', '-S', f'127.0.0.1:{port}', '-t', str(APP)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) procs.append(php) wait(port) print(f'\n--- {mode}') s, j, _ = get(port, 'check=1') check(s == 200 and j.get('ok') is True, 'self-check reports ok', json.dumps(j)) s, j, _ = get(port, 'q=humira') check(s == 200 and j['results'] and j['results'][0]['setid'] == HUMIRA and j['results'][0]['published'] == '2014-05-02', 'search returns the label with an ISO date') s, j, _ = get(port, 'q=%3Cscript%3E') check(s == 400, 'search rejects markup', str(s)) s, j, _ = get(port, f'history={HUMIRA}') check(s == 200 and [v['v'] for v in j['versions']] == [101, 102, 103, 104], 'history follows pages and sorts versions', str([v['v'] for v in j.get('versions', [])])) check(j['versions'][0]['date'] == '2011-01-15', 'history dates converted to ISO') s, j, _ = get(port, 'history=not-a-uuid') check(s == 400, 'history rejects a bad set ID') t0 = time.time() s, v1, h = get(port, f'setid={HUMIRA}&v=101') first = time.time() - t0 check(s == 200 and v1['v'] == 101 and v1['effective'] == '2013-09-30', 'version parsed (number, effective date)', f"{v1.get('v')} {v1.get('effective')}") check('immutable' in h.get('Cache-Control', ''), 'parsed versions are marked immutable for browsers') titles = [x['t'] for x in v1['sections']] check('1.6 Ulcerative Colitis' not in titles and '1.7 Plaque Psoriasis' in titles, 'sections and subsections read with titles') check(not any(x['c'] in ('48780-1', '51945-4') for x in v1['sections']), 'product data and package label panels skipped') ind = next(x for x in v1['sections'] if x['c'] == '34067-9') kids = [x for x in v1['sections'] if x['p'] == v1['sections'].index(ind)] check(ind['d'] == 0 and len(kids) >= 6, 'subsections point to their parent', f'{len(kids)} children') ra = next(x for x in v1['sections'] if x['t'].startswith('1.1 ')) check('signs and symptoms' in ra['x'] and '<' not in ra['x'], 'section text is plain text', ra['x'][:80]) ar = next(x for x in v1['sections'] if x['t'].startswith('6.1')) check(' | ' in ar['x'], 'tables become rows of cells') check(v1['labeler'].startswith('AbbVie'), 'labeler read from the document author', v1['labeler']) s, v3, _ = get(port, f'setid={HUMIRA}&v=103') check(s == 200 and any(x['t'] == '1.6 Ulcerative Colitis' for x in v3['sections']), 'stored (uncompressed) ZIP entries read too') t0 = time.time() s, again, _ = get(port, f'setid={HUMIRA}&v=101') second = time.time() - t0 check(s == 200 and again == v1, 'second request served from cache, identical', f'{first*1000:.0f} ms then {second*1000:.0f} ms') check(any(f.name.startswith('v-') for f in Path(cache).iterdir()), 'cache file written') s, j, _ = get(port, f'setid={HUMIRA}&v=999') check(s == 404 and 'error' in j, 'missing version is a clean 404', str(j)) s, j, _ = get(port, 'q=humira', {'Sec-Fetch-Site': 'cross-site'}) check(s == 403, 'cross-site browser requests refused') size = len(json.dumps(v3)) print(f' one parsed version: {size/1024:.0f} KB JSON, {len(v3["sections"])} sections') shutil.rmtree(cache, ignore_errors=True)finally: for p in procs: p.terminate()print(f'\n{"all checks passed" if not fails else str(fails) + " FAILED"}')sys.exit(1 if fails else 0)tests/mock_dailymed.py113 lines
"""A stand-in for the DailyMed web services, for testing labels.php without the internet.It serves the three resources the relay uses, in DailyMed's documented formats: /dailymed/services/v2/spls.json?drug_name=... search /dailymed/services/v2/spls/{setid}/history.json version history (paged, 2 per page here) /dailymed/getFile.cfm?type=zip&setid=...&version=N one version as a ZIP (SPL XML + an image)Versions are made from real SPL XML files (for example the openFDA test fixtures ingithub.com/FDA/openfda, openfda/spl/tests/data) with sections removed, so the history has changes. python3 tests/mock_dailymed.py FIXTURES_DIR PORT"""import io, json, re, sys, zipfilefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom pathlib import Pathfrom urllib.parse import urlparse, parse_qsFIX = Path(sys.argv[1])PORT = int(sys.argv[2])HUMIRA = '608d4f0d-b19f-46d3-749a-7159aa5f933d'LIPITOR = 'c6e131fe-e7df-4876-83f7-9156fc4e8228'def drop_section(xml, title_start): """Remove the <component><section> whose title starts with title_start.""" i = xml.find('<title>' + title_start) if i < 0: raise SystemExit(f'fixture has no section titled {title_start!r}') a = xml.rfind('<component>', 0, i) depth, j = 0, a for m in re.finditer(r'<(/?)component\b[^>]*?(/?)>', xml[a:]): if m.group(2): continue depth += -1 if m.group(1) else 1 if depth == 0: j = a + m.end() break return xml[:a] + xml[j:]def versions(): h = (FIX / 'humira-2013.xml').read_text(encoding='utf-8') v1 = drop_section(drop_section(h, '1.6 Ulcerative Colitis'), '5.11 Use with Abatacept') v1 = v1.replace('WARNING: SERIOUS INFECTIONS AND MALIGNANCY', 'WARNING: SERIOUS INFECTIONS') v2 = drop_section(h, '5.11 Use with Abatacept') v3 = h.replace('reducing signs and symptoms', 'reducing the signs and symptoms', 1) set_v = lambda x, n: re.sub(r'<versionNumber value="\d+"/>', f'<versionNumber value="{n}"/>', x, count=1) out = {(HUMIRA, 101): set_v(v1, 101), (HUMIRA, 102): set_v(v2, 102), (HUMIRA, 103): set_v(h, 103), (HUMIRA, 104): set_v(v3, 104)} out[(LIPITOR, 11)] = (FIX / 'lipitor-2014.xml').read_text(encoding='utf-8') return outVERS = versions()DATES = {101: 'Jan 15, 2011', 102: 'Feb 03, 2012', 103: 'Sep 30, 2013', 104: 'May 02, 2014'}def make_zip(xml, stored): b = io.BytesIO() with zipfile.ZipFile(b, 'w', zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED) as z: z.writestr('a1b2c3-image-01.jpg', b'\xff\xd8\xff' + b'\0' * 5000) z.writestr('label.xml', xml) return b.getvalue()class H(BaseHTTPRequestHandler): def log_message(self, *a): pass def send(self, code, body, ctype='application/json'): self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): u = urlparse(self.path) q = {k: v[0] for k, v in parse_qs(u.query).items()} ua = self.headers.get('User-Agent', '') if 'mafflab' not in ua or self.headers.get('Cookie') or self.headers.get('X-Forwarded-For'): return self.send(400, b'{"error":"unexpected headers"}') if u.path == '/dailymed/services/v2/spls.json': name = q.get('drug_name', '').lower() data = [] if 'humira' in name or 'adalimumab' in name: data.append({'spl_version': '104', 'published_date': 'May 02, 2014', 'setid': HUMIRA, 'title': 'HUMIRA (ADALIMUMAB) KIT [ABBVIE INC.]'}) if 'lipitor' in name or 'atorvastatin' in name or name == 'aspirin': data.append({'spl_version': '11', 'published_date': 'Jan 13, 2014', 'setid': LIPITOR, 'title': 'LIPITOR (ATORVASTATIN CALCIUM) TABLET, FILM COATED [PARKE-DAVIS DIV OF PFIZER INC]'}) body = {'metadata': {'total_elements': str(len(data)), 'next_page': 'null'}, 'data': data} return self.send(200, json.dumps(body).encode()) m = re.match(r'^/dailymed/services/v2/spls/([0-9a-f-]{36})/history\.json$', u.path) if m: sid = m.group(1) vs = sorted([v for (s, v) in VERS if s == sid], reverse=True) if not vs: return self.send(404, b'{}') page, size = int(q.get('page', 1)), 2 chunk = vs[(page - 1) * size: page * size] more = page * size < len(vs) body = {'metadata': {'next_page': str(page + 1) if more else 'null', 'total_pages': str((len(vs) + size - 1) // size)}, 'data': {'history': [{'spl_version': str(v), 'published_date': DATES.get(v, 'Jan 13, 2014')} for v in chunk], 'spl': {'setid': sid, 'title': 'TEST TITLE'}}} return self.send(200, json.dumps(body).encode()) if u.path == '/dailymed/getFile.cfm' and q.get('type') == 'zip': key = (q.get('setid'), int(q.get('version', 0))) if key not in VERS: return self.send(404, b'<html>not found</html>', 'text/html') return self.send(200, make_zip(VERS[key], stored=key[1] % 2 == 0), 'application/zip') self.send(404, b'{}')ThreadingHTTPServer(('127.0.0.1', PORT), H).serve_forever()tests/make_demo.py156 lines
"""Builds demo.json: the label history of DEMOLARA, a FICTIONAL medicine, in the relay's format.It lets the app run offline and in previews without DailyMed. Nothing here describes a real product. python3 tests/make_demo.py"""import copy, jsonfrom pathlib import PathSETID = '00000000-0000-4000-8000-00000000d3m0'.replace('d3m0', 'de30')def para(*lines): return '\n'.join(lines)# Each section: (code, title, text, [children]). Children are (title, text).base = { 'ind': ['34067-9', '1 INDICATIONS AND USAGE', 'DEMOLARA is a fictional kinase inhibitor indicated for:', [ ['1.1 Plaque Psoriasis', 'Treatment of adults with moderate to severe plaque psoriasis who are candidates for systemic therapy or phototherapy.'], ]], 'dos': ['34068-7', '2 DOSAGE AND ADMINISTRATION', '', [ ['2.1 Plaque Psoriasis', para('The recommended dosage is 10 mg orally once daily, with or without food.', 'Swallow tablets whole. Do not crush, split or chew.')], ['2.2 Laboratory Testing Before Treatment', 'Obtain a complete blood count and liver tests before starting DEMOLARA.'], ]], 'forms': ['43678-2', '3 DOSAGE FORMS AND STRENGTHS', 'Tablets: 10 mg, round, white, film-coated, debossed with "D10".', []], 'ci': ['34070-3', '4 CONTRAINDICATIONS', 'DEMOLARA is contraindicated in patients with known hypersensitivity to demonstrafin or to any of the excipients.', []], 'wp': ['43685-7', '5 WARNINGS AND PRECAUTIONS', '', [ ['5.1 Serious Infections', para('Serious infections, including pneumonia and cellulitis, have occurred in patients receiving DEMOLARA.', 'Avoid use in patients with an active, serious infection. Interrupt treatment if a serious infection develops.')], ['5.2 Hypersensitivity', 'Reactions such as angioedema and urticaria have been observed. Discontinue DEMOLARA if a serious reaction occurs.'], ['5.3 Laboratory Abnormalities', para('Lymphopenia and neutropenia: avoid starting treatment if the absolute lymphocyte count is below 500 cells/mm3.', 'Liver enzyme elevations: monitor liver tests periodically during treatment.')], ]], 'ar': ['34084-4', '6 ADVERSE REACTIONS', 'The following adverse reactions are discussed in greater detail in other sections: serious infections, hypersensitivity and laboratory abnormalities.', [ ['6.1 Clinical Trials Experience', para('In two placebo-controlled trials, 1,204 patients received DEMOLARA 10 mg once daily for up to 16 weeks.', 'Table 1: Adverse reactions in 1% or more of patients', 'Adverse reaction | DEMOLARA (N=1,204) | Placebo (N=598)', 'Upper respiratory tract infection | 9% | 6%', 'Headache | 6% | 4%', 'Diarrhea | 4% | 2%', 'Nausea | 3% | 1%')], ]], 'di': ['34073-7', '7 DRUG INTERACTIONS', '', [ ['7.1 Strong CYP3A Inhibitors', 'Coadministration increases demonstrafin exposure. Reduce the dosage to 5 mg once daily.'], ]], 'pop': ['43684-0', '8 USE IN SPECIFIC POPULATIONS', '', [ ['8.1 Pregnancy', 'Pregnancy Category C. There are no adequate studies in pregnant women. Use only if the potential benefit justifies the potential risk to the fetus.'], ['8.3 Nursing Mothers', 'It is not known whether demonstrafin is excreted in human milk.'], ['8.4 Pediatric Use', 'Safety and effectiveness in pediatric patients have not been established.'], ]], 'pk': ['34090-1', '12 CLINICAL PHARMACOLOGY', '', [ ['12.1 Mechanism of Action', 'Demonstrafin is a fictional inhibitor of an intracellular kinase involved in inflammatory signalling. Its mechanism is described for demonstration only.'], ['12.3 Pharmacokinetics', 'Peak plasma concentrations occur 2 hours after dosing. The mean elimination half-life is 11 hours.'], ]], 'cs': ['34092-7', '14 CLINICAL STUDIES', '', [ ['14.1 Plaque Psoriasis', para('Two randomized, double-blind, placebo-controlled trials (Trials PSO-1 and PSO-2) enrolled 1,802 adults with moderate to severe plaque psoriasis.', 'At week 16, 58% of patients on DEMOLARA and 9% on placebo achieved at least a 75% improvement in the Psoriasis Area and Severity Index.')], ]],}ORDER = ['bw', 'ind', 'dos', 'forms', 'ci', 'wp', 'ar', 'di', 'pop', 'pk', 'cs']versions = []def snapshot(v, date, rmc): secs = [{'c': '43683-2', 't': 'RECENT MAJOR CHANGES', 'd': 0, 'p': -1, 'x': '\n'.join(rmc)}] if rmc else [] for k in ORDER: if k not in L: continue code, title, text, kids = L[k] idx = len(secs) secs.append({'c': code, 't': title, 'd': 0, 'p': -1, 'x': text}) for t, x in kids: secs.append({'c': '42229-5', 't': t, 'd': 1, 'p': idx, 'x': x}) versions.append({'setid': SETID, 'v': v, 'effective': date, 'doctype': 'HUMAN PRESCRIPTION DRUG LABEL', 'title': 'DEMOLARA (demonstrafin) tablets, for oral use\nFICTIONAL LABEL FOR DEMONSTRATION. NOT A REAL MEDICINE.', 'labeler': 'Example Therapeutics (fictional)', 'sections': copy.deepcopy(secs)})def kids(k): return L[k][3]def add_kid(k, title, text, after=None): ks = kids(k) i = len(ks) if after is None else next(n for n, x in enumerate(ks) if x[0].startswith(after)) + 1 ks.insert(i, [title, text])def set_kid(k, start, text): next(x for x in kids(k) if x[0].startswith(start))[1] = textdef del_kid(k, start): L[k][3] = [x for x in kids(k) if not x[0].startswith(start)]L = copy.deepcopy(base)snapshot(1, '2012-03-14', [])kids('ar').append(['6.2 Postmarketing Experience', 'The following reactions have been identified during post-approval use: rash, mouth ulcers and fatigue.'])snapshot(2, '2012-11-02', [])L['ind'][2] = 'DEMOLARA is a fictional kinase inhibitor indicated for the treatment of:'add_kid('ind', '1.2 Psoriatic Arthritis', 'Adults with active psoriatic arthritis, alone or in combination with non-biologic disease-modifying antirheumatic drugs.')add_kid('dos', '2.2 Psoriatic Arthritis', 'The recommended dosage is 10 mg orally once daily.', after='2.1')next(x for x in kids('dos') if x[0].startswith('2.2 Laboratory'))[0] = '2.3 Laboratory Testing Before Treatment'add_kid('cs', '14.2 Psoriatic Arthritis', 'In Trial PSA-1 (N=612), 47% of patients on DEMOLARA and 22% on placebo achieved an ACR20 response at week 24.')snapshot(3, '2013-08-21', ['Indications and Usage, Psoriatic Arthritis (1.2) 08/2013', 'Dosage and Administration (2.2) 08/2013'])add_kid('wp', '5.4 Malignancies', 'Malignancies, including non-melanoma skin cancer and lymphoma, were observed in clinical trials. Consider the risks and benefits before starting treatment in patients with a known malignancy.', after='5.3')snapshot(4, '2014-06-10', ['Warnings and Precautions, Malignancies (5.4) 06/2014'])L['bw'] = ['34066-1', 'WARNING: SERIOUS INFECTIONS AND MALIGNANCY', para( 'Patients treated with DEMOLARA are at increased risk for serious infections that may lead to hospitalization or death.', 'Lymphoma and other malignancies have been observed in patients treated with DEMOLARA.'), []]set_kid('wp', '5.1', para('Serious infections, including pneumonia, cellulitis and opportunistic infections, have occurred in patients receiving DEMOLARA. Some were fatal.', 'Avoid use in patients with an active, serious infection. Interrupt treatment if a serious infection develops.', 'Test patients for latent tuberculosis before starting treatment.'))snapshot(5, '2015-10-05', ['Boxed Warning 10/2015', 'Warnings and Precautions, Serious Infections (5.1) 10/2015'])set_kid('di', '7.1', 'Coadministration with strong CYP3A inhibitors increases demonstrafin exposure. Reduce the dosage to 5 mg once daily.')snapshot(6, '2016-04-18', [])add_kid('ind', '1.3 Ankylosing Spondylitis', 'Adults with active ankylosing spondylitis.')add_kid('cs', '14.3 Ankylosing Spondylitis', 'In Trial AS-1 (N=420), 52% of patients on DEMOLARA and 29% on placebo achieved an ASAS40 response at week 16.')snapshot(7, '2017-02-27', ['Indications and Usage, Ankylosing Spondylitis (1.3) 02/2017'])L['ci'][2] = para('DEMOLARA is contraindicated in patients with:', '• known hypersensitivity to demonstrafin or to any of the excipients', '• an active, serious infection, including localized infections')add_kid('wp', '5.5 Thrombosis', 'Thrombosis, including pulmonary embolism and deep venous thrombosis, has occurred in patients treated with DEMOLARA. Avoid use in patients at increased risk of thrombosis.', after='5.4')L['bw'][2] = para('Patients treated with DEMOLARA are at increased risk for serious infections that may lead to hospitalization or death.', 'Lymphoma and other malignancies have been observed in patients treated with DEMOLARA.', 'Thrombosis, including pulmonary embolism, has occurred in patients treated with DEMOLARA.')L['bw'][1] = 'WARNING: SERIOUS INFECTIONS, MALIGNANCY AND THROMBOSIS'snapshot(8, '2018-09-12', ['Boxed Warning 09/2018', 'Contraindications (4) 09/2018', 'Warnings and Precautions, Thrombosis (5.5) 09/2018'])del_kid('pop', '8.1'); del_kid('pop', '8.3')add_kid('pop', '8.1 Pregnancy', para('Risk Summary: available data from case reports are insufficient to identify a drug-associated risk of major birth defects or miscarriage.', 'In animal reproduction studies, oral administration of demonstrafin during organogenesis caused fetal harm at exposures 30 times the human exposure.'), after=None)L['pop'][3].insert(0, L['pop'][3].pop())add_kid('pop', '8.2 Lactation', 'There are no data on the presence of demonstrafin in human milk. Advise women not to breastfeed during treatment and for 3 days after the last dose.', after='8.1')snapshot(9, '2019-05-30', ['Use in Specific Populations, Pregnancy and Lactation (8.1, 8.2) 05/2019'])set_kid('ind', '1.1', 'Treatment of adults and pediatric patients 6 years and older with moderate to severe plaque psoriasis who are candidates for systemic therapy or phototherapy.')set_kid('pop', '8.4', 'Safety and effectiveness have been established in pediatric patients 6 years and older with plaque psoriasis. Use in patients younger than 6 years has not been studied.')set_kid('dos', '2.1', para('Adults: 10 mg orally once daily, with or without food.', 'Pediatric patients 6 years and older weighing 30 kg or more: 10 mg once daily. Weighing less than 30 kg: 5 mg once daily.', 'Swallow tablets whole. Do not crush, split or chew.'))L['forms'][2] = 'Tablets: 5 mg, round, yellow, film-coated, debossed with "D5"; 10 mg, round, white, film-coated, debossed with "D10".'snapshot(10, '2020-12-08', ['Indications and Usage, Plaque Psoriasis (1.1) 12/2020', 'Dosage and Administration (2.1) 12/2020'])add_kid('wp', '5.6 Major Adverse Cardiovascular Events', 'In a randomized safety study in patients 50 years and older with at least one cardiovascular risk factor, a higher rate of major adverse cardiovascular events was observed with DEMOLARA than with the comparator. Consider the benefits and risks before starting or continuing treatment.', after='5.5')L['bw'][1] = 'WARNING: SERIOUS INFECTIONS, MORTALITY, MALIGNANCY, MAJOR ADVERSE CARDIOVASCULAR EVENTS AND THROMBOSIS'L['bw'][2] = L['bw'][2] + '\nHigher rates of all-cause mortality and major adverse cardiovascular events were observed in a safety study in patients 50 years and older with cardiovascular risk factors.'snapshot(11, '2022-03-16', ['Boxed Warning 03/2022', 'Warnings and Precautions, Major Adverse Cardiovascular Events (5.6) 03/2022'])set_kid('ar', '6.1', kids('ar')[0][1].replace('Headache | 6% | 4%', 'Headache | 6% | 4%\nHypertension | 2% | 1%').replace('1,204 patients received', '1,204 adults received'))snapshot(12, '2023-07-24', [])del_kid('ind', '1.3'); del_kid('cs', '14.3')snapshot(13, '2024-10-01', ['Indications and Usage, Ankylosing Spondylitis (1.3), removed 10/2024'])set_kid('dos', '2.2', 'The recommended dosage is 10 mg orally once daily. A 15 mg once-daily dosage may be considered in patients with an inadequate response after 16 weeks.')L['forms'][2] = 'Tablets: 5 mg, round, yellow; 10 mg, round, white; 15 mg, oval, purple. All film-coated and debossed with the strength.'snapshot(14, '2026-02-09', ['Dosage and Administration (2.2) 02/2026', 'Dosage Forms and Strengths (3) 02/2026'])demo = {'setid': SETID, 'title': 'DEMOLARA (demonstrafin) tablets [EXAMPLE THERAPEUTICS (FICTIONAL)]', 'fictional': True, 'history': [{'v': x['v'], 'date': x['effective']} for x in versions], 'versions': versions}out = Path(__file__).resolve().parent.parent / 'demo.json'out.write_text(json.dumps(demo, ensure_ascii=False, separators=(',', ':')))print(f'{len(versions)} versions, {out.stat().st_size // 1024} KB -> {out.name}').htaccess13 lines
# Label Time Machine: security headers for Apache and LiteSpeed (Hostinger, IONOS and most shared hosting).DirectoryIndex index.htmlAddType application/manifest+json .webmanifest<IfModule mod_headers.c> Header always set Referrer-Policy "no-referrer" Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "SAMEORIGIN" Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()" <FilesMatch "^(sw\.js|index\.html|manifest\.webmanifest)$"> Header set Cache-Control "no-cache" </FilesMatch></IfModule>LICENSE22 lines
MIT LicenseCopyright (c) 2026 Aitor Martínez RosellPermission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.