New approach to extracting publication data and filtering/searching it #17
7 changed files with 253 additions and 77 deletions
|
|
@ -16,19 +16,11 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Prepare environment
|
- name: Prepare environment
|
||||||
uses: ./.forgejo/actions/prep-metadata-query
|
uses: ./.forgejo/actions/prep-metadata-query
|
||||||
- name: Install jq
|
- name: Update publication pages
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y jq
|
|
||||||
- name: Update pages
|
|
||||||
run: |
|
run: |
|
||||||
dtc get-records ${{ env.DUMPTHINGS_APIURL }} public -C XYZPublication \
|
dtc get-records ${{ env.DUMPTHINGS_APIURL }} public -C XYZPublication \
|
||||||
| qri inline-records -p about -p attributed_to -c public \
|
| qri inline-records -p about -p attributed_to -c public \
|
||||||
| jq -sc '{pid: "xyzri:this-is-not-important", publications: .}' \
|
| qri render-record page_templates/publication.md.j2 \
|
||||||
| qri render-record page_templates/publications.json.j2 'data/publications.json'
|
'content/{__pid_curie_reference}/_index.md'
|
||||||
- name: Deposit changes
|
- name: Deposit changes
|
||||||
run: |
|
uses: ./.forgejo/actions/deposit-changes
|
||||||
git add data
|
|
||||||
git diff --quiet --cached \
|
|
||||||
&& echo "Already up-to-date" \
|
|
||||||
|| ( git commit -m "chore: auto-generate content from metadata" && git push origin )
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
---
|
|
||||||
title: Publications
|
|
||||||
---
|
|
||||||
67
layouts/_partials/publication-item.html
Normal file
67
layouts/_partials/publication-item.html
Normal 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>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
<div class="pub-layout">
|
<div class="pub-layout">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="filters-panel">
|
<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">
|
<div class="filter-block">
|
||||||
<strong>Kind</strong>
|
<strong>Kind</strong>
|
||||||
<div id="filter-kind" class="filter-group"></div>
|
<div id="filter-kind" class="filter-group"></div>
|
||||||
|
|
@ -26,14 +29,17 @@
|
||||||
</aside>
|
</aside>
|
||||||
<!-- Main rendered content -->
|
<!-- Main rendered content -->
|
||||||
<div class="results-panel">
|
<div class="results-panel">
|
||||||
<input type="text" id="search" placeholder="Search publications..." class="search-bar" />
|
<input type="text" id="search" placeholder="Search publications..." class="search-bar" /> <span id="pub-count"></span>
|
||||||
<div id="results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Data -->
|
<!-- Data -->
|
||||||
<script id="pub-data" type="application/json">
|
{{ $pubPages := where .Site.Pages "Section" "publications" }}
|
||||||
{{ site.Data.publications }}
|
{{ $pubPagesTerms := where $pubPages "Kind" "term" }}
|
||||||
</script>
|
<div id="results">
|
||||||
|
{{ range $i, $p := $pubPagesTerms }}
|
||||||
|
{{ partial "publication-item.html" $p }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- JS + CSS -->
|
<!-- JS + CSS -->
|
||||||
<script src="/publications.js"></script>
|
<script src="/publications.js"></script>
|
||||||
<link rel="stylesheet" href="/publications.css">
|
<link rel="stylesheet" href="/publications.css">
|
||||||
59
page_templates/publication.md.j2
Normal file
59
page_templates/publication.md.j2
Normal 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 }}
|
||||||
|
---
|
||||||
|
|
@ -18,6 +18,12 @@
|
||||||
max-height: calc(100vh - 4rem);
|
max-height: calc(100vh - 4rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel {
|
.results-panel {
|
||||||
|
|
@ -49,4 +55,11 @@
|
||||||
color: #292524;
|
color: #292524;
|
||||||
padding-left: 0.5em;
|
padding-left: 0.5em;
|
||||||
border-radius: 3px;
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
// Load data
|
// Load data from all pre-rendered publication item divs
|
||||||
const rawData = JSON.parse(document.getElementById("pub-data").textContent);
|
const publications = Array.from(document.querySelectorAll(".pub"));
|
||||||
|
|
||||||
// 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}`)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let state = {
|
let state = {
|
||||||
|
|
@ -20,15 +12,13 @@ let state = {
|
||||||
// Build filter options dynamically
|
// Build filter options dynamically
|
||||||
function getUniqueValues(field) {
|
function getUniqueValues(field) {
|
||||||
const values = new Set();
|
const values = new Set();
|
||||||
|
publications.forEach(el => {
|
||||||
publications.forEach(p => {
|
if (field === "topic") {
|
||||||
if (Array.isArray(p[field])) {
|
getTopics(el).forEach(v => values.add(v));
|
||||||
p[field].forEach(v => values.add(v));
|
} else {
|
||||||
} else if (p[field]) {
|
values.add(el.dataset[field]);
|
||||||
values.add(p[field]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(values).sort();
|
return Array.from(values).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +32,6 @@ function formatValue(field, value) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render checkboxes for filters
|
|
||||||
function renderFilter(containerId, field, values) {
|
function renderFilter(containerId, field, values) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
values.forEach(value => {
|
values.forEach(value => {
|
||||||
|
|
@ -52,6 +41,8 @@ function renderFilter(containerId, field, values) {
|
||||||
const checkbox = document.createElement("input");
|
const checkbox = document.createElement("input");
|
||||||
checkbox.type = "checkbox";
|
checkbox.type = "checkbox";
|
||||||
checkbox.value = value;
|
checkbox.value = value;
|
||||||
|
checkbox.dataset.field = field;
|
||||||
|
checkbox.id = id;
|
||||||
checkbox.addEventListener("change", () => {
|
checkbox.addEventListener("change", () => {
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
state[field].add(value);
|
state[field].add(value);
|
||||||
|
|
@ -67,67 +58,118 @@ function renderFilter(containerId, field, values) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtering logic
|
// Filtering logic
|
||||||
function matchesFilters(p) {
|
function matchesFilters(el) {
|
||||||
// Kind
|
const kind = el.dataset.kind;
|
||||||
if (state.kind.size && !state.kind.has(p.kind)) return false;
|
const year = el.dataset.year;
|
||||||
// Year
|
const topics = getTopics(el);
|
||||||
if (state.year.size && !state.year.has(String(p.year))) return false;
|
|
||||||
// Topic (multi-value)
|
// 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) {
|
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;
|
if (!match) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search
|
// Helper to get topics from an element
|
||||||
function matchesSearch(p) {
|
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;
|
if (!state.search) return true;
|
||||||
const text = (
|
const text = (
|
||||||
p.title +
|
el.dataset.title + " " +
|
||||||
" " +
|
el.dataset.kind + " " +
|
||||||
p.kind +
|
el.dataset.year + " " +
|
||||||
" " +
|
el.dataset.topics + " " +
|
||||||
p.topics.join(" ") +
|
el.dataset.authors
|
||||||
" " +
|
|
||||||
p.authors.join(" ")
|
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
|
|
||||||
return text.includes(state.search);
|
return text.includes(state.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render results
|
// Change display of divs based on filtering/searching
|
||||||
function render() {
|
function render() {
|
||||||
const container = document.getElementById("results");
|
let count = 0;
|
||||||
container.innerHTML = "";
|
publications.forEach(el => {
|
||||||
const filtered = publications.filter(p =>
|
const visible =
|
||||||
matchesFilters(p) && matchesSearch(p)
|
matchesFilters(el) &&
|
||||||
);
|
matchesSearch(el);
|
||||||
if (filtered.length === 0) {
|
if (visible) count+=1;
|
||||||
container.innerHTML = "<p>No results</p>";
|
renderCount(count)
|
||||||
return;
|
el.style.display = visible ? "" : "none";
|
||||||
}
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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:
|
// On startup:
|
||||||
// Build filters
|
// 1) Build filters
|
||||||
renderFilter("filter-kind", "kind", getUniqueValues("kind"));
|
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));
|
renderFilter("filter-year", "year", getUniqueValues("year").map(String));
|
||||||
// Search input
|
// 2) Register search input
|
||||||
document.getElementById("search").addEventListener("input", e => {
|
document.getElementById("search").addEventListener("input", e => {
|
||||||
state.search = e.target.value.toLowerCase();
|
state.search = e.target.value.toLowerCase();
|
||||||
render();
|
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();
|
render();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue