New approach to extracting publication data and filtering/searching it #17

Merged
jsheunis merged 1 commit from pubs into main 2026-04-13 08:55:14 +00:00
7 changed files with 253 additions and 77 deletions

View file

@ -16,19 +16,11 @@ jobs:
uses: actions/checkout@v4
- name: Prepare environment
uses: ./.forgejo/actions/prep-metadata-query
- name: Install jq
run: |
apt-get update
apt-get install -y jq
- name: Update pages
- name: Update publication pages
run: |
dtc get-records ${{ env.DUMPTHINGS_APIURL }} public -C XYZPublication \
| qri inline-records -p about -p attributed_to -c public \
| jq -sc '{pid: "xyzri:this-is-not-important", publications: .}' \
| qri render-record page_templates/publications.json.j2 'data/publications.json'
| qri render-record page_templates/publication.md.j2 \
'content/{__pid_curie_reference}/_index.md'
- name: Deposit changes
run: |
git add data
git diff --quiet --cached \
&& echo "Already up-to-date" \
|| ( git commit -m "chore: auto-generate content from metadata" && git push origin )
uses: ./.forgejo/actions/deposit-changes

View file

@ -1,3 +0,0 @@
---
title: Publications
---

View file

@ -0,0 +1,67 @@
<article
class="pub mb-6 border border-neutral-200 dark:border-neutral-700"
data-kind="{{ .Params.kind }}"
data-year="{{ if .Params.date }}{{ substr .Params.date 0 4 }}{{ else }}unknown{{ end }}"
data-topics='{{ .Params.topic | jsonify }}'
data-authors='{{ .Params.author | jsonify }}'
data-title="{{ .Title | lower }}"
style="border-radius: 5px; padding: 1em;"
>
<!-- Title, including year and DOI and publication kind -->
<h3 class="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style="margin-top: 0;">
<a href="{{ .RelPermalink }}" style="text-decoration: unset;">
{{ .Title }}
</a>
{{ if .Params.date }}
{{ $year := substr .Params.date 0 4 }}
<span class="text-neutral-500 text-base font-normal">
({{ $year }})
</span>
{{ end }}
{{ with .Params.doi }}
<span class="mt-3 text-sm" style="border-left: 1px solid grey; padding-left: 0.5em;">
<a
href="https://doi.org/{{ . }}"
target="_blank"
class="text-primary-600"
style="text-decoration: unset;"
>
DOI: {{ . }}
</a>
</span>
{{ end }}
{{ with .Params.kind }}
<span class="mt-3 text-xs" style="font-style: italic; border-left: 1px solid grey; padding-left: 0.5em; margin-left: 0.5em;">
{{ . | replaceRE "^.*:" "" }}
</span>
{{ end }}
</h3>
<!-- Authors -->
{{ with .Params.author }}
<div class="mt-2 flex flex-wrap gap-2">
{{ range $i, $a := . }}
<span class="text-sm px-2 py-1 rounded-full border border-neutral-200 dark:border-neutral-700 whitespace-nowrap">
{{ $a.given_name }} {{ $a.family_name }}
</span>
{{ end }}
</div>
{{ end }}
<!-- Topics -->
{{ with .Params.topic }}
<div class="flex flex-wrap gap-2" style="margin-top: 1em;">
{{ range . }}
<span
class="topic-chip text-xs bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 cursor-pointer transition-colors duration-150 hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-700 dark:hover:text-primary-300"
style="border-radius: 4px; padding: 0.5em"
data-topic="{{ .display_label }}"
>
{{ .display_label }}
</span>&nbsp;
{{ end }}
</div>
{{ end }}
</article>

View file

@ -9,6 +9,9 @@
<div class="pub-layout">
<!-- Sidebar -->
<aside class="filters-panel">
<button onclick="clearAllFilters()" class="clear-button mt-2 text-sm text-primary-600 hover:underline">
Clear all
</button>
<div class="filter-block">
<strong>Kind</strong>
<div id="filter-kind" class="filter-group"></div>
@ -26,14 +29,17 @@
</aside>
<!-- Main rendered content -->
<div class="results-panel">
<input type="text" id="search" placeholder="Search publications..." class="search-bar" />
<div id="results"></div>
<input type="text" id="search" placeholder="Search publications..." class="search-bar" /> <span id="pub-count"></span>
<!-- Data -->
{{ $pubPages := where .Site.Pages "Section" "publications" }}
{{ $pubPagesTerms := where $pubPages "Kind" "term" }}
<div id="results">
{{ range $i, $p := $pubPagesTerms }}
{{ partial "publication-item.html" $p }}
{{ end }}
</div>
</div>
</div>
<!-- Data -->
<script id="pub-data" type="application/json">
{{ site.Data.publications }}
</script>
<!-- JS + CSS -->
<script src="/publications.js"></script>
<link rel="stylesheet" href="/publications.css">

View file

@ -0,0 +1,59 @@
{% macro taxonomy_terms(taxonomy, terms_list) -%}
{% if terms_list -%}
{{ taxonomy }}:
{% for el in terms_list -%}
{% if el.pid -%}
- {{ el.pid.split('/')[-1] }}
{% endif -%}
{% endfor -%}
{% endif -%}
{%- endmacro -%}
{%- set doi = (
__rec.identifiers
| selectattr('schema_type', 'equalto', 'dlthings:DOI')
| list
| first
) if __rec.identifiers is defined and __rec.identifiers is sequence else none -%}
{%- set generation = (
__rec.generated_by
| selectattr('object', 'equalto', 'obo:IAO_0000444')
| list
| first
) if __rec.generated_by is defined and __rec.generated_by is sequence else none -%}
{%- set authors = [] -%}
{%- for auth in __rec.attributed_to -%}
{%- set _ = authors.append({
"pid": auth.object.pid | default(none),
"given_name": auth.object.given_name | default(none),
"family_name": auth.object.family_name | default(none)
}) -%}
{%- endfor -%}
{%- set topics = [] -%}
{%- for top in __rec.about -%}
{%- set _ = topics.append({
"pid": top.pid | default(none),
"display_label": top.display_label | default(none)
}) -%}
{%- endfor -%}
{%- set publication = {
"pid": __rec.pid | default(none),
"doi": doi.notation | default(none) if doi else none,
"date": generation.at_time | default(none) if generation else none,
"title": __rec.title | default(none),
"kind": __rec.kind | default(none),
"author": authors | default(none),
"topic": topics | default(none)
} -%}
---
title: {{ publication.title | toyaml | replace('...\n','') | trim }}
{{ taxonomy_terms('persons', authors) -}}
{{ taxonomy_terms('topics', topics) -}}
params:
graphRootNodePID: {{ pid }}
{{ publication | toyaml | indent(2) | trim }}
---

View file

@ -18,6 +18,12 @@
max-height: calc(100vh - 4rem);
overflow-y: auto;
scroll-behavior: smooth;
display: flex;
flex-direction: column;
}
.clear-button {
margin-left: auto;
}
.results-panel {
@ -49,4 +55,11 @@
color: #292524;
padding-left: 0.5em;
border-radius: 3px;
margin-bottom: 1em;
border: 1px solid grey;
}
.topic-chip.active {
background-color: rgb(59 130 246 / 0.15);
color: rgb(59 130 246);
}

View file

