Download app/static/index.html from roz322/annotator: direct link, hf CLI and curl.
- Browser
- Download file 73.5 kB
-
https://huggingface.co/spaces/roz322/annotator/resolve/main/app/static/index.html
- Command line
-
hf download hf://spaces/roz322/annotator/app/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/roz322/annotator/resolve/main/app/static/index.html
73.5 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Reproducibility Annotator</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <style> | |
| :root{ | |
| --bg:#f6f7f9; --panel:#ffffff; --ink:#1d2430; --muted:#6b7686; --line:#e3e7ee; | |
| --accent:#d97706; --accent-soft:#fff3e0; --blue:#2563eb; --green:#15803d; --red:#b91c1c; | |
| --na:#eef0f4; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%;margin:0} | |
| body{font:14px/1.55 -apple-system,"PingFang SC","Helvetica Neue",sans-serif;color:var(--ink);background:var(--bg)} | |
| button{font:inherit;cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:6px;padding:4px 10px} | |
| button:hover{border-color:#b9c2d0} | |
| button.primary{background:var(--accent);border-color:var(--accent);color:#fff} | |
| select,input[type=text],textarea{font:inherit;border:1px solid var(--line);border-radius:6px;padding:4px 8px;background:#fff;color:var(--ink);width:100%} | |
| textarea{resize:vertical;min-height:34px} | |
| .muted{color:var(--muted)} | |
| /* ---------- login ---------- */ | |
| #login{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:50} | |
| #login .box{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:28px 32px;width:420px;box-shadow:0 8px 30px rgba(0,0,0,.06)} | |
| #login h1{font-size:18px;margin:0 0 4px} | |
| #login .sub{font-size:12px;color:var(--muted);margin-bottom:18px} | |
| #login label{display:block;font-size:12px;color:var(--muted);margin:12px 0 4px} | |
| #login input[type=password]{font:inherit;border:1px solid var(--line);border-radius:6px;padding:4px 8px;width:100%} | |
| .login-err{color:var(--red);font-size:12px;min-height:16px;margin-top:8px} | |
| /* ---------- home ---------- */ | |
| :root{--c-l1:#475569;--c-dataset:#2563eb;--c-split:#0891b2;--c-asset:#0d9488;--c-model:#7c3aed;--c-result:#d97706;--c-claim:#e11d48;--c-scope:#475569;--c-code:#0d9488;--c-summary:#e11d48;--c-notes:#64748b} | |
| #home{display:none;min-height:100vh} | |
| .topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:12px;padding:10px 24px;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)} | |
| .brand{font-weight:700;font-size:15px;letter-spacing:-.01em} | |
| .brand small{font-weight:400;color:var(--muted);margin-left:6px} | |
| .topbar .spacer{flex:1} | |
| .topbar .chip{font-size:12px;background:var(--na);border-radius:99px;padding:3px 10px} | |
| .topbar select{width:auto;font-size:12px;padding:3px 8px} | |
| .home-wrap{max-width:1320px;margin:0 auto;padding:24px} | |
| .home-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:6px} | |
| .home-head h2{margin:0;font-size:22px;letter-spacing:-.02em} | |
| .home-stats{color:var(--muted);font-size:13px} | |
| .blind-note{font-size:12px;color:var(--muted);margin:4px 0 18px} | |
| .filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:18px} | |
| .filters input{max-width:320px} | |
| .pill{font-size:12px;border:1px solid var(--line);background:#fff;border-radius:99px;padding:4px 12px;cursor:pointer;color:var(--muted);white-space:nowrap} | |
| .pill.on{background:var(--ink);border-color:var(--ink);color:#fff} | |
| .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px} | |
| .pcard{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 16px 14px;cursor:pointer;display:flex;flex-direction:column;gap:8px;transition:box-shadow .15s,transform .15s,border-color .15s;min-height:230px} | |
| .pcard:hover{box-shadow:0 10px 28px rgba(15,23,42,.08);transform:translateY(-2px);border-color:#cfd6e2} | |
| .pcard .meta{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)} | |
| .venue{font-size:11px;font-weight:600;border-radius:6px;padding:2px 8px;background:var(--na);color:var(--ink)} | |
| .pcard h3{margin:0;font-size:14.5px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} | |
| .pcard .abs{font-size:12.5px;color:var(--muted);line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;flex:1} | |
| .pcard .foot{display:flex;align-items:center;gap:10px;border-top:1px dashed var(--line);padding-top:10px} | |
| .blocks{display:flex;gap:3px;flex:1} | |
| .blk{flex:1;height:22px;border-radius:5px;background:var(--na);font-size:10px;font-weight:600;color:#9aa3b2;display:flex;align-items:center;justify-content:center} | |
| .blk.on{color:#fff} | |
| .status{font-size:11px;border-radius:99px;padding:2px 9px;white-space:nowrap} | |
| .status.todo{background:var(--na);color:var(--muted)} | |
| .status.doing{background:var(--accent-soft);color:var(--accent)} | |
| .empty{color:var(--muted);text-align:center;padding:60px 0} | |
| /* ---------- 1003: admin upload + pool ---------- */ | |
| .pool-pill{font-size:11px;border-radius:99px;padding:2px 9px;border:1px solid var(--line);background:#fff;color:var(--muted);cursor:pointer;white-space:nowrap} | |
| .pool-pill.on{background:#e7f6ec;border-color:#bfe3cb;color:var(--green)} | |
| .src-tag{font-size:10.5px;border-radius:4px;padding:1px 6px;background:var(--na);color:var(--muted)} | |
| #upload{display:none;position:fixed;inset:0;z-index:70;background:rgba(15,23,42,.45);align-items:center;justify-content:center;padding:24px} | |
| #upload .box{background:var(--panel);border-radius:14px;width:min(640px,100%);max-height:100%;overflow:auto;padding:22px 24px;box-shadow:0 30px 80px rgba(0,0,0,.25)} | |
| #upload h3{margin:0 0 4px;font-size:17px} | |
| #upload .sub{font-size:12.5px;color:var(--muted);margin-bottom:14px;line-height:1.5} | |
| #upload label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px} | |
| #upload .row{display:flex;gap:10px} | |
| #upload .row>div{flex:1} | |
| #upload .acts{display:flex;align-items:center;gap:10px;margin-top:16px} | |
| #up-err{color:var(--red);font-size:12px} | |
| .job{border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin-top:10px;font-size:12.5px} | |
| .job .st{font-weight:600} | |
| .job .st.done{color:var(--green)} .job .st.error{color:var(--red)} .job .st.running,.job .st.queued{color:var(--accent)} | |
| .job pre{margin:6px 0 0;font-size:11.5px;white-space:pre-wrap;color:var(--muted);font-family:ui-monospace,monospace;max-height:140px;overflow:auto} | |
| /* ---------- detail modal ---------- */ | |
| #detail{display:none;position:fixed;inset:0;z-index:60;background:rgba(15,23,42,.45);align-items:center;justify-content:center;padding:24px} | |
| #detail .modal{background:var(--bg);border-radius:16px;width:min(1400px,100%);height:min(920px,100%);display:flex;flex-direction:column;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.25)} | |
| .m-head{display:flex;align-items:flex-start;gap:14px;padding:16px 20px;background:var(--panel);border-bottom:1px solid var(--line)} | |
| .m-head h2{margin:4px 0 0;font-size:17px;line-height:1.35} | |
| .m-head .x{margin-left:auto;font-size:18px;line-height:1;padding:4px 10px} | |
| .m-body{flex:1;display:flex;min-height:0} | |
| .m-pdf{flex:1.1;min-width:0;border-right:1px solid var(--line);display:flex;flex-direction:column;background:#525659} | |
| .m-pdf iframe{flex:1;border:0;width:100%} | |
| .m-pdf .nopdf{color:#fff;margin:auto;opacity:.8} | |
| .m-side{flex:1;min-width:0;display:flex;flex-direction:column} | |
| .m-cards{flex:1;overflow:auto;padding:16px 18px;display:grid;grid-template-columns:1fr 1fr;gap:12px;align-content:start} | |
| .bcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;border-top:3px solid var(--bc,var(--line))} | |
| .bcard.wide{grid-column:1/-1} | |
| .bcard h4{margin:0 0 2px;font-size:13px;display:flex;align-items:center;gap:6px} | |
| .bcard .sub{font-size:11.5px;color:var(--muted);margin-bottom:8px} | |
| .bcard ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} | |
| .bcard li{font-size:12.5px;line-height:1.45;background:#fafbfd;border:1px solid #eef1f6;border-radius:8px;padding:6px 8px} | |
| .bcard li .k{color:var(--muted);font-size:11px;margin-right:4px} | |
| .bcard li .tag{font-size:10.5px;border-radius:4px;padding:0 5px;background:var(--na);color:var(--muted);margin-left:4px;white-space:nowrap;display:inline-block} | |
| .bcard .none{font-size:12px;color:#a0a8b6;font-style:italic} | |
| .bar{height:4px;background:var(--na);border-radius:99px;overflow:hidden;margin:6px 0 8px} | |
| .bar i{display:block;height:100%;background:var(--bc)} | |
| .m-foot{display:flex;align-items:center;gap:10px;padding:12px 18px;background:var(--panel);border-top:1px solid var(--line)} | |
| .m-foot .spacer{flex:1} | |
| .m-foot button.primary{padding:7px 18px;font-weight:600;white-space:nowrap} | |
| .m-foot a{white-space:nowrap} | |
| @media (max-width:900px){.m-body{flex-direction:column}.m-pdf{flex:none;height:45%}.m-cards{grid-template-columns:1fr}} | |
| header .chip.sync{cursor:pointer} | |
| header .chip.sync.ok{background:#e7f6ec;color:var(--green)} | |
| header .chip.sync.pending{background:var(--accent-soft);color:var(--accent)} | |
| header .chip.sync.err{background:#fde8e8;color:var(--red)} | |
| /* ---------- layout ---------- */ | |
| #app{display:none;height:100vh;flex-direction:column} | |
| header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)} | |
| header .title{font-weight:600} | |
| header .chip{font-size:12px;background:var(--na);border-radius:99px;padding:2px 10px} | |
| header .spacer{flex:1} | |
| #main{flex:1;display:flex;min-height:0} | |
| #left,#right{min-width:0;display:flex;flex-direction:column} | |
| #left{width:46%;border-right:1px solid var(--line)} | |
| #right{flex:1} | |
| .tabs{display:flex;gap:2px;padding:6px 10px 0;background:var(--panel);border-bottom:1px solid var(--line);flex-wrap:wrap} | |
| .tab{padding:5px 12px;border:1px solid transparent;border-bottom:none;border-radius:8px 8px 0 0;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap} | |
| .tab.on{background:var(--bg);border-color:var(--line);color:var(--ink);font-weight:600} | |
| .tab .cnt{font-size:11px;color:var(--muted)} | |
| .pane{flex:1;overflow:auto;min-height:0} | |
| /* ---------- left ---------- */ | |
| #textpane{padding:16px 20px;white-space:pre-wrap;font-size:13.5px;line-height:1.75;user-select:text} | |
| #textpane mark{background:#ffe9a8;border-radius:3px} | |
| #textpane table{border-collapse:collapse;margin:10px 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%} | |
| #textpane th,#textpane td{border:1px solid var(--line);padding:3px 8px;text-align:left;white-space:nowrap} | |
| #textpane th{background:#fbfcfe;font-weight:600} | |
| #textpane h2,#textpane h3,#textpane h4,#textpane h5,#textpane h6{margin:14px 0 6px;line-height:1.3} | |
| #textpane h2{font-size:16px} #textpane h3{font-size:14.5px} #textpane h4,#textpane h5,#textpane h6{font-size:13.5px} | |
| #textpane p{margin:6px 0} | |
| #textpane .flash{background:#ffe9a8;transition:background .3s} | |
| /* 1003: PDF ↔ text side by side */ | |
| #splitter{flex:none;width:6px;cursor:col-resize;background:var(--line)} | |
| #splitter:hover,#splitter.drag{background:var(--accent)} | |
| .lpane{flex:1;display:flex;min-height:0} | |
| .lpane>div{flex:1;min-width:0;overflow:auto} | |
| #pdfview{background:#525659;padding:12px;position:relative;border-right:1px solid var(--line)} | |
| #pdfview .nopdf{color:#fff;opacity:.8;text-align:center;margin-top:40px} | |
| #pdfview .pg{position:relative;margin:0 auto 12px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.35)} | |
| #pdfview .pg canvas{position:absolute;left:0;top:0} | |
| .textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;text-size-adjust:none;forced-color-adjust:none;transform-origin:0 0;z-index:2} | |
| .textLayer span,.textLayer br{color:transparent;position:absolute;white-space:pre;cursor:text;transform-origin:0% 0%} | |
| .textLayer ::selection{background:rgba(37,99,235,.3)} | |
| .textLayer .endOfContent{display:block;position:absolute;inset:100% 0 0;z-index:-1;cursor:default;user-select:none} | |
| .hl-layer{position:absolute;inset:0;pointer-events:none;z-index:3} | |
| .hl-layer i{position:absolute;border-radius:3px} | |
| .hl-layer i.blk{background:rgba(217,119,6,.12);outline:2px solid rgba(217,119,6,.75)} | |
| .hl-layer i.ev{background:rgba(250,204,21,.35);outline:2px solid #eab308} | |
| #textpane .tb{border-left:3px solid transparent;padding-left:8px;margin-left:-11px;border-radius:2px} | |
| #textpane.linked .tb:hover{border-left-color:#d6dbe4} | |
| #textpane .tb.cur{border-left-color:var(--accent);background:#fffaf0} | |
| #textpane .tb.k-figure,#textpane .tb.k-formula{color:var(--muted);font-style:italic} | |
| #textpane .tb.k-caption{font-size:12.5px;font-weight:600;color:#374151} | |
| #textpane .tb.k-footnote,#textpane .tb.k-reference{font-size:12px;color:var(--muted)} | |
| .src-chip{margin-left:auto;align-self:center;font-size:11px;border-radius:99px;padding:2px 9px;margin-bottom:4px;white-space:nowrap} | |
| .src-chip.good{background:#e7f6ec;color:var(--green)} | |
| .src-chip.ok{background:#eff6ff;color:#1d4ed8} | |
| .src-chip.weak{background:#fde8e8;color:var(--red)} | |
| .ev .pdf-tag{font-size:10px;font-weight:600;border-radius:4px;padding:0 4px;background:#eff6ff;color:#1d4ed8;cursor:pointer} | |
| .ev .g-pdf{color:var(--accent);font-weight:600} | |
| #seltool{position:fixed;display:none;z-index:40;background:var(--ink);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;box-shadow:0 4px 14px rgba(0,0,0,.25);cursor:pointer} | |
| /* ---------- right ---------- */ | |
| .rt-help{font-size:12px;color:var(--muted);padding:8px 14px 0} | |
| .recwrap{padding:10px 14px 80px} | |
| .record{background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:14px} | |
| .rec-head{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);background:#fbfcfe;border-radius:10px 10px 0 0;flex-wrap:wrap} | |
| .rec-head .rlabel{font-weight:600;font-size:13px} | |
| .rec-head .uid{font-size:11px;color:var(--muted)} | |
| .extras{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:12px} | |
| .extras select{width:auto} | |
| .group{border-top:1px solid var(--line)} | |
| .group:first-of-type{border-top:none} | |
| .group-title{font-size:12px;font-weight:600;color:var(--muted);padding:8px 12px 0;cursor:pointer} | |
| .group-title .tri{display:inline-block;transition:.15s} | |
| .group.closed .tri{transform:rotate(-90deg)} | |
| .group.closed .field{display:none} | |
| /* ---------- field card ---------- */ | |
| .field{padding:8px 12px;border-top:1px dashed #eef1f6} | |
| .field.active{background:var(--accent-soft);outline:2px solid var(--accent);outline-offset:-2px;border-radius:6px} | |
| .field.na{background:var(--na);opacity:.65} | |
| .f-head{display:flex;align-items:baseline;gap:8px;cursor:pointer} | |
| .f-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--blue);font-weight:600} | |
| .f-label{font-size:13px} | |
| .badge{font-size:10px;border-radius:4px;padding:1px 5px;font-weight:600} | |
| .b-novel{background:#fdf2f8;color:#be185d} | |
| .b-op{background:#eff6ff;color:#1d4ed8} | |
| .b-gold{background:#fef9c3;color:#a16207} | |
| .b-verb{background:#f0fdf4;color:#15803d} | |
| .b-na{background:#e2e8f0;color:#475569} | |
| .f-done{margin-left:auto;font-size:11px;color:var(--green)} | |
| .f-body{margin-top:6px;display:grid;grid-template-columns:150px 170px 1fr;gap:6px;align-items:start} | |
| .f-body .full{grid-column:1/-1} | |
| .f-hint{grid-column:1/-1;font-size:11px;color:var(--muted)} | |
| .f-guide{font-size:12px;color:var(--muted);margin:2px 0 0;line-height:1.45} | |
| .f-guide .eg{color:#8a94a6;font-style:italic} | |
| .ev-list{grid-column:1/-1;display:flex;flex-direction:column;gap:4px} | |
| .ev{display:flex;gap:6px;align-items:center;background:#fbfcfe;border:1px solid var(--line);border-radius:6px;padding:4px 6px;font-size:12px} | |
| .ev .q{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer} | |
| .ev select{width:auto;font-size:11px;padding:2px 4px} | |
| .ev .g-ok{color:var(--green)} .ev .g-bad{color:var(--red);font-weight:600} | |
| .ev label{display:flex;gap:2px;align-items:center;font-size:11px;color:var(--muted);white-space:nowrap} | |
| .axes-row{display:flex;gap:6px;margin-bottom:4px} | |
| .axes-row input{width:140px} | |
| .fk-box{border:1px solid var(--line);border-radius:6px;background:#fff;padding:4px 8px;max-height:110px;overflow:auto} | |
| .fk-box label{display:flex;gap:6px;font-size:12px;align-items:center} | |
| .addbar{padding:0 14px 20px} | |
| .footer-tip{flex:none;background:var(--panel);border-top:1px solid var(--line);padding:5px 14px;font-size:11px;color:var(--muted)} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="login"> | |
| <div class="box"> | |
| <h1>Reproducibility Annotator</h1> | |
| <div class="sub">Blind mode · schema-driven · full audit trail. Do not consult any model output.</div> | |
| <div id="step-login"> | |
| <div id="quick-ids" style="display:none"> | |
| <label>Who are you?</label> | |
| <div id="quick-list" style="display:flex;gap:8px;flex-wrap:wrap"></div> | |
| <label style="margin-top:16px">or type an ID</label> | |
| </div> | |
| <label id="id-label">Annotator ID (e.g. A / B, keep it consistent)</label> | |
| <input type="text" id="in-annotator" placeholder="A" autocomplete="username"> | |
| <div id="pw-wrap" style="display:none"> | |
| <label>Password</label> | |
| <input type="password" id="in-password" autocomplete="current-password"> | |
| </div> | |
| <div class="login-err" id="login-err"></div> | |
| <div style="margin-top:18px;text-align:right"><button class="primary" id="btn-login">Sign in</button></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="home"> | |
| <div class="topbar"> | |
| <span class="brand">Reproducibility Annotator<small>independent double annotation</small></span> | |
| <span class="chip" id="hm-schema"></span> | |
| <span class="spacer"></span> | |
| <button id="hm-add" class="primary" style="display:none">+ Add paper</button> | |
| <span id="hm-view" style="display:none;font-size:12px;color:var(--muted)">Viewing annotator <select id="hm-annotator"></select></span> | |
| <span class="chip" id="hm-who"></span> | |
| <button id="hm-logout">Sign out</button> | |
| </div> | |
| <div class="home-wrap"> | |
| <div class="home-head"> | |
| <h2>Papers</h2> | |
| <span class="home-stats" id="hm-stats"></span> | |
| </div> | |
| <div class="blind-note" id="hm-note">Blind mode: you only see your own annotations. Open a card to preview the PDF and your annotation cards.</div> | |
| <div class="filters"> | |
| <input type="text" id="hm-q" placeholder="Search title or ID"> | |
| <span id="hm-venues"></span> | |
| <span style="width:8px"></span> | |
| <span id="hm-status"></span> | |
| </div> | |
| <div class="grid" id="hm-grid"></div> | |
| </div> | |
| </div> | |
| <div id="upload"> | |
| <div class="box"> | |
| <h3>Add a paper</h3> | |
| <div class="sub">Upload a PDF, or just give a PMCID / DOI / PMID. If the paper is in the PMC open-access subset the publisher's XML is fetched automatically (best text); otherwise the PDF is converted with Docling. Conversion takes about 10 s – 2 min. Papers are only ever added, never overwritten.</div> | |
| <label>PDF</label> | |
| <input type="file" id="up-pdf" accept="application/pdf,.pdf"> | |
| <label>PMCID / DOI / PMID <span class="muted">(optional; detected from the PDF if left empty)</span></label> | |
| <input type="text" id="up-ident" placeholder="e.g. PMC4502302 or 10.1155/2015/605940"> | |
| <div class="row"> | |
| <div><label>Title <span class="muted">(optional)</span></label><input type="text" id="up-title"></div> | |
| <div style="max-width:160px"><label>Venue <span class="muted">(optional)</span></label><input type="text" id="up-venue" placeholder="e.g. MICCAI"></div> | |
| </div> | |
| <label style="display:flex;gap:6px;align-items:center;color:var(--ink);font-size:13px;margin-top:14px"><input type="checkbox" id="up-pool" checked> Add to the annotators' pool (everyone except admin sees only pool papers)</label> | |
| <div class="acts"><button class="primary" id="up-go">Upload & convert</button><button id="up-close">Close</button><span id="up-err"></span></div> | |
| <div id="up-jobs"></div> | |
| </div> | |
| </div> | |
| <div id="detail"> | |
| <div class="modal"> | |
| <div class="m-head"> | |
| <div style="min-width:0"> | |
| <div class="meta" id="dt-meta" style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--muted)"></div> | |
| <h2 id="dt-title"></h2> | |
| </div> | |
| <button class="x" id="dt-close" title="Close (Esc)">×</button> | |
| </div> | |
| <div class="m-body"> | |
| <div class="m-pdf" id="dt-pdf"></div> | |
| <div class="m-side"> | |
| <div class="m-cards" id="dt-cards"></div> | |
| <div class="m-foot"> | |
| <span class="muted" id="dt-sum" style="font-size:12px"></span> | |
| <span class="spacer"></span> | |
| <a id="dt-pdflink" target="_blank" style="font-size:12px">Open PDF in new tab ↗</a> | |
| <button class="primary" id="dt-start">Start annotating</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="app"> | |
| <header> | |
| <span class="title" id="h-paper"></span> | |
| <span class="chip" id="h-annotator"></span> | |
| <span class="chip" id="h-schema"></span> | |
| <span class="chip" id="h-progress">0 fields annotated</span> | |
| <span class="spacer"></span> | |
| <span class="chip sync" id="h-sync" title="Click to sync now">…</span> | |
| <button id="btn-switch">← Papers</button> | |
| <button id="btn-logout">Sign out</button> | |
| </header> | |
| <div id="main"> | |
| <div id="left"> | |
| <div class="tabs" id="lt-tabs"></div> | |
| <div class="lpane"><div id="pdfview"></div><div id="textpane"></div></div> | |
| </div> | |
| <div id="splitter" title="Drag to resize"></div> | |
| <div id="right"> | |
| <div class="tabs" id="rt-tabs"></div> | |
| <div class="rt-help" id="rt-help"></div> | |
| <div class="pane"><div class="recwrap" id="recwrap"></div></div> | |
| <div class="footer-tip">Select text in the text pane <b>or on the PDF</b> → "Add as evidence" or ⌘/Ctrl+E attaches it to the active field · click a paragraph to see it on the PDF (and vice versa) · if text and PDF disagree, trust the PDF and tick dmg · ✓ grounded / ✗ mismatch · autosave</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="seltool">Add as evidence ⌘E</div> | |
| <script> | |
| ; | |
| const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; | |
| const api=(p,opt)=>fetch(p,opt).then(r=>{ | |
| if(r.status===401&&S.annotator){alert("Session expired, please sign in again");location.reload();} | |
| if(!r.ok)throw new Error(p+" "+r.status);return r.json()}); | |
| const lsGet=()=>{try{return JSON.parse(localStorage.getItem("repl_ann")||"{}")}catch(e){return {}}}; | |
| const lsSet=v=>{try{localStorage.setItem("repl_ann",JSON.stringify(v))}catch(e){}}; | |
| const S={annotator:null,paper:null,schema:null,manifest:null,records:[],fields:{},rtype:null,active:null,rawText:"",saveTimers:{}}; | |
| /* ---------------- boot / login ---------------- */ | |
| (async function(){ | |
| const cfg=await fetch("/api/config").then(r=>r.json()); | |
| S.authMode=cfg.auth; | |
| $("#pw-wrap").style.display=cfg.auth?"block":"none"; | |
| if(!cfg.auth&&cfg.annotators&&cfg.annotators.length){ | |
| $("#quick-ids").style.display="block";$("#id-label").style.display="none"; | |
| $("#quick-list").innerHTML=cfg.annotators.map(a=>`<button class="qid" data-id="${esc(a)}" style="padding:6px 16px;font-weight:600">${esc(a)}</button>`).join(""); | |
| $("#quick-list").onclick=e=>{const b=e.target.closest(".qid");if(b){$("#in-annotator").value=b.dataset.id;doLogin()}}; | |
| } | |
| const saved=lsGet(); | |
| if(saved.annotator)$("#in-annotator").value=saved.annotator; | |
| const me=await fetch("/api/me"); | |
| if(me.ok)await enterHome(await me.json()); | |
| })(); | |
| async function doLogin(){ | |
| const a=$("#in-annotator").value.trim(), pw=$("#in-password").value; | |
| if(!a){$("#login-err").textContent="Enter an annotator ID";return} | |
| const r=await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({annotator:a,password:pw})}); | |
| if(!r.ok){$("#login-err").textContent=r.status===401?(S.authMode?"Wrong ID or password":"Unknown annotator ID"):r.status===400?"ID: letters, digits, _ or - only":r.status===403?"This ID cannot sign in":"Login failed ("+r.status+")";return} | |
| $("#login-err").textContent=""; | |
| await enterHome(await api("/api/me")); | |
| } | |
| $("#btn-login").onclick=doLogin; | |
| $("#in-password").addEventListener("keydown",e=>{if(e.key==="Enter")doLogin()}); | |
| $("#in-annotator").addEventListener("keydown",e=>{if(e.key==="Enter"&&!S.authMode)doLogin()}); | |
| /* ---------------- home: paper cards ---------------- */ | |
| const enc=encodeURIComponent; | |
| /* 1103: block letters come from the schema (record_types[].short) so the same page serves 0817 and v7 */ | |
| const BLOCKS_0817=[["l1","L1"],["dataset","D"],["split","S"],["asset","A"],["model","M"],["result","R"],["claim","C"]]; | |
| const blocks=()=>S.schema.record_types.some(r=>r.short)?S.schema.record_types.map(r=>[r.id,r.short||r.id]):BLOCKS_0817; | |
| /* All UI text is English (0925). Schema labels carry Chinese in parentheses; stripped for display (schema file untouched). */ | |
| const EN_RT={l1:"L1 characterization",dataset:"Datasets",split:"Splits",asset:"Artifacts (code / weights / environment)",model:"Models",result:"Results",claim:"Claims"}; | |
| const EN_STATUS={stated:"stated",directly_reported:"directly reported",inferred:"inferred",not_reported:"not reported",absent_deferred:"deferred elsewhere",uncertain:"uncertain"}; | |
| const rtName=rt=>rt.label_en||EN_RT[rt.id]||rt.label; | |
| const prettyEnum=t=>String(t).replace(/^C\d_/,"").replace(/_/g," "); | |
| const stripZh=t=>typeof t==="string"?t.replace(/\s*[((](?=[^()()]*[\u4e00-\u9fff])[^()()]*[))]/g,"").trim():t; | |
| function enSchema(x){if(Array.isArray(x))return x.map(enSchema);if(x&&typeof x==="object"){const o={};for(const [k,v] of Object.entries(x))o[k]=(k==="label"||k==="title"||k==="help"||k==="hint")?stripZh(v):enSchema(v);return o}return x} | |
| const H={view:null,overview:{},q:"",venue:"all",status:"all",pid:null}; | |
| const rtDef=id=>S.schema.record_types.find(r=>r.id===id); | |
| const fieldDefs=rt=>rt.fields||(rt.groups||[]).flatMap(g=>g.fields||[]); | |
| const isFilled=p=>!!(p&&(p.status||p.value||(p.evidence&&p.evidence.length)||(p.fk&&p.fk.length)||(p.axes&&p.axes.length)||p.no_anchor)); | |
| function valOf(p,max=90){ | |
| if(!p)return ""; | |
| let v=p.value; | |
| if(v&&typeof v==="object")v=Array.isArray(v)?v.join(", "):Object.values(v).filter(Boolean).join(" · "); | |
| v=(v||"").toString().trim(); | |
| if(p.value_text)v=v?v+" · "+p.value_text:p.value_text; /* v7 choice + text */ | |
| if(!v&&p.status)v="("+(EN_STATUS[p.status]||p.status)+")"; | |
| return v.length>max?v.slice(0,max)+"…":v; | |
| } | |
| async function enterHome(me){ | |
| S.me=me; | |
| if(!S.schema)S.schema=enSchema(await api("/api/schema")); | |
| if(!S.manifest)S.manifest=await api("/api/papers"); | |
| H.view=H.view||me.annotator; | |
| $("#hm-schema").textContent="schema "+S.schema.schema_version; | |
| $("#hm-who").textContent="Signed in: "+me.annotator+(me.admin?" (admin)":""); | |
| if(me.admin){ | |
| $("#hm-view").style.display="inline";$("#hm-add").style.display="inline-block"; | |
| $("#hm-annotator").innerHTML=me.annotators.map(a=>`<option value="${esc(a)}" ${a===H.view?"selected":""}>${esc(a)}</option>`).join(""); | |
| $("#hm-note").textContent="Admin view: switch annotator at the top right to read anyone's cards (read-only). Annotators only ever see their own."; | |
| } | |
| const venues=[...new Set(Object.values(S.manifest.papers).map(e=>e.venue).filter(Boolean))].sort(); | |
| $("#hm-venues").innerHTML=["all",...venues].map(v=>`<span class="pill ${v===H.venue?"on":""}" data-venue="${esc(v)}">${v==="all"?"All venues":esc(v)}</span>`).join(" "); | |
| $("#hm-status").innerHTML=[["all","Any status"],["todo","Not started"],["doing","In progress"]].map(([k,l])=>`<span class="pill ${k===H.status?"on":""}" data-status="${k}">${l}</span>`).join(" "); | |
| $("#login").style.display="none";$("#app").style.display="none";$("#home").style.display="block"; | |
| await loadOverview(); | |
| } | |
| async function loadOverview(){ | |
| $("#hm-grid").innerHTML=`<div class="empty">Loading…</div>`; | |
| H.overview=(await api(`/api/overview?annotator=${enc(H.view)}`)).papers; | |
| renderGrid(); | |
| } | |
| const statusOf=pid=>(H.overview[pid]&&H.overview[pid].filled>0)?"doing":"todo"; | |
| function renderGrid(){ | |
| const all=Object.entries(S.manifest.papers); | |
| const doing=all.filter(([p])=>statusOf(p)==="doing").length; | |
| $("#hm-stats").textContent=`${all.length} papers · ${doing} in progress · ${all.length-doing} not started`+(H.view!==S.me.annotator?` · viewing ${H.view}`:""); | |
| const q=H.q.toLowerCase(); | |
| const rows=all.filter(([pid,e])=>(H.venue==="all"||e.venue===H.venue)&&(H.status==="all"||statusOf(pid)===H.status) | |
| &&(!q||pid.toLowerCase().includes(q)||(e.title||"").toLowerCase().includes(q))); | |
| if(!rows.length){$("#hm-grid").innerHTML=`<div class="empty">No papers match</div>`;return} | |
| $("#hm-grid").innerHTML=rows.map(([pid,e])=>{ | |
| const o=H.overview[pid]||{blocks:{},filled:0}; | |
| const blks=blocks().map(([id,sh])=>{ | |
| const b=o.blocks[id]||{records:0,filled:0},rt=rtDef(id); | |
| const on=b.filled>0; | |
| return `<span class="blk ${on?"on":""}" style="${on?`background:var(--c-${id})`:""}" title="${esc(rt?rtName(rt):id)}: ${b.records} records · ${b.filled} fields filled">${sh}</span>`; | |
| }).join(""); | |
| const st=statusOf(pid); | |
| return `<div class="pcard" data-pid="${esc(pid)}"> | |
| <div class="meta"><span class="venue">${esc(e.venue||"")}</span><span>${esc(pid)}</span>${e.text_source==="jats"?'<span class="src-tag">PMC XML</span>':""}${S.me.admin&&S.manifest.pool?`<span style="flex:1"></span><span class="pool-pill ${S.manifest.pool.includes(pid)?"on":""}" data-pool="${esc(pid)}" title="Whether annotators (A/B/C…) see this paper">${S.manifest.pool.includes(pid)?"✓ in pool":"not in pool"}</span>`:""}</div> | |
| <h3>${esc(e.title||pid)}</h3> | |
| <div class="abs">${esc(e.abstract||"")}</div> | |
| <div class="foot"><div class="blocks">${blks}</div> | |
| <span class="status ${st}">${st==="doing"?o.filled+" fields":"Not started"}</span></div> | |
| </div>`; | |
| }).join(""); | |
| } | |
| $("#hm-grid").onclick=async e=>{ | |
| const pp=e.target.closest(".pool-pill"); | |
| if(pp){ | |
| e.stopPropagation();const pid=pp.dataset.pool,inc=!S.manifest.pool.includes(pid); | |
| pp.textContent="…"; | |
| try{const r=await api("/api/admin/pool",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({paper:pid,include:inc})});S.manifest.pool=r.pool} | |
| catch(err){alert("Could not update the pool: "+err.message)} | |
| renderGrid();return; | |
| } | |
| const c=e.target.closest(".pcard");if(c)openDetail(c.dataset.pid)}; | |
| /* ---------------- admin: add paper (1003) ---------------- */ | |
| const UP={timer:null}; | |
| $("#hm-add").onclick=()=>{$("#upload").style.display="flex";$("#up-err").textContent="";pollJobs()}; | |
| $("#up-close").onclick=()=>{$("#upload").style.display="none"}; | |
| $("#upload").onclick=e=>{if(e.target.id==="upload")$("#upload").style.display="none"}; | |
| $("#up-go").onclick=async()=>{ | |
| const f=$("#up-pdf").files[0],ident=$("#up-ident").value.trim(); | |
| if(!f&&!ident){$("#up-err").textContent="Choose a PDF or enter an ID";return} | |
| const fd=new FormData(); | |
| if(f)fd.append("pdf",f); | |
| fd.append("ident",ident);fd.append("title",$("#up-title").value.trim());fd.append("venue",$("#up-venue").value.trim()); | |
| fd.append("add_to_pool",$("#up-pool").checked?"true":"false"); | |
| $("#up-go").disabled=true;$("#up-err").textContent="Uploading…"; | |
| try{ | |
| const r=await fetch("/api/admin/upload",{method:"POST",body:fd}); | |
| if(!r.ok){const t=await r.json().catch(()=>({}));throw new Error(t.detail||r.status)} | |
| $("#up-err").textContent="";$("#up-pdf").value="";$("#up-ident").value="";$("#up-title").value="";$("#up-venue").value=""; | |
| }catch(err){$("#up-err").textContent=String(err.message||err)} | |
| $("#up-go").disabled=false;pollJobs(); | |
| }; | |
| async function pollJobs(){ | |
| clearTimeout(UP.timer); | |
| let jobs=[];try{jobs=(await api("/api/admin/jobs")).jobs}catch(e){return} | |
| const ST={queued:"waiting",running:"converting…",done:"added",error:"failed"}; | |
| $("#up-jobs").innerHTML=jobs.map(j=>`<div class="job"><span class="st ${j.status}">${ST[j.status]||j.status}</span> · ${esc(j.file||j.ident||"")}${j.pid?` → <b>${esc(j.pid)}</b>`:""}${j.error?`<div style="color:var(--red);margin-top:4px">${esc(j.error)}</div>`:""}<pre>${esc((j.log||[]).join("\n"))}</pre></div>`).join(""); | |
| const busy=jobs.some(j=>j.status==="queued"||j.status==="running"); | |
| const fresh=jobs.filter(j=>j.status==="done"&&!(j.pid in S.manifest.papers)); | |
| if(fresh.length){S.manifest=await api("/api/papers");await loadOverview()} | |
| if(busy||$("#upload").style.display==="flex")UP.timer=setTimeout(pollJobs,busy?2000:6000); | |
| } | |
| $("#hm-q").oninput=e=>{H.q=e.target.value;renderGrid()}; | |
| $("#hm-venues").onclick=e=>{const p=e.target.closest(".pill");if(!p)return;H.venue=p.dataset.venue;$$("#hm-venues .pill").forEach(x=>x.classList.toggle("on",x===p));renderGrid()}; | |
| $("#hm-status").onclick=e=>{const p=e.target.closest(".pill");if(!p)return;H.status=p.dataset.status;$$("#hm-status .pill").forEach(x=>x.classList.toggle("on",x===p));renderGrid()}; | |
| $("#hm-annotator").onchange=e=>{H.view=e.target.value;loadOverview()}; | |
| /* ---------------- detail: PDF preview + annotation cards ---------------- */ | |
| async function openDetail(pid){ | |
| H.pid=pid;const e=S.manifest.papers[pid]; | |
| $("#dt-meta").innerHTML=`<span class="venue">${esc(e.venue||"")}</span><span>${esc(pid)}</span>`; | |
| $("#dt-title").textContent=e.title||pid; | |
| const url=`/api/paper/${enc(pid)}/pdf`; | |
| $("#dt-pdf").innerHTML=e.pdf?`<iframe src="${url}#navpanes=0&view=FitH"></iframe>`:`<div class="nopdf">No PDF for this paper</div>`; | |
| $("#dt-pdflink").style.display=e.pdf?"inline":"none";$("#dt-pdflink").href=url; | |
| $("#dt-cards").innerHTML=`<div class="muted">Loading…</div>`; | |
| $("#detail").style.display="flex"; | |
| const st=await api(`/api/state?paper=${enc(pid)}&annotator=${enc(H.view)}`); | |
| if(H.pid!==pid)return; | |
| renderCards(st); | |
| const mine=!st.read_only, started=Object.values(st.fields).some(m=>Object.values(m).some(isFilled)); | |
| $("#dt-start").textContent=!mine?`Annotate as ${S.me.annotator}`:started?"Continue annotating":"Start annotating"; | |
| } | |
| function recordLine(rt,r,flds){ | |
| const f=flds||{},x=r.extra||{}; | |
| const tags=Object.entries(x).filter(([k,v])=>v&&k!=="model_fk"&&typeof v!=="object").map(([k,v])=>{const t=v===true?k:String(v);return `<span class="tag">${esc(prettyEnum(t))}</span>`}).join(""); | |
| let main; | |
| if(rt.id==="result"){main=[valOf(f.R1,50),valOf(f.R2,40)].filter(Boolean).join(" = ")} | |
| else if(rt.id==="claim"||rt.id==="summary"){main=valOf(f.F1,160)} | |
| else if(rt.id==="asset"){main=[x.asset_type&&prettyEnum(x.asset_type),valOf(f.availability_claim,90)].filter(Boolean).join(": ")} | |
| else{main=valOf(f[rt.label_field],80)} | |
| const ce=rt.card_extra; /* v7: [label, field id] from the schema */ | |
| const extra=ce?(f[ce[1]]?`<span class="k">${esc(ce[0])}</span>${esc(valOf(f[ce[1]],40))}`:""):rt.id==="dataset"&&f.D3?`<span class="k">source</span>${esc(valOf(f.D3,40))}`:rt.id==="split"&&f.D26?`<span class="k">split unit</span>${esc(valOf(f.D26,40))}`:""; | |
| const n=fieldDefs(rt).filter(d=>isFilled(f[d.id])).length; | |
| return `<li>${main?esc(main):`<span class="none">(unnamed)</span>`}${rt.id==="asset"?"":tags} ${extra?"<br>"+extra:""}<span class="tag" style="float:right">${n}/${fieldDefs(rt).length}</span></li>`; | |
| } | |
| function renderCards(st){ | |
| const by={};st.records.forEach(r=>(by[r.rtype]=by[r.rtype]||[]).push(r)); | |
| let totalFilled=0; | |
| $("#dt-cards").innerHTML=S.schema.record_types.map(rt=>{ | |
| const defs=fieldDefs(rt),recs=by[rt.id]||[]; | |
| const filledN=recs.reduce((s,r)=>s+defs.filter(d=>isFilled((st.fields[r.uid]||{})[d.id])).length,0); | |
| totalFilled+=filledN; | |
| const denom=defs.length*Math.max(recs.length,1),pct=Math.round(100*filledN/denom); | |
| let body; | |
| if(rt.cardinality==="single"){ | |
| const r=recs[0],f=r?(st.fields[r.uid]||{}):{}; | |
| const items=defs.filter(d=>d.type!=="gate_list").map(d=>`<li><span class="k">${esc(d.label)}</span>${isFilled(f[d.id])?esc(valOf(f[d.id],70)):'<span class="none">—</span>'}</li>`); | |
| body=`<ul>${items.join("")}</ul>`; | |
| }else{ | |
| body=recs.length?`<ul>${recs.map(r=>recordLine(rt,r,st.fields[r.uid])).join("")}</ul>`:`<div class="none">Not annotated yet</div>`; | |
| } | |
| const sub=rt.cardinality==="single"?`${filledN}/${defs.length} fields`:`${recs.length} record${recs.length===1?"":"s"} · ${filledN} fields filled`; | |
| return `<div class="bcard ${rt.cardinality==="single"||rt.id==="claim"||rt.id==="summary"?"wide":""}" style="--bc:var(--c-${rt.id})"> | |
| <h4>${esc(rtName(rt))}</h4><div class="sub">${sub}</div> | |
| <div class="bar"><i style="width:${Math.min(pct,100)}%"></i></div>${body}</div>`; | |
| }).join(""); | |
| $("#dt-sum").textContent=(st.read_only?`${st.annotator}'s annotation (read-only) · `:"")+`${totalFilled} fields filled in total`; | |
| } | |
| function closeDetail(){$("#detail").style.display="none";$("#dt-pdf").innerHTML="";H.pid=null} | |
| $("#dt-close").onclick=closeDetail; | |
| $("#detail").onclick=e=>{if(e.target.id==="detail")closeDetail()}; | |
| document.addEventListener("keydown",e=>{if(e.key==="Escape"&&$("#detail").style.display==="flex")closeDetail()}); | |
| $("#dt-start").onclick=async()=>{ | |
| const pid=H.pid;closeDetail(); | |
| S.annotator=S.me.annotator;S.paper=pid; | |
| lsSet({annotator:S.annotator,paper:pid}); | |
| $("#home").style.display="none"; | |
| await loadPaper(); | |
| $("#app").style.display="flex"; | |
| pollSync();if(!S.syncTimer)S.syncTimer=setInterval(pollSync,5000); | |
| }; | |
| async function logout(){ | |
| await flushPendingSaves(); | |
| await fetch("/api/sync",{method:"POST"}).catch(()=>{}); | |
| await fetch("/api/logout",{method:"POST"});location.reload(); | |
| } | |
| $("#btn-logout").onclick=logout; | |
| $("#hm-logout").onclick=logout; | |
| $("#btn-switch").onclick=async()=>{ | |
| await flushPendingSaves(); | |
| $("#app").style.display="none";$("#home").style.display="block"; | |
| H.view=S.me.annotator;if(S.me.admin)$("#hm-annotator").value=H.view; | |
| await loadOverview(); | |
| }; | |
| /* ---------------- sync indicator ---------------- */ | |
| function fmtTime(ts){return new Date(ts*1000).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"})} | |
| function showSync(s){ | |
| const el=$("#h-sync");el.classList.remove("ok","pending","err"); | |
| const where=s.backend==="github"?"GitHub":"disk"; | |
| if(s.error){el.classList.add("err");el.textContent="Sync error, retrying";el.title=s.error;return} | |
| if(s.pending||Object.keys(S.saveTimers).length){el.classList.add("pending");el.textContent="Saving…";el.title="Changes are saved on the server and will be committed to "+where+" within "+s.flush_seconds+"s";return} | |
| el.classList.add("ok");el.textContent=s.last_ok?"Synced to "+where+" ✓ "+fmtTime(s.last_ok):"Synced ✓";el.title="Click to sync now"; | |
| } | |
| async function pollSync(){try{showSync(await api("/api/sync"))}catch(e){}} | |
| $("#h-sync").onclick=async()=>{try{showSync(await api("/api/sync",{method:"POST"}))}catch(e){}}; | |
| async function flushPendingSaves(){ | |
| for(const [k,t] of Object.entries(S.saveTimers)){clearTimeout(t);const f=S.pendingSaves&&S.pendingSaves[k];if(f)await f();} | |
| S.saveTimers={}; | |
| } | |
| window.addEventListener("beforeunload",e=>{if(Object.keys(S.saveTimers).length){e.preventDefault();e.returnValue=""}}); | |
| async function loadPaper(){ | |
| const pe=enc(S.paper), entry=S.manifest.papers[S.paper]; | |
| const [text,state,layout]=await Promise.all([ | |
| fetch(`/api/paper/${pe}/text`).then(r=>r.text()), | |
| api(`/api/state?paper=${pe}`), | |
| fetch(`/api/paper/${pe}/layout`).then(r=>r.ok?r.json():null).catch(()=>null) | |
| ]); | |
| S.rawText=text;S.records=state.records;S.fields=state.fields;S.layout=layout; | |
| S.isMd=(entry.fulltext||"").endsWith(".md");S.rawView=false; | |
| $("#h-paper").textContent=S.paper; | |
| $("#h-annotator").textContent="Annotator "+S.annotator; | |
| $("#h-schema").textContent="schema "+S.schema.schema_version; | |
| setupLeftTabs(entry); | |
| openPdf(entry.pdf?`/api/paper/${pe}/pdf`:null); | |
| S.rtype=S.schema.record_types[0].id; | |
| ensureSingletons(); | |
| renderTabs();renderRight();updateProgress(); | |
| } | |
| async function ensureSingletons(){ | |
| for(const rt of S.schema.record_types.filter(r=>r.cardinality==="single")){ | |
| if(!S.records.some(r=>r.rtype===rt.id)){ | |
| const res=await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"create",rtype:rt.id,extra:{}})}); | |
| S.records.push({uid:res.uid,rtype:rt.id,extra:{}}); | |
| } | |
| } | |
| renderTabs();renderRight(); | |
| } | |
| /* ---------------- left pane ---------------- */ | |
| function esc(t){return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")} | |
| function renderText(hlFrom,hlTo){ | |
| const el=$("#textpane"); | |
| el.classList.toggle("linked",!!S.layout&&!S.rawView); | |
| if(S.layout&&!S.rawView){ | |
| /* 1003: 按 layout 块渲染, 每块记 data-b, 用来和 PDF 互相定位 */ | |
| el.innerHTML=S.layout.blocks.map(b=>`<div class="tb k-${b.kind}" data-b="${b.id}">${mdToHtml(S.rawText.slice(b.start,b.end))}</div>`).join(""); | |
| return; | |
| } | |
| if(S.isMd&&!S.rawView){el.innerHTML=mdToHtml(S.rawText);return} | |
| if(hlFrom==null){el.innerHTML=esc(S.rawText);return} | |
| el.innerHTML=esc(S.rawText.slice(0,hlFrom))+"<mark id='hl'>"+esc(S.rawText.slice(hlFrom,hlTo))+"</mark>"+esc(S.rawText.slice(hlTo)); | |
| document.getElementById("hl")?.scrollIntoView({block:"center",behavior:"smooth"}); | |
| } | |
| /* 极简 md 渲染: 标题 / 管道表格 / 去粗斜体标记。只做展示, 取证坐标由后端宽容匹配回源文件 */ | |
| function mdToHtml(src){ | |
| const inline=t=>esc(t).replace(/\*\*([^*]+)\*\*/g,"$1").replace(/\*([^*]+)\*/g,"$1").replace(/`([^`]+)`/g,"$1"); | |
| const lines=src.split("\n"); | |
| let html="",i=0,para=[]; | |
| const flush=()=>{if(para.length){html+="<p>"+inline(para.join(" "))+"</p>";para=[]}}; | |
| while(i<lines.length){ | |
| const L=lines[i]; | |
| if(/^\s*\|/.test(L)){ | |
| flush(); | |
| const rows=[]; | |
| while(i<lines.length&&/^\s*\|/.test(lines[i])){ | |
| if(!/^\s*\|[\s\-:|]+\|?\s*$/.test(lines[i]))rows.push(lines[i]); | |
| i++; | |
| } | |
| html+="<table>"+rows.map((r,ri)=>{ | |
| const cells=r.replace(/^\s*\|/,"").replace(/\|\s*$/,"").split(/(?<!\\)\|/).map(c=>c.replace(/\\\|/g,"|")); | |
| const tag=ri===0?"th":"td"; | |
| return "<tr>"+cells.map(c=>`<${tag}>`+inline(c.trim())+`</${tag}>`).join("")+"</tr>"; | |
| }).join("")+"</table>"; | |
| continue; | |
| } | |
| const h=L.match(/^(#{1,6})\s+(.*)/); | |
| if(h){flush();const lv=Math.min(h[1].length+1,6);html+=`<h${lv}>`+inline(h[2])+`</h${lv}>`;i++;continue} | |
| if(/^\s*<!--.*-->\s*$/.test(L)){i++;continue} | |
| if(/^\s*$/.test(L)){flush();i++;continue} | |
| para.push(L);i++; | |
| } | |
| flush(); | |
| return html; | |
| } | |
| /* ---------------- left: views (side by side / text / PDF / raw) ---------------- */ | |
| const SRC={jats:["good","Text: publisher XML (official)","Text comes from the PMC XML, not from PDF conversion"], | |
| docling:["ok","Text: layout-aware PDF conversion","Converted with Docling; tables/formulas can still be wrong, check against the PDF"], | |
| docling_0817:["ok","Text: PDF conversion (0817)","Docling conversion without PDF linking"], | |
| docling_package:["ok","Text: v7 package (Docling)","Same text the v7 extraction read: main text + supplementary parts, marked <<<PART n>>>; figures as captions only. No click-sync with the PDF."], | |
| pymupdf:["weak","Text: plain PDF extraction","Flat PyMuPDF text: tables are often scrambled, check against the PDF"]}; | |
| const W=(()=>{try{return Object.assign({split:64,single:46},JSON.parse(localStorage.getItem("repl_widths")||"{}"))}catch(e){return {split:64,single:46}}})(); | |
| function saveW(){try{localStorage.setItem("repl_widths",JSON.stringify(W))}catch(e){}} | |
| function applyLeftWidth(){$("#left").style.width=(S.view==="split"?W.split:W.single)+"%"} | |
| function setupLeftTabs(entry){ | |
| const views=S.layout?[["split","Side by side"],["text","Text"],["pdf","PDF"]]:[["text","Text"],["pdf","PDF"]]; | |
| if(S.isMd)views.push(["raw","Raw MD"]); | |
| const src=SRC[entry.text_source||(S.isMd?"docling_0817":"pymupdf")]||SRC.pymupdf; | |
| $("#lt-tabs").innerHTML=views.map(([k,l])=>`<div class="tab" data-lt="${k}">${l}</div>`).join("")+`<span class="src-chip ${src[0]}" title="${esc(src[2])}">${esc(src[1])}</span>`; | |
| $$("#lt-tabs .tab").forEach(t=>t.onclick=()=>setView(t.dataset.lt)); | |
| setView(S.layout?"split":"text"); | |
| } | |
| function setView(v){ | |
| S.view=v;S.rawView=(v==="raw"); | |
| $$("#lt-tabs .tab").forEach(x=>x.classList.toggle("on",x.dataset.lt===v)); | |
| $("#pdfview").style.display=(v==="split"||v==="pdf")?"block":"none"; | |
| $("#textpane").style.display=v==="pdf"?"none":"block"; | |
| applyLeftWidth(); | |
| if(v!=="pdf")renderText(); | |
| } | |
| $("#splitter").onmousedown=e=>{ | |
| e.preventDefault(); | |
| const box=$("#main").getBoundingClientRect(),sp=$("#splitter");sp.classList.add("drag"); | |
| const key=S.view==="split"?"split":"single"; | |
| const mv=ev=>{W[key]=Math.min(82,Math.max(22,(ev.clientX-box.left)/box.width*100));$("#left").style.width=W[key]+"%"}; | |
| const up=()=>{document.removeEventListener("mousemove",mv);document.removeEventListener("mouseup",up);sp.classList.remove("drag");saveW()}; | |
| document.addEventListener("mousemove",mv);document.addEventListener("mouseup",up); | |
| }; | |
| /* ---------------- PDF viewer (PDF.js, lazy per page; falls back to the browser viewer) ---------------- */ | |
| if(window.pdfjsLib)pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"; | |
| const PV={doc:null,pages:[],width:0,hl:{},obs:null,token:0}; | |
| async function openPdf(url){ | |
| const box=$("#pdfview"),my=++PV.token; | |
| box.innerHTML="";PV.doc=null;PV.pages=[];PV.hl={};PV.width=0; | |
| if(!url){box.innerHTML=`<div class="nopdf">No PDF for this paper</div>`;return} | |
| if(!window.pdfjsLib){box.style.padding="0";box.innerHTML=`<iframe src="${url}" style="width:100%;height:100%;border:0"></iframe>`;return} | |
| box.style.padding=""; | |
| try{ | |
| const doc=await pdfjsLib.getDocument({url}).promise; | |
| if(my!==PV.token)return; | |
| for(let n=1;n<=doc.numPages;n++){const page=await doc.getPage(n);if(my!==PV.token)return;PV.pages[n]={page}} | |
| PV.doc=doc;layoutPages(); | |
| }catch(e){box.innerHTML=`<div class="nopdf">Could not load the PDF (${esc(String(e.message||e))})</div>`} | |
| } | |
| function layoutPages(){ | |
| const box=$("#pdfview");if(!PV.doc)return; | |
| const w=box.clientWidth-24;if(w<60)return; // hidden (text-only view) | |
| const keep=PV.width?currentPdfSpot():null; | |
| PV.width=w; | |
| if(PV.obs)PV.obs.disconnect(); | |
| PV.obs=new IntersectionObserver(es=>es.forEach(e=>{if(e.isIntersecting)renderPage(+e.target.dataset.page)}),{root:box,rootMargin:"800px 0px"}); | |
| box.innerHTML=""; | |
| PV.pages.forEach((P,n)=>{ | |
| const vp=P.page.getViewport({scale:1});P.w=vp.width;P.scale=w/vp.width;P.rendered=false; | |
| const d=document.createElement("div");d.className="pg";d.dataset.page=n; | |
| d.style.width=w+"px";d.style.height=vp.height*P.scale+"px"; | |
| P.div=d;box.appendChild(d);PV.obs.observe(d); | |
| }); | |
| if(keep)scrollPdfTo(keep.page,keep.y,false); | |
| } | |
| async function renderPage(n){ | |
| const P=PV.pages[n];if(!P||P.rendered)return;P.rendered=true; | |
| const div=P.div,vp=P.page.getViewport({scale:P.scale}),dpr=window.devicePixelRatio||1; | |
| const c=document.createElement("canvas");c.width=Math.floor(vp.width*dpr);c.height=Math.floor(vp.height*dpr); | |
| c.style.width=vp.width+"px";c.style.height=vp.height+"px";div.appendChild(c); | |
| await P.page.render({canvasContext:c.getContext("2d"),viewport:vp,transform:dpr!==1?[dpr,0,0,dpr,0,0]:undefined}).promise; | |
| if(P.div!==div)return; // re-laid out meanwhile | |
| div.style.setProperty("--scale-factor",P.scale); | |
| const tl=document.createElement("div");tl.className="textLayer";div.appendChild(tl); | |
| try{await pdfjsLib.renderTextLayer({textContentSource:P.page.streamTextContent(),container:tl,viewport:vp}).promise}catch(e){} | |
| const hl=document.createElement("div");hl.className="hl-layer";div.appendChild(hl); | |
| drawHl(n); | |
| } | |
| /* layout bbox = PDF points, top-left origin; PDF.js page width in points → css px */ | |
| function pageScale(n){const P=PV.pages[n];if(!P)return 1;const lp=S.layout&&S.layout.pages&&S.layout.pages[n];return PV.width/(lp?lp[0]:P.w)} | |
| function currentPdfSpot(){ | |
| const box=$("#pdfview"),top=box.scrollTop; | |
| for(const [n,P] of PV.pages.entries())if(P&&P.div&&P.div.offsetTop+P.div.offsetHeight>top)return {page:n,y:Math.max(0,top-P.div.offsetTop)/pageScale(n)}; | |
| return null; | |
| } | |
| function scrollPdfTo(n,y,smooth){ | |
| const P=PV.pages[n];if(!P||!P.div)return; | |
| $("#pdfview").scrollTo({top:P.div.offsetTop+y*pageScale(n)-60,behavior:smooth?"smooth":"auto"}); | |
| } | |
| function setHl(boxes,cls,scroll=true){ | |
| PV.hl={};(boxes||[]).forEach(b=>(PV.hl[b.page]=PV.hl[b.page]||[]).push({bbox:b.bbox,cls})); | |
| PV.pages.forEach((P,n)=>{if(P&&P.rendered)drawHl(n)}); | |
| if(scroll&&boxes&&boxes.length&&$("#pdfview").style.display!=="none")scrollPdfTo(boxes[0].page,boxes[0].bbox[1],true); | |
| } | |
| function drawHl(n){ | |
| const P=PV.pages[n],layer=P&&P.div&&P.div.querySelector(".hl-layer");if(!layer)return; | |
| const k=pageScale(n); | |
| layer.innerHTML=(PV.hl[n]||[]).map(h=>{const [x0,y0,x1,y1]=h.bbox; | |
| return `<i class="${h.cls}" style="left:${x0*k-2}px;top:${y0*k-2}px;width:${(x1-x0)*k+4}px;height:${(y1-y0)*k+4}px"></i>`}).join(""); | |
| } | |
| let roT;new ResizeObserver(()=>{clearTimeout(roT);roT=setTimeout(()=>{ | |
| const w=$("#pdfview").clientWidth-24;if(PV.doc&&w>=60&&Math.abs(w-PV.width)>8)layoutPages()},150)}).observe($("#pdfview")); | |
| /* ---------------- text ↔ PDF linking ---------------- */ | |
| function focusBlock(id,from){ | |
| const b=S.layout&&S.layout.blocks[id];if(!b)return; | |
| $$("#textpane .tb.cur").forEach(x=>x.classList.remove("cur")); | |
| const el=$(`#textpane .tb[data-b="${id}"]`); | |
| if(el){el.classList.add("cur");if(from==="pdf")el.scrollIntoView({block:"center",behavior:"smooth"})} | |
| setHl(b.boxes,"blk",from!=="pdf"); | |
| } | |
| $("#textpane").addEventListener("click",e=>{ | |
| if(!S.layout||S.rawView||!window.getSelection().isCollapsed)return; | |
| const tb=e.target.closest(".tb");if(tb)focusBlock(+tb.dataset.b,"text"); | |
| }); | |
| $("#pdfview").addEventListener("click",e=>{ | |
| if(!S.layout||!window.getSelection().isCollapsed)return; | |
| const pg=e.target.closest(".pg");if(!pg)return; | |
| const n=+pg.dataset.page,r=pg.getBoundingClientRect(),k=pageScale(n),x=(e.clientX-r.left)/k,y=(e.clientY-r.top)/k; | |
| let best=null,area=Infinity; | |
| for(const b of S.layout.blocks)for(const bx of b.boxes){ | |
| if(bx.page!==n)continue;const [x0,y0,x1,y1]=bx.bbox; | |
| if(x>=x0-3&&x<=x1+3&&y>=y0-3&&y<=y1+3){const a=(x1-x0)*(y1-y0);if(a<area){area=a;best=b}} | |
| } | |
| if(best)focusBlock(best.id,"pdf"); | |
| }); | |
| /* PDF selection → page boxes (PDF points), merged per line */ | |
| function selectionBoxes(range){ | |
| const pages=$$("#pdfview .pg").map(d=>[d,d.getBoundingClientRect()]),out=[]; | |
| for(const r of range.getClientRects()){ | |
| if(r.width<1||r.height<1)continue; | |
| const hit=pages.find(([,b])=>r.top>=b.top-2&&r.bottom<=b.bottom+2&&r.left>=b.left-2&&r.right<=b.right+2);if(!hit)continue; | |
| const [d,b]=hit,n=+d.dataset.page,k=pageScale(n); | |
| const bb=[(r.left-b.left)/k,(r.top-b.top)/k,(r.right-b.left)/k,(r.bottom-b.top)/k]; | |
| const prev=out[out.length-1]; | |
| if(prev&&prev.page===n&&Math.abs(prev.bbox[1]-bb[1])<4&&bb[0]-prev.bbox[2]<30){ | |
| prev.bbox=[Math.min(prev.bbox[0],bb[0]),Math.min(prev.bbox[1],bb[1]),Math.max(prev.bbox[2],bb[2]),Math.max(prev.bbox[3],bb[3])]; | |
| }else out.push({page:n,bbox:bb}); | |
| } | |
| return out.slice(0,60).map(o=>({page:o.page,bbox:o.bbox.map(v=>Math.round(v*10)/10)})); | |
| } | |
| /* selection toolbar */ | |
| const seltool=$("#seltool"); | |
| document.addEventListener("selectionchange",()=>{ | |
| const sel=window.getSelection(); | |
| if(sel.isCollapsed||!($("#textpane").contains(sel.anchorNode)||$("#pdfview").contains(sel.anchorNode))){seltool.style.display="none";return} | |
| const r=sel.getRangeAt(0).getBoundingClientRect(); | |
| seltool.style.left=Math.min(r.left,innerWidth-160)+"px"; | |
| seltool.style.top=Math.max(4,r.top-34)+"px"; | |
| seltool.style.display="block"; | |
| }); | |
| seltool.onmousedown=e=>{e.preventDefault();addSelectionAsEvidence()}; | |
| document.addEventListener("keydown",e=>{ | |
| if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="e"){e.preventDefault();addSelectionAsEvidence()} | |
| }); | |
| function addSelectionAsEvidence(){ | |
| const sel=window.getSelection(); | |
| if(sel.isCollapsed||!sel.rangeCount)return; | |
| const fromPdf=$("#pdfview").contains(sel.anchorNode); | |
| let q=sel.toString(); | |
| if(fromPdf)q=q.replace(/([A-Za-z])-\s*\n\s*([a-z])/g,"$1$2"); // PDF line-break hyphenation | |
| q=q.replace(/\s+/g," ").trim(); | |
| if(!q)return; | |
| if(!S.active){alert("Activate a field card first");return} | |
| const pl=getPayload(S.active.uid,S.active.fid); | |
| pl.evidence=pl.evidence||[]; | |
| const ev={quote:q,section:"other",flags:{},grounded:null,char_span:null}; | |
| /* 在 PDF 上划选: 记下精确位置; 转换文本里对不上(损坏)时位置也不丢 */ | |
| if(fromPdf){ev.origin="pdf";ev.pdf_anchor={blocks:[],boxes:selectionBoxes(sel.getRangeAt(0))}} | |
| pl.evidence.push(ev); | |
| if(!pl.status)pl.status=S.schema.attributes.status[0].v; | |
| saveField(S.active.uid,S.active.fid,pl,true); | |
| sel.removeAllRanges();seltool.style.display="none"; | |
| } | |
| /* ---------------- right pane ---------------- */ | |
| function rt(){return S.schema.record_types.find(r=>r.id===S.rtype)} | |
| function recsOf(t){return S.records.filter(r=>r.rtype===t)} | |
| function getPayload(uid,fid){ | |
| S.fields[uid]=S.fields[uid]||{}; | |
| S.fields[uid][fid]=S.fields[uid][fid]||{value:"",status:"",granularity:"",evidence:[],defer_target:"",note:""}; | |
| return S.fields[uid][fid]; | |
| } | |
| function naSet(){ | |
| const l1=recsOf("l1")[0];if(!l1)return new Set(); | |
| const v=(S.fields[l1.uid]?.L1_na_fields?.value)||""; | |
| /* 分隔符: , , ; ; 、 空格都行; 范围 R9-R11 / R9–R11 展开 */ | |
| const out=new Set(); | |
| v.toUpperCase().replace(/\s*([-–—~~])\s*/g,"$1").split(/[,,;;、\s]+/).filter(Boolean).forEach(t=>{ | |
| const m=t.match(/^([A-Z]+)(\d+)[-–—~~][A-Z]*(\d+)$/); | |
| if(m&&+m[3]>=+m[2]&&+m[3]-+m[2]<60){for(let i=+m[2];i<=+m[3];i++)out.add(m[1]+i)}else out.add(t); | |
| }); | |
| return out; | |
| } | |
| function recLabel(rec,def){ | |
| const lf=def.label_field; | |
| if(lf&&def.extras?.some(e=>e.id===lf))return rec.extra[lf]||"(unset)"; | |
| if(lf)return (S.fields[rec.uid]?.[lf]?.value||"").slice(0,60)||"("+lf+" empty)"; | |
| return def.label; | |
| } | |
| function renderTabs(){ | |
| const bar=$("#rt-tabs");bar.innerHTML=""; | |
| S.schema.record_types.forEach(def=>{ | |
| const t=document.createElement("div"); | |
| t.className="tab"+(def.id===S.rtype?" on":""); | |
| const n=recsOf(def.id).length; | |
| t.innerHTML=esc(def.label)+(def.cardinality==="multi"?` <span class="cnt">(${n})</span>`:""); | |
| t.onclick=()=>{S.rtype=def.id;renderTabs();renderRight()}; | |
| bar.appendChild(t); | |
| }); | |
| } | |
| function renderRight(){ | |
| const def=rt(); | |
| $("#rt-help").textContent=def.help||""; | |
| const wrap=$("#recwrap");wrap.innerHTML=""; | |
| recsOf(def.id).forEach(rec=>wrap.appendChild(renderRecord(rec,def))); | |
| if(def.cardinality==="multi"){ | |
| const bar=document.createElement("div");bar.className="addbar"; | |
| const b=document.createElement("button");b.className="primary";b.textContent="+ New record: "+def.label; | |
| b.onclick=async()=>{ | |
| const extra={};(def.extras||[]).forEach(e=>{if(e.default)extra[e.id]=e.default}); | |
| const res=await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"create",rtype:def.id,extra})}); | |
| S.records.push({uid:res.uid,rtype:def.id,extra}); | |
| renderTabs();renderRight(); | |
| }; | |
| bar.appendChild(b);wrap.appendChild(bar); | |
| } | |
| } | |
| function renderRecord(rec,def){ | |
| const card=document.createElement("div");card.className="record"; | |
| /* head */ | |
| const head=document.createElement("div");head.className="rec-head"; | |
| head.innerHTML=`<span class="rlabel">${esc(recLabel(rec,def))}</span><span class="uid">#${rec.uid}</span>`; | |
| const ex=document.createElement("span");ex.className="extras"; | |
| (def.extras||[]).filter(e=>e.type!=="fk").forEach(e=>{ // fk 类(model_fk)在下面单独画成勾选框 | |
| const lab=document.createElement("label");lab.textContent=e.label+" "; | |
| if(e.type==="flag"){ | |
| const c=document.createElement("input");c.type="checkbox";c.checked=!!rec.extra[e.id]; | |
| c.onchange=()=>{rec.extra[e.id]=c.checked;saveExtra(rec);renderRight()}; | |
| lab.prepend(c); | |
| }else if(e.type==="text"){ /* 1103 v7: free-text record attribute (model name, ablation variant) */ | |
| const t=document.createElement("input");t.type="text";t.value=rec.extra[e.id]||"";t.style.width="140px"; | |
| t.oninput=()=>{rec.extra[e.id]=t.value;saveExtra(rec)}; | |
| t.onchange=()=>{renderTabs();renderRight()}; /* refresh the card title (label_field may be this attribute) */ | |
| lab.appendChild(t); | |
| }else{ | |
| const s=document.createElement("select"); | |
| s.innerHTML="<option value=''>—</option>"+e.options.map(o=>`<option ${rec.extra[e.id]===o?"selected":""}>${o}</option>`).join(""); | |
| s.onchange=()=>{rec.extra[e.id]=s.value;saveExtra(rec);renderTabs();renderRight()}; | |
| lab.appendChild(s); | |
| } | |
| if(e.hint)lab.title=e.hint; | |
| ex.appendChild(lab); | |
| }); | |
| head.appendChild(ex); | |
| if(def.cardinality==="multi"){ | |
| const del=document.createElement("button");del.textContent="Delete";del.style.marginLeft="auto"; | |
| del.onclick=async()=>{ | |
| if(!confirm("Delete this record and all its field annotations?"))return; | |
| await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"delete",uid:rec.uid})}); | |
| S.records=S.records.filter(r=>r.uid!==rec.uid);renderTabs();renderRight(); | |
| }; | |
| head.appendChild(del); | |
| } | |
| card.appendChild(head); | |
| /* fk-type extras (model_fk) rendered as a field-like row */ | |
| (def.extras||[]).filter(e=>e.type==="fk").forEach(e=>{ | |
| const g=document.createElement("div");g.className="field"; | |
| g.innerHTML=`<div class="f-head"><span class="f-id">${e.id}</span><span class="f-label">${esc(e.label)}</span></div>`; | |
| g.appendChild(fkBox(e.fk_target,rec.extra[e.id]||[],ids=>{rec.extra[e.id]=ids;saveExtra(rec)})); | |
| if(e.hint){const h=document.createElement("div");h.className="f-hint";h.textContent=e.hint;g.appendChild(h)} | |
| card.appendChild(g); | |
| }); | |
| /* groups */ | |
| const na=naSet(); | |
| const groups=def.groups||[{title:null,fields:def.fields}]; | |
| const su=def.simple_unless; | |
| const simple=su?!!(rec.extra[su.extra]&&rec.extra[su.extra]!==su.equals):!!(def.id==="model"&&rec.extra.role&&rec.extra.role!=="proposed"); /* datasets also have a role in v7 */ | |
| groups.forEach(gr=>{ | |
| if(!groupVisible(rec,gr))return; | |
| const g=document.createElement("div");g.className="group"; | |
| if(gr.title){ | |
| const t=document.createElement("div");t.className="group-title"; | |
| t.innerHTML=`<span class="tri">▾</span> ${esc(gr.title)}`; | |
| t.onclick=()=>g.classList.toggle("closed"); | |
| g.appendChild(t); | |
| if(simple&&!gr.always_open&&!/M1–M4|framework/i.test(gr.title))g.classList.add("closed"); | |
| } | |
| gr.fields.forEach(f=>g.appendChild(renderField(rec,f,na))); | |
| card.appendChild(g); | |
| }); | |
| if(simple){ | |
| const n=document.createElement("div");n.className="f-hint";n.style.padding="6px 12px"; | |
| n.textContent=su?"Baseline / comparison: header (incl. HP source) + M1 only.":"role ≠ proposed: abbreviated record suffices; other groups collapsed, expand as needed."; | |
| card.appendChild(n); | |
| } | |
| return card; | |
| } | |
| /* 1103 v7 display rules (all optional in the schema): | |
| trigger = flag extra must be on · only_if_extra / hide_if_extra = {extra: [values]} · show_if_field = {field id: [values]} */ | |
| function groupVisible(rec,gr){ | |
| if(gr.trigger&&!rec.extra[gr.trigger])return false; | |
| for(const [k,vals] of Object.entries(gr.only_if_extra||{}))if(!vals.includes(rec.extra[k]))return false; | |
| for(const [k,vals] of Object.entries(gr.hide_if_extra||{}))if(vals.includes(rec.extra[k]))return false; | |
| for(const [fid,vals] of Object.entries(gr.show_if_field||{}))if(!vals.includes(S.fields[rec.uid]?.[fid]?.value))return false; | |
| return true; | |
| } | |
| function fkBox(target,selected,onchange){ | |
| const targets=Array.isArray(target)?target:[target]; | |
| const box=document.createElement("div");box.className="fk-box"; | |
| let any=false; | |
| targets.forEach(tg=>{ | |
| const tdef=S.schema.record_types.find(r=>r.id===tg); | |
| recsOf(tg).forEach(r=>{ | |
| any=true; | |
| const lab=document.createElement("label"); | |
| const c=document.createElement("input");c.type="checkbox"; | |
| c.checked=selected.includes(r.uid); | |
| c.onchange=()=>{ | |
| const s=new Set(selected);c.checked?s.add(r.uid):s.delete(r.uid); | |
| selected=[...s];onchange(selected); | |
| }; | |
| lab.appendChild(c); | |
| lab.append(`[${tdef.label.split("·").pop().trim()}] ${recLabel(r,tdef)} #${r.uid}`); | |
| box.appendChild(lab); | |
| }); | |
| }); | |
| if(!any){box.innerHTML=`<span class="muted" style="font-size:12px">No target records yet — create a ${targets.join("/")} record first</span>`} | |
| return box; | |
| } | |
| function renderField(rec,f,na){ | |
| const uid=rec.uid; | |
| const pl=getPayload(uid,f.id); | |
| const el=document.createElement("div"); | |
| el.className="field";el.dataset.uid=uid;el.dataset.fid=f.id; | |
| const isNA=na.has(f.id.toUpperCase()); | |
| if(isNA)el.classList.add("na"); | |
| if(S.active&&S.active.uid===uid&&S.active.fid===f.id)el.classList.add("active"); | |
| const badges=[ | |
| f.novel?'<span class="badge b-novel">novel</span>':"", | |
| f.operational?'<span class="badge b-op">operational</span>':"", | |
| f.gold?'<span class="badge b-gold">gold priority</span>':"", | |
| f.verbatim?'<span class="badge b-verb">verbatim</span>':"", | |
| isNA?'<span class="badge b-na">L1 N/A</span>':"" | |
| ].join(""); | |
| const done=pl.status?`<span class="f-done">● ${pl.status}</span>`:""; | |
| el.innerHTML=`<div class="f-head"><span class="f-id">${f.id}</span><span class="f-label">${esc(f.label)}</span>${badges}${done}</div>` | |
| +(f.hint?`<div class="f-guide">${esc(f.hint)}${f.example?` <span class="eg">e.g. ${esc(f.example)}</span>`:""}${f.suggest&&f.example?` <span class="eg">vocabulary: ${esc(f.suggest.join(" · "))}</span>`:""}</div>`:""); | |
| el.querySelector(".f-head").onclick=()=>activate(uid,f.id); | |
| const body=document.createElement("div");body.className="f-body"; | |
| /* status */ | |
| const st=document.createElement("select"); | |
| st.innerHTML="<option value=''>status…</option>"+S.schema.attributes.status.map(s=>`<option value="${s.v}" ${pl.status===s.v?"selected":""}>${s.label}</option>`).join(""); | |
| st.onchange=()=>{pl.status=st.value;saveField(uid,f.id,pl,true)}; | |
| body.appendChild(st); | |
| /* granularity */ | |
| if(S.schema.attributes.granularity){ /* v7 has no granularity */ | |
| const gr=document.createElement("select"); | |
| gr.innerHTML="<option value=''>granularity…</option>"+S.schema.attributes.granularity.map(s=>`<option value="${s.v}" ${pl.granularity===s.v?"selected":""}>${s.label}</option>`).join(""); | |
| gr.onchange=()=>{pl.granularity=gr.value;saveField(uid,f.id,pl)}; | |
| body.appendChild(gr); | |
| } | |
| /* value */ | |
| body.appendChild(valueEditor(f,pl,uid)); | |
| /* defer_target */ | |
| if(pl.status==="absent_deferred"){ | |
| const d=document.createElement("input");d.type="text";d.className="full"; | |
| d.placeholder="defer_target: where it points (e.g. Appendix B.1). First confirm it is truly absent from the documents at hand."; | |
| d.value=pl.defer_target||""; | |
| d.oninput=()=>{pl.defer_target=d.value;saveField(uid,f.id,pl)}; | |
| body.appendChild(d); | |
| } | |
| /* evidence */ | |
| if(!f.no_evidence)body.appendChild(evidenceList(uid,f.id,pl)); | |
| /* note */ | |
| const note=document.createElement("input");note.type="text";note.className="full"; | |
| note.placeholder="Note: reason for uncertain, internal contradictions, ...";note.value=pl.note||""; | |
| note.style.fontSize="12px"; | |
| note.oninput=()=>{pl.note=note.value;saveField(uid,f.id,pl)}; | |
| body.appendChild(note); | |
| el.appendChild(body); | |
| el.addEventListener("mousedown",()=>activate(uid,f.id,el),true); | |
| return el; | |
| } | |
| function valueEditor(f,pl,uid){ | |
| if(f.type==="enum_field"){ | |
| const s=document.createElement("select");s.className="full"; | |
| s.innerHTML="<option value=''>value…</option>"+f.options.map(o=>`<option ${pl.value===o?"selected":""}>${o}</option>`).join(""); | |
| s.onchange=()=>{pl.value=s.value;saveField(uid,f.id,pl);if(f.rerender)renderRight()}; | |
| if(!f.with_text)return s; | |
| /* 1103 v7: choice + free text (D12 name, D15/D10 "other", R1 metric) */ | |
| const box=document.createElement("div");box.className="full"; | |
| const t=document.createElement("input");t.type="text";t.className="full";t.style.marginTop="4px"; | |
| t.placeholder=f.text_placeholder||"details";t.value=pl.value_text||""; | |
| t.oninput=()=>{pl.value_text=t.value;saveField(uid,f.id,pl)}; | |
| box.append(s,t);return box; | |
| } | |
| if(f.type==="axes"){ | |
| const box=document.createElement("div");box.className="full"; | |
| pl.axes=pl.axes||[]; | |
| const draw=()=>{ | |
| box.innerHTML=""; | |
| pl.axes.forEach((ax,i)=>{ | |
| const row=document.createElement("div");row.className="axes-row"; | |
| const a=document.createElement("input");a.type="text";a.placeholder="axis (patient/video/image…)";a.value=ax.axis||""; | |
| a.oninput=()=>{ax.axis=a.value;saveField(uid,f.id,pl)}; | |
| const s=document.createElement("select"); | |
| s.innerHTML=["disjoint","overlap","unknown"].map(o=>`<option ${ax.status===o?"selected":""}>${o}</option>`).join(""); | |
| s.onchange=()=>{ax.status=s.value;saveField(uid,f.id,pl)}; | |
| const d=document.createElement("button");d.textContent="×"; | |
| d.onclick=()=>{pl.axes.splice(i,1);saveField(uid,f.id,pl);draw()}; | |
| row.append(a,s,d);box.appendChild(row); | |
| }); | |
| const add=document.createElement("button");add.textContent="+ Add axis"; | |
| add.onclick=()=>{pl.axes.push({axis:"",status:"unknown"});draw()}; | |
| box.appendChild(add); | |
| }; | |
| draw();return box; | |
| } | |
| if(f.type==="fk"||f.type==="fk2"){ | |
| const box=document.createElement("div");box.className="full"; | |
| pl.fk=pl.fk||[]; | |
| box.appendChild(fkBox(f.type==="fk2"?f.fk_targets:f.fk_target,pl.fk,ids=>{pl.fk=ids;saveField(uid,f.id,pl)})); | |
| if(f.allow_no_anchor){ | |
| const lab=document.createElement("label");lab.style.cssText="font-size:12px;display:flex;gap:4px;margin-top:4px"; | |
| const c=document.createElement("input");c.type="checkbox";c.checked=!!pl.no_anchor; | |
| c.onchange=()=>{pl.no_anchor=c.checked;saveField(uid,f.id,pl)}; | |
| lab.append(c,"no_anchor (explicit: anchors to no reported result)"); | |
| box.appendChild(lab); | |
| } | |
| const v=document.createElement("textarea");v.placeholder=f.example?"e.g. "+f.example:"Describe in the paper's words";v.value=pl.value||""; | |
| v.style.marginTop="4px"; | |
| v.oninput=()=>{pl.value=v.value;saveField(uid,f.id,pl)}; | |
| box.appendChild(v); | |
| return box; | |
| } | |
| /* text (default) */ | |
| const t=document.createElement("textarea");t.className="full";t.value=pl.value||""; | |
| t.placeholder=f.example?"e.g. "+f.example:f.suggest?("e.g. "+f.suggest.join(" / ")):"What the paper says, in its own words"; | |
| t.oninput=()=>{pl.value=t.value;saveField(uid,f.id,pl)}; | |
| return t; | |
| } | |
| function evidenceList(uid,fid,pl){ | |
| const box=document.createElement("div");box.className="ev-list"; | |
| (pl.evidence||[]).forEach((ev,i)=>{ | |
| const row=document.createElement("div");row.className="ev"; | |
| const hasBox=!!(ev.pdf_anchor&&ev.pdf_anchor.boxes&&ev.pdf_anchor.boxes.length); | |
| const g=ev.grounded===true?'<span class="g-ok">✓</span>' | |
| :ev.grounded===false&&ev.origin==="pdf"&&hasBox?'<span class="g-pdf" title="Selected on the PDF but not found in the converted text (likely conversion damage). The PDF location is kept; consider ticking dmg.">◐</span>' | |
| :ev.grounded===false?'<span class="g-bad" title="Grounding mismatch: quote is not an exact substring of the fulltext; verify and fix">✗</span>':"…"; | |
| row.innerHTML=`${g}`; | |
| const q=document.createElement("span");q.className="q";q.textContent=ev.quote;q.title="Click to locate in the left pane"; | |
| q.onclick=()=>locateQuote(ev); | |
| row.appendChild(q); | |
| if(hasBox&&PV.doc){const t=document.createElement("span");t.className="pdf-tag";t.textContent="PDF p."+ev.pdf_anchor.boxes[0].page; | |
| t.title="Show on the PDF";t.onclick=()=>{if(S.view==="text")setView("split");setHl(ev.pdf_anchor.boxes,"ev")};row.appendChild(t)} | |
| const sec=document.createElement("select"); | |
| sec.innerHTML=S.schema.attributes.evidence_sections.map(s=>`<option ${ev.section===s?"selected":""}>${s}</option>`).join(""); | |
| sec.onchange=()=>{ev.section=sec.value;saveField(uid,fid,pl)}; | |
| row.appendChild(sec); | |
| S.schema.attributes.evidence_flags.forEach(fl=>{ | |
| const lab=document.createElement("label");lab.title=fl.label; | |
| const c=document.createElement("input");c.type="checkbox";c.checked=!!(ev.flags&&ev.flags[fl.v]); | |
| c.onchange=()=>{ev.flags=ev.flags||{};ev.flags[fl.v]=c.checked;saveField(uid,fid,pl)}; | |
| lab.append(c,fl.v.replace("pipeline_damage","dmg").replace("figure_only","fig")); | |
| row.appendChild(lab); | |
| }); | |
| const d=document.createElement("button");d.textContent="×"; | |
| d.onclick=()=>{pl.evidence.splice(i,1);saveField(uid,fid,pl,true)}; | |
| row.appendChild(d); | |
| box.appendChild(row); | |
| }); | |
| const add=document.createElement("button"); | |
| add.textContent="+ Figure/table evidence (no selection)"; | |
| add.style.cssText="font-size:11px;align-self:flex-start"; | |
| add.title="Use when the value only exists in a figure or damaged table: paste the caption verbatim (grounds if it matches) or a locator like Figure 3 left panel"; | |
| add.onclick=()=>{ | |
| const q=prompt("Figure/table evidence: paste the caption/table title verbatim if possible (it can then be grounded), otherwise a locator description, e.g. Figure 3 left panel"); | |
| if(!q)return; | |
| pl.evidence=pl.evidence||[]; | |
| pl.evidence.push({quote:q.replace(/\s+/g," ").trim(),section:"figure",flags:{figure_only:true},grounded:null,char_span:null}); | |
| if(!pl.status)pl.status=S.schema.attributes.status[0].v; | |
| saveField(uid,fid,pl,true); | |
| }; | |
| box.appendChild(add); | |
| return box; | |
| } | |
| const normQ=t=>t.replace(/\s+/g," ").trim().toLowerCase(); | |
| function locateQuote(ev){ | |
| if(S.layout){ | |
| const boxes=ev.pdf_anchor&&ev.pdf_anchor.boxes,ids=ev.pdf_anchor&&ev.pdf_anchor.blocks; | |
| if(S.view==="pdf"&&boxes&&boxes.length){setHl(boxes,"ev");return} | |
| if(S.view==="pdf"||S.view==="raw")setView("split"); | |
| const fq=normQ(ev.quote); | |
| let el=ids&&ids.length?$(`#textpane .tb[data-b="${ids[0]}"]`):null; | |
| if(!el)el=$$("#textpane .tb").find(b=>normQ(b.textContent).includes(fq))||null; | |
| if(el){ | |
| $$("#textpane .tb.cur").forEach(x=>x.classList.remove("cur"));el.classList.add("cur"); | |
| el.scrollIntoView({block:"center",behavior:"smooth"});el.classList.add("flash");setTimeout(()=>el.classList.remove("flash"),2500); | |
| } | |
| const bx=boxes&&boxes.length?boxes:el?S.layout.blocks[+el.dataset.b].boxes:null; | |
| if(bx)setHl(bx,"ev"); | |
| if(!el&&!bx)alert("Not found in the text or on the PDF. char_span (folded coords) = "+JSON.stringify(ev.char_span)); | |
| return; | |
| } | |
| if(S.view==="pdf")setView("text"); | |
| if(S.isMd){ | |
| const fq=ev.quote.replace(/\s+/g," ").trim().toLowerCase(); | |
| for(const b of $("#textpane").querySelectorAll("p,h2,h3,h4,h5,h6,tr")){ | |
| if(b.textContent.replace(/\s+/g," ").toLowerCase().includes(fq)){ | |
| b.scrollIntoView({block:"center",behavior:"smooth"}); | |
| b.classList.add("flash");setTimeout(()=>b.classList.remove("flash"),2500); | |
| return; | |
| } | |
| } | |
| setView("raw"); // 渲染视图找不到, 退到源文件视图按原逻辑高亮 | |
| } | |
| const raw=S.rawText, q=ev.quote; | |
| let i=raw.indexOf(q); | |
| if(i<0){ /* whitespace-insensitive fallback */ | |
| const pat=q.trim().split(/\s+/).map(w=>w.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("\\s+"); | |
| const m=raw.match(new RegExp(pat)); | |
| if(m){renderText(m.index,m.index+m[0].length);return} | |
| alert("Not found in the displayed text. char_span (folded coords) = "+JSON.stringify(ev.char_span));return; | |
| } | |
| renderText(i,i+q.length); | |
| } | |
| /* ---------------- save ---------------- */ | |
| function activate(uid,fid,el){ | |
| const prev=S.active; | |
| S.active={uid,fid}; | |
| if(!prev||prev.uid!==uid||prev.fid!==fid){ | |
| $$(".field.active").forEach(x=>x.classList.remove("active")); | |
| (el||document.querySelector(`.field[data-uid="${uid}"][data-fid="${fid}"]`))?.classList.add("active"); | |
| } | |
| } | |
| function saveExtra(rec){ | |
| api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"extra",uid:rec.uid,extra:rec.extra})}); | |
| } | |
| function saveField(uid,fid,pl,rerender){ | |
| const key=uid+"."+fid; | |
| clearTimeout(S.saveTimers[key]); | |
| S.pendingSaves=S.pendingSaves||{}; | |
| const run=S.pendingSaves[key]=async()=>{ | |
| delete S.saveTimers[key];delete S.pendingSaves[key]; | |
| const res=await api("/api/field",{method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({paper:S.paper,record_uid:uid,field_id:fid,payload:pl})}); | |
| S.fields[uid][fid]=res.payload; | |
| updateProgress(); | |
| if(rerender)renderRight(); | |
| pollSync(); | |
| }; | |
| S.saveTimers[key]=setTimeout(run,rerender?0:600); | |
| const chip=$("#h-sync");chip.classList.remove("ok","err");chip.classList.add("pending");chip.textContent="Saving…"; | |
| } | |
| function updateProgress(){ | |
| let n=0; | |
| Object.values(S.fields).forEach(m=>Object.values(m).forEach(p=>{ | |
| if(isFilled(p))n++; | |
| })); | |
| $("#h-progress").textContent=n+" fields annotated"; | |
| } | |
| </script> | |
| </body> | |
| </html> | |