PageSourceSearch

https://matteofilosa.github.io/assets/js/pubs.js

js matteofilosa.github.io collected 2026-10-03 09:34:58 UTC 13,296 bytes, 311 lines download raw bytes

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.