@ -1,13 +1,5 @@
// Load data
const rawData = JSON.parse(document.getElementById("pub-data").textContent);
// Normalize data
const publications = rawData.map(p => ({
...p,
year: p.date ? new Date(p.date).getFullYear() : 'unknown',
topics: (p.topic || []).map(t => t.display_label),
authors: (p.author || []).map(a => `${a.given_name} ${a.family_name}`)
}));
// Load data from all pre-rendered publication item divs
const publications = Array.from(document.querySelectorAll(".pub"));
// State
let state = {
@ -20,15 +12,13 @@ let state = {
// Build filter options dynamically
function getUniqueValues(field) {
const values = new Set();
publications.forEach(p => {
if (Array.isArray(p[field])) {
p[field].forEach(v => values.add(v));
} else if (p[field]) {
values.add(p[field]);
publications.forEach(el => {
if (field === "topic") {
getTopics(el).forEach(v => values.add(v));
} else {
values.add(el.dataset[field]);
}
});
return Array.from(values).sort();
}
@ -42,7 +32,6 @@ function formatValue(field, value) {
return value
}
// Render checkboxes for filters
function renderFilter(containerId, field, values) {
const container = document.getElementById(containerId);
values.forEach(value => {
@ -52,6 +41,8 @@ function renderFilter(containerId, field, values) {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = value;
checkbox.dataset.field = field;
checkbox.id = id;
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
state[field].add(value);
@ -67,67 +58,118 @@ function renderFilter(containerId, field, values) {
}
// Filtering logic
function matchesFilters(p) {
// Kind
if (state.kind.size && !state.kind.has(p.kind)) return false;
// Year
if (state.year.size && !state.year.has(String(p.year))) return false;
// Topic (multi-value)
function matchesFilters(el) {
const kind = el.dataset.kind;
const year = el.dataset.year;
const topics = getTopics(el);
// kind filter
if (state.kind.size && !state.kind.has(kind)) {
return false;
}
// year filter
if (state.year.size && !state.year.has(year)) {
return false;
}
// topic filter (multi-match)
if (state.topic.size) {
const match = p.topics.some(t => state.topic.has(t));
const match = topics.some(t => state.topic.has(t));
if (!match) return false;
}
return true;
}
// Search
function matchesSearch(p) {
// Helper to get topics from an element
function getTopics(el) {
try {
return JSON.parse(el.dataset.topics || "[]").map(t => t.display_label);
} catch {
return [];
}
}
// Search logic
function matchesSearch(el) {
if (!state.search) return true;
const text = (
p.title +
" " +
p.kind +
" " +
p.topics.join(" ") +
" " +
p.authors.join(" ")
el.dataset.title + " " +
el.dataset.kind + " " +
el.dataset.year + " " +
el.dataset.topics + " " +
el.dataset.authors
).toLowerCase();
return text.includes(state.search);
}
// Render results
// Change display of divs based on filtering/searching
function render() {
const container = document.getElementById("results");
container.innerHTML = "";
const filtered = publications.filter(p =>
matchesFilters(p) && matchesSearch(p)
);
if (filtered.length === 0) {
container.innerHTML = "<p>No results</p>";
return;
}
filtered.forEach(p => {
const div = document.createElement("div");
div.className = "pub";
div.innerHTML = `
<h3><em>${p.title} ${p.year ? ' ('+p.year+')' : ''}</em></h3>
<p><strong>Authors:</strong> ${p.authors.join(", ")}</p>
<p><strong>Kind:</strong> ${formatValue("kind", p.kind)}</p>
<p><strong>Topics:</strong> ${p.topics.join(", ")}</p>
`;
container.appendChild(div);
let count = 0;
publications.forEach(el => {
const visible =
matchesFilters(el) &&
matchesSearch(el);
if (visible) count+=1;
renderCount(count)
el.style.display = visible ? "" : "none";
});
}
// Count of searched+filtered publications
function renderCount(count) {
const countEl = document.getElementById('pub-count');
countEl.innerHTML = `${count}` ;
}
// Set checkbox if user clicks on topic pill
function selectFilter(field, value) {
const checkbox = document.querySelector(
`input[type="checkbox"][data-field="${field}"][value="${CSS.escape(value)}"]`
);
if (!checkbox) return;
if (!checkbox.checked) {
checkbox.checked = true;
state[field].add(value);
render();
}
}
// Clear all
function clearAllFilters() {
// Reset state
state.kind.clear();
state.topic.clear();
state.year.clear();
state.search = "";
// Uncheck all checkboxes
document.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
// Clear search input
const searchInput = document.getElementById("search");
if (searchInput) {
searchInput.value = "";
}
// Re-render results
render();
}
// On startup:
// Build filters
// 1) Build filters
renderFilter("filter-kind", "kind", getUniqueValues("kind"));
renderFilter("filter-topic", "topic", getUniqueValues("topics"));
renderFilter("filter-topic", "topic", getUniqueValues("topic"));
renderFilter("filter-year", "year", getUniqueValues("year").map(String));
// Search input
// 2) Register search input
document.getElementById("search").addEventListener("input", e => {
state.search = e.target.value.toLowerCase();
render();
});
// Initial render
// 3) Add topic click handlers
document.querySelectorAll(".topic-chip").forEach(el => {
el.addEventListener("click", () => {
const topic = el.dataset.topic;
selectFilter("topic", topic);
});
});
// 4) Initial render
render();