1/* ============================================================= 2 pubs.js â publication dashboard, filters, BibTeX, ORCID sync 3 ============================================================= */ 4 5(function () { 6 'use strict'; 7 8 const { $, $$, esc, copy, toast, chipsHTML, registerCommands, revealScan } = window.MF; 9 10 /* Live list = curated PUBLICATIONS + anything ORCID knows and we don't. */ 11 let items = PUBLICATIONS.slice(); 12 13 const state = { q: '', type: 'all', topic: 'all', year: 'all' }; 14 15 const TYPE_LABEL = { 16 journal: 'Journal article', 17 conference: 'Conference paper', 18 workshop: 'Workshop paper', 19 chapter: 'Book chapter', 20 preprint: 'Preprint', 21 other: 'Publication' 22 }; 23 24 /* ---------- BibTeX ---------------------------------------------------- */ 25 function bibKey(p) { 26 const first = (p.authors && p.authors[0]) || 'Filosa'; 27 const last = first.trim().split(/\s+/).pop().toLowerCase().replace(/[^a-z]/g, ''); 28 const word = (p.title.match(/[A-Za-z]{4,}/) || ['paper'])[0].toLowerCase(); 29 return `${last}${p.year}${word}`; 30 } 31 32 function bibtex(p) { 33 const kind = p.type === 'journal' ? 'article' 34 : (p.type === 'conference' || p.type === 'workshop') ? 'inproceedings' 35 : p.type === 'chapter' ? 'incollection' : 'misc'; 36 const f = []; 37 f.push(['title', `{${p.title}}`]); 38 if (p.authors && p.authors.length) f.push(['author', p.authors.join(' and ')]); 39 if (kind === 'article') f.push(['journal', p.venue]); 40 else if (kind === 'inproceedings') f.push(['booktitle', p.venue]); 41 else if (kind === 'incollection') f.push(['booktitle', p.venue]); 42 else if (p.venue) f.push(['howpublished', p.venue]); 43 if (p.volume) f.push(['volume', p.volume]); 44 if (p.pages) f.push(['pages', p.pages.replace(/â/g, '--')]); 45 f.push(['year', p.year]); 46 if (p.publisher) f.push(['publisher', p.publisher]); 47 if (p.doi) f.push(['doi', p.doi]); 48 else if (p.url) f.push(['url', p.url]); 49 const pad = Math.max.apply(null, f.map(x => x[0].length)); 50 const body = f.map(([k, v]) => ` ${k.padEnd(pad)} = {${v}}`).join(',\n'); 51 return `@${kind}{${bibKey(p)},\n${body}\n}`; 52 } 53 54 window.MF.allBibtex = () => 55 items.slice().sort((a, b) => b.year - a.year).map(bibtex).join('\n\n'); 56 57 /* ---------- dashboard ------------------------------------------------- */ 58 function renderDash() { 59 const years = {}; 60 items.forEach(p => { years[p.year] = (years[p.year] || 0) + 1; }); 61 const ys = Object.keys(years).map(Number).sort((a, b) => a - b); 62 const max = Math.max.apply(null, Object.values(years)); 63 64 const bars = ys.map(y => { 65 const pct = Math.round((years[y] / max) * 100); 66 const on = state.year === String(y); 67 return `<button type="button" data-year="${y}" aria-pressed="${on}" 68 title="${years[y]} in ${y}" aria-label="${years[y]} publications in ${y}"> 69 <span class="bar" style="height:${pct}%"></span> 70 <span class="yl">${String(y).slice(2)}</span> 71 </button>`; 72 }).join(''); 73 74 const byType = {}; 75 items.forEach(p => { byType[p.type] = (byType[p.type] || 0) + 1; }); 76 const typeSummary = Object.entries(byType) 77 .sort((a, b) => b[1] - a[1]) 78 .map(([t, n]) => `${n} ${(TYPE_LABEL[t] || t).toLowerCase()}${n > 1 ? 's' : ''}`) 79 .join(' · '); 80 81 const venues = new Set(items.map(p => p.venueShort || p.venue)); 82 const span = ys.length ? `${ys[0]}â${ys[ys.length - 1]}` : 'â'; 83 84 $('#pub-dash').innerHTML = ` 85 <div><span class="k">Total</span><p class="v">${items.length}</p></div> 86 <div><span class="k">Per year â click to filter</span><div class="year-bars">${bars}</div></div> 87 <div><span class="k">Distinct venues</span><p class="v">${venues.size} <small>${esc(span)}</small></p></div> 88 <div><span class="k">Breakdown</span><p style="font-size:.85rem;color:var(--text-dim)">${esc(typeSummary)}</p></div>`; 89 90 $$('#pub-dash [data-year]').forEach(b => b.addEventListener('click', () => { 91 state.year = state.year === b.dataset.year ? 'all' : b.dataset.year; 92 renderDash(); apply(); 93 })); 94 } 95 96 /* ---------- filter pills ---------------------------------------------- */ 97 function renderFilters() { 98 const types = Array.from(new Set(items.map(p => p.type))); 99 const topics = Array.from(new Set(items.flatMap(p => p.topics || []))); 100 101 const pill = (key, val, label) => 102 `<button type="button" class="pill" data-k="${key}" data-v="${esc(val)}" 103 aria-pressed="${state[key] === val}">${esc(label)}</button>`; 104 105 let html = pill('type', 'all', 'All types');
106 types.forEach(t => { html += pill('type', t, TYPE_LABEL[t] || t); }); 107 html += '<span style="width:.5rem"></span>'; 108 html += pill('topic', 'all', 'All topics'); 109 topics.forEach(t => { 110 const tp = MF.topicMap[t]; 111 if (tp) html += pill('topic', t, tp.label); 112 }); 113 $('#pub-filters').innerHTML = html; 114 115 $$('#pub-filters .pill').forEach(b => b.addEventListener('click', () => { 116 state[b.dataset.k] = b.dataset.v; 117 renderFilters(); apply(); 118 })); 119 } 120 121 /* ---------- list ------------------------------------------------------ */ 122 function authorsHTML(p) { 123 if (!p.authors || !p.authors.length) return ''; 124 return p.authors.map(a => 125 a === SITE.name ? `<span class="me">${esc(a)}</span>` : esc(a)).join(', '); 126 } 127 128 function renderList() { 129 $('#pub-list').innerHTML = items 130 .slice() 131 .sort((a, b) => b.year - a.year || a.title.localeCompare(b.title)) 132 .map(p => { 133 const links = []; 134 if (p.url || p.doi) links.push(`<a href="${esc(p.url || 'https://doi.org/' + p.doi)}" target="_blank" rel="noopener">${p.doi ? 'DOI' : 'Link'} â</a>`); 135 if (p.pdf) links.push(`<a href="${esc(p.pdf)}" target="_blank" rel="noopener">PDF â</a>`); 136 if (p.code) links.push(`<a href="${esc(p.code)}" target="_blank" rel="noopener">Code â</a>`); 137 if (p.video) links.push(`<a href="${esc(p.video)}" target="_blank" rel="noopener">Video â</a>`); 138 if (p.slides) links.push(`<a href="${esc(p.slides)}" target="_blank" rel="noopener">Slides â</a>`); 139 links.push(`<button type="button" data-bib="${esc(p.id)}">BibTeX</button>`); 140 141 const meta = [p.venue, p.volume ? `vol. ${p.volume}` : '', p.pages ? `pp. ${p.pages}` : ''] 142 .filter(Boolean); 143 144 return ` 145 <article class="pub" id="pub-${esc(p.id)}" 146 data-type="${esc(p.type)}" data-year="${esc(p.year)}" 147 data-topics="${esc((p.topics || []).join(' '))}" 148 data-search="${esc([p.title, (p.authors || []).join(' '), p.venue, p.venueShort].join(' ').toLowerCase())}"> 149 <div class="pub-year">${esc(p.year)}<span class="type">${esc(p.venueShort || TYPE_LABEL[p.type] || p.type)}</span></div> 150 <div> 151 <h3><a href="${esc(p.url || 'https://doi.org/' + p.doi)}" target="_blank" rel="noopener">${esc(p.title)}</a>${p.fromOrcid ? '<span class="badge-new">new</span>' : ''}${p.award ? `<span class="badge-new">${esc(p.award)}</span>` : ''}</h3> 152 ${p.authors && p.authors.length ? `<p class="authors">${authorsHTML(p)}</p>` : ''} 153 <p class="venue">${meta.map((m, i) => i === 0 ? `<b>${esc(m)}</b>` : esc(m)).join(', ')}</p> 154 <div class="pub-links">${links.join('')}</div> 155 <div class="bib" id="bib-${esc(p.id)}" hidden> 156 <pre>${esc(bibtex(p))}</pre> 157 <div class="bib-bar"><button type="button" class="pill" data-bibcopy="${esc(p.id)}">Copy</button></div> 158 </div> 159 ${chipsHTML(p.topics)} 160 </div> 161 </article>`; 162 }).join(''); 163 164 $$('#pub-list [data-bib]').forEach(b => b.addEventListener('click', () => { 165 const box = $('#bib-' + b.dataset.bib); 166 box.hidden = !box.hidden; 167 b.textContent = box.hidden ? 'BibTeX' : 'Hide BibTeX'; 168 })); 169 $$('#pub-list [data-bibcopy]').forEach(b => b.addEventListener('click', () => { 170 const p = items.find(x => x.id === b.dataset.bibcopy); 171 copy(bibtex(p), 'BibTeX copied'); 172 })); 173 174 revealScan($('#pub-list')); 175 } 176 177 /* ---------- apply filters --------------------------------------------- */ 178 function apply() { 179 let shown = 0; 180 $$('#pub-list .pub').forEach(el => { 181 const okType = state.type === 'all' || el.dataset.type === state.type; 182 const okYear = state.year === 'all' || el.dataset.year === state.year; 183 const okTopic = state.topic === 'all' || el.dataset.topics.split(' ').includes(state.topic); 184 const okQ = !state.q || el.dataset.search.includes(state.q); 185 const ok = okType && okYear && okTopic && okQ; 186 el.classList.toggle('dim', !ok); 187 if (ok) shown++; 188 }); 189 $('#pub-empty').hidden = shown > 0; 190 } 191 192 /* ---------- ORCID sync ------------------------------------------------ */ 193 const CACHE_KEY = 'mf-orcid-cache-v1'; 194 const CACHE_TTL = 12 * 60 * 60 * 1000; 195 196 function normDoi(d) { return String(d || '').toLowerCase().replace(/^https?:\/\/(dx\.)?doi\.org\//, '').trim(); } 197 198 function orcidToEntry(s) { 199 const doi = (((s['external-ids'] || {})['external-id'] || []) 200 .find(x => x['external-id-type'] === 'doi') || {})['external-id-value']; 201 const type = s.type === 'journal-article' ? 'journal' 202 : s.type === 'conference-paper' ? 'conference' 203 : s.type === 'book-chapter' ? 'chapter' : 'other'; 204 return { 205 id: 'orcid-' + (s['put-code'] || Math.random().toString(36).slice(2)), 206 title: s.title.title.value, 207 authors: [], 208 venue: (s['journal-title'] || {}).value || 'See ORCID record', 209 venueShort: (s['journal-title'] || {}).value || 'ORCID', 210 year: parseInt(((s['publication-date'] || {}).year || {}).value, 10) || new Date().getFullYear(), 211 type, 212 doi: doi || '', 213 url: (s.url || {}).value || (doi ? 'https://doi.org/' + doi : 'https://orcid.org/' + SITE.orcidId), 214 topics: [], 215 fromOrcid: true 216 }; 217 } 218 219 function mergeOrcid(groups) {
220 const known = new Set(items.map(p => normDoi(p.doi)).filter(Boolean)); 221 const knownTitles = new Set(items.map(p => p.title.toLowerCase().replace(/[^a-z0-9]/g, ''))); 222 let added = 0; 223 224 (groups || []).forEach(g => { 225 const s = (g['work-summary'] || [])[0]; 226 if (!s) return; 227 const e = orcidToEntry(s); 228 const t = e.title.toLowerCase().replace(/[^a-z0-9]/g, ''); 229 if ((e.doi && known.has(normDoi(e.doi))) || knownTitles.has(t)) return; 230 items.push(e); added++; 231 }); 232 233 if (added) { 234 renderDash(); renderFilters(); renderList(); apply(); 235 $('#pub-sync-note').innerHTML = 236 `<strong style="color:var(--green)">${added} new item${added > 1 ? 's' : ''} found on ORCID</strong>`; 237 } else { 238 $('#pub-sync-note').textContent = 'Up to date with ORCID'; 239 } 240 } 241 242 function syncOrcid() { 243 let cached = null; 244 try { 245 const raw = localStorage.getItem(CACHE_KEY); 246 if (raw) { 247 const o = JSON.parse(raw); 248 if (Date.now() - o.t < CACHE_TTL) cached = o.d; 249 } 250 } catch (e) { } 251 252 if (cached) { mergeOrcid(cached); return; } 253 254 fetch(`https://pub.orcid.org/v3.0/${SITE.orcidId}/works`, { headers: { Accept: 'application/json' } }) 255 .then(r => r.ok ? r.json() : Promise.reject(new Error('ORCID ' + r.status))) 256 .then(d => { 257 try { localStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), d: d.group })); } catch (e) { } 258 mergeOrcid(d.group); 259 }) 260 .catch(() => { 261 $('#pub-sync-note').textContent = 'Curated list â ORCID unreachable'; 262 }); 263 } 264 265 /* ---------- public API for the graph ---------------------------------- */ 266 window.MF.filterByTopic = function (topicId) { 267 state.topic = topicId; state.type = 'all'; state.year = 'all'; state.q = ''; 268 $('#pub-search').value = ''; 269 renderDash(); renderFilters(); apply(); 270 $('#publications').scrollIntoView({ behavior: MF.reduceMotion ? 'auto' : 'smooth' }); 271 const t = MF.topicMap[topicId]; 272 if (t) toast(`Filtered by â${t.label}â`); 273 }; 274 275 window.MF.focusPub = function (id) { 276 state.topic = 'all'; state.type = 'all'; state.year = 'all'; state.q = ''; 277 $('#pub-search').value = ''; 278 renderFilters(); renderDash(); apply(); 279 const el = $('#pub-' + id); 280 if (!el) return; 281 el.scrollIntoView({ behavior: MF.reduceMotion ? 'auto' : 'smooth', block: 'center' }); 282 el.animate( 283 [{ backgroundColor: 'transparent' }, { backgroundColor: MF.cssVar('--accent-soft') }, { backgroundColor: 'transparent' }], 284 { duration: 1600, easing: 'ease-out' } 285 ); 286 }; 287 288 /* ---------- boot ------------------------------------------------------- */ 289 document.addEventListener('DOMContentLoaded', () => { 290 renderDash(); 291 renderFilters(); 292 renderList(); 293 apply(); 294 295 let t; 296 $('#pub-search').addEventListener('input', (e) => { 297 clearTimeout(t); 298 t = setTimeout(() => { state.q = e.target.value.trim().toLowerCase(); apply(); }, 120); 299 }); 300 301 registerCommands(items.map(p => ({ 302 group: 'Publications', 303 title: p.title, 304 icon: String(p.year).slice(2), 305 hint: p.venueShort || p.venue, 306 run: () => MF.focusPub(p.id) 307 }))); 308 309 syncOrcid(); 310 }); 311})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.