Files
my-pkgs/pkgs/org-zotero-export/org-zotero-export.py
Luis Eduardo Bueso de Barrio 2eb4492b55
All checks were successful
CI / Build Packages (default) (push) Successful in 46s
CI / Build Packages (example-a) (push) Successful in 47s
CI / Nix Flake Check (push) Successful in 1m51s
CI / Build Packages (example-b) (push) Successful in 46s
CI / Build Packages (pyzotero) (push) Successful in 51s
CI / Build Packages (pyzotero-cli) (push) Successful in 54s
org-zotero-export
2026-02-10 16:55:46 +01:00

1020 lines
28 KiB
Python

#!/usr/bin/env python3
"""Export RESEARCH.org to HTML with Zotero PDF links"""
import argparse
import json
import re
import subprocess
import sys
from socket import setdefaulttimeout
from urllib.request import urlopen, Request
from urllib.error import URLError
BBT_API = "http://localhost:23119/better-bibtex/json-rpc"
TIMEOUT = 5 # seconds
def call_bbt(method, params=None):
"""Call BBT JSON-RPC API with timeout"""
payload = {
"jsonrpc": "2.0",
"method": method,
"id": 1
}
if params is not None:
payload["params"] = params
setdefaulttimeout(TIMEOUT)
req = Request(BBT_API, data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'})
try:
with urlopen(req) as response:
result = json.loads(response.read().decode('utf-8'))
if 'error' in result:
print(f"BBT API error: {result['error']}", file=sys.stderr)
return None
return result.get('result')
except URLError as e:
print(f"Connection error to BBT API: {e}", file=sys.stderr)
print("Is Zotero with Better BibTeX running?", file=sys.stderr)
return None
except TimeoutError:
print(f"Timeout connecting to BBT API ({TIMEOUT}s)", file=sys.stderr)
return None
except Exception as e:
print(f"Unexpected error calling BBT API: {e}", file=sys.stderr)
return None
def format_author_label(authors, year):
"""Format author label: 'Chow, 1978', 'Arts & Thompson, 2010', 'Kalaji et al., 2011'"""
if not authors:
return "Unknown, Year"
if len(authors) == 1:
return f"{authors[0]['family']}, {year}"
elif len(authors) == 2:
return f"{authors[0]['family']} & {authors[1]['family']}, {year}"
else:
return f"{authors[0]['family']} et al., {year}"
def get_zotero_link(citekey):
"""Get (url, label) tuple for a cite key"""
# First, try to get PDF attachments
attachments = call_bbt("item.attachments", [citekey])
if attachments and len(attachments) > 0:
label = "PDF" # Default, will be refined below
url = attachments[0]['open']
# Try to get proper label from item info
item_info = call_bbt("item.search", [citekey])
if item_info and len(item_info) > 0:
item = item_info[0]
authors = item.get('author', [])
year = item.get('issued', {}).get('date-parts', [['?']])[0][0]
label = format_author_label(authors, year)
return url, label
# Fallback: get item info and construct select link
item_info = call_bbt("item.search", [citekey])
if item_info and len(item_info) > 0:
item = item_info[0]
item_id = item['id']
# Extract item key from URL like "http://zotero.org/users/.../items/ABC12345"
match = re.search(r'/items/([A-Z0-9]+)$', item_id)
if match:
item_key = match.group(1)
label = format_author_label(item.get('author', []),
item.get('issued', {}).get('date-parts', [['?']])[0][0])
return f"zotero://select/library/items/{item_key}", label
return None, None
def main():
parser = argparse.ArgumentParser(description='Export RESEARCH.org to HTML with Zotero links')
parser.add_argument('--input', default='RESEARCH.org', help='Input Org file')
parser.add_argument('--output', default='research.html', help='Output HTML file')
args = parser.parse_args()
# Check BBT API is ready
api_status = call_bbt("api.ready")
if not api_status:
print("Error: BBT API not available. Is Zotero with Better BibTeX running?",
file=sys.stderr)
sys.exit(1)
print(f"BBT API ready: {api_status.get('betterbibtex', 'unknown')}")
# Read Org file
try:
with open(args.input, 'r', encoding='utf-8') as f:
content = f.read()
except FileNotFoundError:
print(f"Error: Input file '{args.input}' not found", file=sys.stderr)
sys.exit(1)
# Extract all cite keys
cite_pattern = r'\[cite:@([a-zA-Z0-9_-]+)\]'
cite_keys = re.findall(cite_pattern, content)
unique_keys = sorted(set(cite_keys))
print(f"Found {len(unique_keys)} unique citations")
# Build citation mapping
citation_map = {}
warnings_count = 0
for citekey in unique_keys:
url, label = get_zotero_link(citekey)
if url:
citation_map[citekey] = (url, label)
print(f" Mapped {citekey}{label}")
else:
warnings_count += 1
citation_map[citekey] = (None, citekey)
print(f" Warning: Could not map {citekey}", file=sys.stderr)
if warnings_count > 0:
print(f"Completed with {warnings_count} warnings", file=sys.stderr)
# Replace citations with Org links
def replace_citation(match):
citekey = match.group(1)
url, label = citation_map.get(citekey, (None, citekey))
if url:
return f"[[{url}][{label}]]"
return f"[❓cite:@{citekey}]" # Fallback with emoji
processed_content = re.sub(cite_pattern, replace_citation, content)
# CSS and JS to inject
css_to_inject = """
<style>
/* --- Zotero links --- */
a[href^="zotero://"] {
color: #0066cc;
text-decoration: none;
border-bottom: 1px dotted #0066cc;
}
a[href^="zotero://"]:hover {
color: #004499;
}
/* --- GitHub-style tag labels --- */
span.tag {
display: inline-block;
padding: 0.15em 0.55em;
margin-left: 0.3em;
border-radius: 2em;
font-size: 75%;
font-weight: 500;
line-height: 1.4;
vertical-align: middle;
color: #fff;
background-color: #6c757d; /* fallback, JS overrides */
}
span.tag .smallcaps {
font-variant: normal;
text-transform: none;
letter-spacing: normal;
}
/* --- Collapsible headings --- */
h1, h2, h3, h4, h5, h6 {
cursor: pointer;
user-select: text;
position: relative;
}
header h1.title {
cursor: default;
font-size: 2.6em;
letter-spacing: -0.02em;
line-height: 1.15;
margin-bottom: 0.3em;
}
header .author,
header .date {
display: none; /* moved to footer by JS */
}
/* --- Footer --- */
.site-footer {
margin-top: 4em;
padding: 1.5em 0;
border-top: 1px solid #e0e0e0;
text-align: center;
font-size: 0.85em;
color: #656d76;
}
.fold-toggle {
display: inline-block;
width: 1em;
margin-right: 0.2em;
font-size: 0.7em;
vertical-align: middle;
transition: transform 0.15s ease;
user-select: none;
}
.fold-toggle.collapsed {
transform: rotate(0deg);
}
.fold-toggle.expanded {
transform: rotate(90deg);
}
.collapsible-content {
overflow: hidden;
}
.collapsible-content.collapsed {
display: none;
}
/* --- Search / filter bar --- */
.search-bar {
position: sticky;
top: 0;
z-index: 100;
background: #fdfdfd;
padding: 0.7em 0 0.6em 0;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 1em;
}
.search-row {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.5em;
}
.search-input {
flex: 1;
padding: 0.45em 0.7em;
font-size: 0.95em;
border: 1px solid #d0d7de;
border-radius: 6px;
outline: none;
background: #fff;
color: #1a1a1a;
font-family: inherit;
}
.search-input:focus {
border-color: #0969da;
box-shadow: 0 0 0 3px rgba(9,105,218,0.15);
}
.search-input::placeholder {
color: #8b949e;
}
.mode-toggle {
padding: 0.35em 0.7em;
font-size: 0.8em;
font-weight: 600;
border: 1px solid #d0d7de;
border-radius: 6px;
background: #f6f8fa;
color: #57606a;
cursor: pointer;
white-space: nowrap;
user-select: none;
min-width: 3em;
text-align: center;
}
.mode-toggle:hover {
background: #eaeef2;
}
.clear-btn {
padding: 0.35em 0.7em;
font-size: 0.8em;
border: 1px solid #d0d7de;
border-radius: 6px;
background: #f6f8fa;
color: #57606a;
cursor: pointer;
white-space: nowrap;
user-select: none;
visibility: hidden;
}
.clear-btn.visible {
visibility: visible;
}
.clear-btn:hover {
background: #eaeef2;
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: 0.35em;
}
.tag-filter-pill {
display: inline-block;
padding: 0.15em 0.55em;
border-radius: 2em;
font-size: 0.78em;
font-weight: 500;
line-height: 1.4;
color: #fff;
cursor: pointer;
user-select: none;
opacity: 0.5;
transition: opacity 0.15s ease, box-shadow 0.15s ease;
border: 2px solid transparent;
}
.tag-filter-pill:hover {
opacity: 0.75;
}
.tag-filter-pill.selected {
opacity: 1;
border-color: #fff;
box-shadow: 0 0 0 2px rgba(0,0,0,0.25);
}
.filtered-hidden {
display: none !important;
}
.search-match-count {
font-size: 0.8em;
color: #57606a;
margin-left: 0.3em;
white-space: nowrap;
}
/* --- Dark mode --- */
@media (prefers-color-scheme: dark) {
html {
color: #c9d1d9;
background-color: #0d1117;
}
body {
color: #c9d1d9;
}
a {
color: #58a6ff;
}
a:visited {
color: #58a6ff;
}
a[href^="zotero://"] {
color: #79c0ff;
border-bottom-color: #79c0ff;
}
a[href^="zotero://"]:hover {
color: #a5d6ff;
}
code {
color: #c9d1d9;
background-color: #161b22;
}
pre {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 1em;
}
pre code {
background-color: transparent;
}
blockquote {
border-left-color: #30363d;
color: #8b949e;
}
hr {
border-top-color: #30363d;
}
tbody {
border-top-color: #30363d;
border-bottom-color: #30363d;
}
th {
border-top-color: #30363d;
}
h1, h2, h3, h4, h5, h6 {
color: #e6edf3;
}
header {
border-bottom-color: #30363d;
}
.site-footer {
border-top-color: #30363d;
color: #8b949e;
}
.search-bar {
background: #0d1117;
border-bottom-color: #30363d;
}
.search-input {
background: #161b22;
color: #c9d1d9;
border-color: #30363d;
}
.search-input:focus {
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88,166,255,0.15);
}
.search-input::placeholder {
color: #6e7681;
}
.mode-toggle {
background: #21262d;
color: #c9d1d9;
border-color: #30363d;
}
.mode-toggle:hover {
background: #30363d;
}
.clear-btn {
background: #21262d;
color: #c9d1d9;
border-color: #30363d;
}
.clear-btn:hover {
background: #30363d;
}
.tag-filter-pill.selected {
border-color: #c9d1d9;
box-shadow: 0 0 0 2px rgba(201,209,217,0.25);
}
.search-match-count {
color: #8b949e;
}
}
/* --- Responsive: widen body on larger screens --- */
@media (min-width: 768px) {
body {
max-width: 48em;
}
}
@media (min-width: 1200px) {
body {
max-width: 60em;
}
}
/* --- Responsive: touch-friendly base --- */
* {
-webkit-tap-highlight-color: transparent;
}
/* --- Responsive: mobile (max-width: 600px) --- */
@media (max-width: 600px) {
/* Search bar: add horizontal padding matching body */
.search-bar {
padding-left: 12px;
padding-right: 12px;
}
/* Search row: wrap to stacked layout */
.search-row {
flex-wrap: wrap;
}
.search-input {
width: 100%;
flex: 1 1 100%;
padding: 0.55em 0.7em;
font-size: 1em;
}
.mode-toggle,
.clear-btn {
padding: 0.45em 0.8em;
font-size: 0.85em;
}
.search-match-count {
flex-basis: 100%;
margin-left: 0;
margin-top: 0.2em;
}
/* Tag filter pills: scrollable row on mobile */
.tag-filters {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.3em;
scrollbar-width: thin;
}
.tag-filter-pill {
flex-shrink: 0;
padding: 0.25em 0.55em;
font-size: 0.75em;
}
/* Heading tags: smaller on mobile */
span.tag {
font-size: 65%;
padding: 0.1em 0.4em;
margin-left: 0.15em;
}
/* Headings: slightly smaller, ensure wrapping */
header h1.title { font-size: 2em; }
h1 { font-size: 1.5em; }
h2 { font-size: 1.2em; }
.fold-toggle {
font-size: 0.6em;
}
}
/* --- Responsive: tablet (601px - 767px) --- */
@media (min-width: 601px) and (max-width: 767px) {
span.tag {
font-size: 70%;
}
.tag-filter-pill {
font-size: 0.75em;
}
}
/* --- Responsive: print --- */
@media print {
.search-bar {
display: none !important;
}
.collapsible-content.collapsed {
display: block !important;
}
.fold-toggle {
display: none !important;
}
.filtered-hidden {
display: block !important;
}
.site-footer {
border-top: 1px solid #000;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// =============================================
// Tag coloring
// =============================================
var TAG_COLORS = [
'#0969da', '#1a7f37', '#9a6700', '#bc4c00',
'#cf222e', '#8250df', '#e85aad', '#0e8a16'
];
function hashStr(s) {
var h = 0;
for (var i = 0; i < s.length; i++) {
h = ((h << 5) - h) + s.charCodeAt(i);
h = h & h;
}
return Math.abs(h);
}
function tagColor(name) {
return TAG_COLORS[hashStr(name) % TAG_COLORS.length];
}
document.querySelectorAll('span.tag[data-tag-name]').forEach(function(el) {
el.style.backgroundColor = tagColor(el.getAttribute('data-tag-name'));
});
// =============================================
// Collapsible headings — build heading map
// =============================================
// headingData: array of {heading, level, wrapper, toggle}
var headingData = [];
var headings = document.querySelectorAll(
'body > h1, body > h2, body > h3, body > h4, body > h5, body > h6'
);
headings.forEach(function(heading) {
var level = parseInt(heading.tagName.charAt(1));
var content = [];
var next = heading.nextElementSibling;
while (next) {
if (/^H[1-6]$/.test(next.tagName)) {
var nextLevel = parseInt(next.tagName.charAt(1));
if (nextLevel <= level) break;
}
content.push(next);
next = next.nextElementSibling;
}
var wrapper = null;
var toggle = null;
if (content.length > 0) {
wrapper = document.createElement('div');
wrapper.className = 'collapsible-content collapsed';
heading.parentNode.insertBefore(wrapper, content[0]);
content.forEach(function(el) { wrapper.appendChild(el); });
toggle = document.createElement('span');
toggle.className = 'fold-toggle collapsed';
toggle.textContent = '\\u25B6';
heading.insertBefore(toggle, heading.firstChild);
heading.addEventListener('click', function(e) {
if (e.target.tagName === 'A') return;
if (e.target.closest && e.target.closest('.tag-filter-pill')) return;
toggleCollapse(heading);
});
}
headingData.push({
heading: heading,
level: level,
wrapper: wrapper,
toggle: toggle
});
});
function setCollapsed(entry, collapsed) {
if (!entry.wrapper) return;
if (collapsed) {
entry.wrapper.classList.add('collapsed');
entry.toggle.classList.add('collapsed');
entry.toggle.classList.remove('expanded');
} else {
entry.wrapper.classList.remove('collapsed');
entry.toggle.classList.remove('collapsed');
entry.toggle.classList.add('expanded');
}
}
function isCollapsed(entry) {
return entry.wrapper && entry.wrapper.classList.contains('collapsed');
}
function toggleCollapse(heading) {
var entry = headingData.find(function(d) { return d.heading === heading; });
if (!entry || !entry.wrapper) return;
setCollapsed(entry, !isCollapsed(entry));
}
// =============================================
// Collect paper entries (h2) and section entries (h1)
// =============================================
// Each paper: {entry (from headingData), textContent, tags[]}
// Each section: {entry, papers[]}
var sections = [];
var allTagNames = new Set();
headingData.forEach(function(entry) {
if (entry.level === 1) {
sections.push({ entry: entry, papers: [] });
} else if (entry.level === 2 && sections.length > 0) {
// Extract text content without tag spans
var clone = entry.heading.cloneNode(true);
clone.querySelectorAll('span.tag').forEach(function(t) { t.remove(); });
var ft = clone.querySelectorAll('.fold-toggle');
ft.forEach(function(t) { t.remove(); });
var text = clone.textContent.trim();
var tags = [];
entry.heading.querySelectorAll('span.tag[data-tag-name]').forEach(function(t) {
var name = t.getAttribute('data-tag-name');
tags.push(name);
allTagNames.add(name);
});
// Also collect tags from parent h1
var parentSection = sections[sections.length - 1];
parentSection.entry.heading.querySelectorAll('span.tag[data-tag-name]').forEach(function(t) {
var name = t.getAttribute('data-tag-name');
if (tags.indexOf(name) === -1) tags.push(name);
allTagNames.add(name);
});
var paper = { entry: entry, text: text, tags: tags };
sections[sections.length - 1].papers.push(paper);
}
});
// Collect tags from h3+ headings and associate them with their parent h2 paper
var currentPaper = null;
headingData.forEach(function(entry) {
if (entry.level === 2) {
// Find the matching paper object
for (var si = 0; si < sections.length; si++) {
for (var pi = 0; pi < sections[si].papers.length; pi++) {
if (sections[si].papers[pi].entry === entry) {
currentPaper = sections[si].papers[pi];
}
}
}
} else if (entry.level >= 3 && currentPaper) {
entry.heading.querySelectorAll('span.tag[data-tag-name]').forEach(function(t) {
var name = t.getAttribute('data-tag-name');
allTagNames.add(name);
if (currentPaper.tags.indexOf(name) === -1) {
currentPaper.tags.push(name);
}
});
} else if (entry.level === 1) {
currentPaper = null;
}
});
// =============================================
// Fuzzy match (fzf-style)
// =============================================
function fuzzyMatch(query, target) {
// Returns {match: bool, score: number}
// Characters of query must appear in order in target (case-insensitive)
var q = query.toLowerCase();
var t = target.toLowerCase();
var qi = 0, ti = 0;
var score = 0;
var prevMatchIdx = -2;
var firstMatch = -1;
while (qi < q.length && ti < t.length) {
if (q[qi] === t[ti]) {
if (firstMatch < 0) firstMatch = ti;
// Consecutive bonus
if (ti === prevMatchIdx + 1) {
score += 3;
}
// Word boundary bonus
if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-' ||
t[ti - 1] === '(' || t[ti - 1] === ':' || t[ti - 1] === ',') {
score += 5;
}
// Start bonus
if (ti === 0) {
score += 3;
}
// Camel case bonus
if (ti > 0 && t[ti] !== t[ti].toUpperCase() === false &&
target[ti] === target[ti].toUpperCase() &&
target[ti] !== target[ti].toLowerCase()) {
score += 2;
}
score += 1; // base match point
prevMatchIdx = ti;
qi++;
}
ti++;
}
if (qi < q.length) {
return { match: false, score: 0 };
}
// Prefer shorter targets
score -= Math.floor(t.length / 10);
// Prefer matches closer to start
if (firstMatch >= 0) {
score -= Math.floor(firstMatch / 5);
}
return { match: true, score: Math.max(score, 1) };
}
// =============================================
// Build search bar UI
// =============================================
var searchBar = document.createElement('div');
searchBar.className = 'search-bar';
// Row 1: search input + mode toggle + clear + count
var searchRow = document.createElement('div');
searchRow.className = 'search-row';
var searchInput = document.createElement('input');
searchInput.className = 'search-input';
searchInput.type = 'text';
searchInput.placeholder = 'Search papers\\u2026';
searchInput.setAttribute('autocomplete', 'off');
var modeBtn = document.createElement('button');
modeBtn.className = 'mode-toggle';
modeBtn.textContent = 'OR';
modeBtn.title = 'Tag filter mode: OR (any) / AND (all)';
var filterMode = 'or'; // 'or' or 'and'
var clearBtn = document.createElement('button');
clearBtn.className = 'clear-btn';
clearBtn.textContent = 'Clear';
var matchCount = document.createElement('span');
matchCount.className = 'search-match-count';
searchRow.appendChild(searchInput);
searchRow.appendChild(modeBtn);
searchRow.appendChild(clearBtn);
searchRow.appendChild(matchCount);
// Row 2: tag filter pills
var tagRow = document.createElement('div');
tagRow.className = 'tag-filters';
var sortedTags = Array.from(allTagNames).sort();
var selectedTags = new Set();
var tagPillMap = {}; // tagName -> pill element
sortedTags.forEach(function(name) {
var pill = document.createElement('span');
pill.className = 'tag-filter-pill';
pill.textContent = name;
pill.style.backgroundColor = tagColor(name);
pill.setAttribute('data-tag', name);
pill.addEventListener('click', function(e) {
e.stopPropagation();
if (selectedTags.has(name)) {
selectedTags.delete(name);
pill.classList.remove('selected');
} else {
selectedTags.add(name);
pill.classList.add('selected');
}
runFilter();
});
tagPillMap[name] = pill;
tagRow.appendChild(pill);
});
searchBar.appendChild(searchRow);
searchBar.appendChild(tagRow);
// Insert after <header> (before intro paragraph)
var header = document.querySelector('header#title-block-header');
if (header && header.nextSibling) {
header.parentNode.insertBefore(searchBar, header.nextSibling);
} else {
document.body.insertBefore(searchBar, document.body.firstChild);
}
// =============================================
// Filter logic
// =============================================
var debounceTimer = null;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runFilter, 120);
});
modeBtn.addEventListener('click', function() {
if (filterMode === 'or') {
filterMode = 'and';
modeBtn.textContent = 'AND';
} else {
filterMode = 'or';
modeBtn.textContent = 'OR';
}
runFilter();
});
clearBtn.addEventListener('click', function() {
searchInput.value = '';
selectedTags.clear();
sortedTags.forEach(function(name) {
tagPillMap[name].classList.remove('selected');
});
runFilter();
});
function runFilter() {
var query = searchInput.value.trim();
var hasQuery = query.length > 0;
var hasTags = selectedTags.size > 0;
var isFiltering = hasQuery || hasTags;
// Show/hide clear button
if (isFiltering) {
clearBtn.classList.add('visible');
} else {
clearBtn.classList.remove('visible');
}
var totalVisible = 0;
sections.forEach(function(section) {
var sectionHasVisiblePaper = false;
section.papers.forEach(function(paper) {
var textMatch = true;
var tagMatch = true;
// Text fuzzy match
if (hasQuery) {
var result = fuzzyMatch(query, paper.text);
textMatch = result.match;
}
// Tag match
if (hasTags) {
var selArr = Array.from(selectedTags);
if (filterMode === 'or') {
tagMatch = selArr.some(function(st) {
return paper.tags.indexOf(st) !== -1;
});
} else {
tagMatch = selArr.every(function(st) {
return paper.tags.indexOf(st) !== -1;
});
}
}
var visible = textMatch && tagMatch;
if (visible) {
sectionHasVisiblePaper = true;
totalVisible++;
paper.entry.heading.classList.remove('filtered-hidden');
if (paper.entry.wrapper) {
paper.entry.wrapper.classList.remove('filtered-hidden');
}
// When filtering, expand matches; when not, collapse
if (isFiltering) {
setCollapsed(paper.entry, false);
} else {
setCollapsed(paper.entry, true);
}
} else {
paper.entry.heading.classList.add('filtered-hidden');
if (paper.entry.wrapper) {
paper.entry.wrapper.classList.add('filtered-hidden');
}
}
});
// Show/hide section heading
if (isFiltering) {
if (sectionHasVisiblePaper) {
section.entry.heading.classList.remove('filtered-hidden');
if (section.entry.wrapper) {
section.entry.wrapper.classList.remove('filtered-hidden');
}
setCollapsed(section.entry, false);
} else {
section.entry.heading.classList.add('filtered-hidden');
if (section.entry.wrapper) {
section.entry.wrapper.classList.add('filtered-hidden');
}
}
} else {
// No filter active — show all, restore collapsed
section.entry.heading.classList.remove('filtered-hidden');
if (section.entry.wrapper) {
section.entry.wrapper.classList.remove('filtered-hidden');
}
setCollapsed(section.entry, true);
}
});
// Update match count
if (isFiltering) {
var totalPapers = sections.reduce(function(s, sec) {
return s + sec.papers.length;
}, 0);
matchCount.textContent = totalVisible + ' / ' + totalPapers + ' papers';
} else {
matchCount.textContent = '';
}
}
// =============================================
// Move date to footer
// =============================================
var dateEl = document.querySelector('header .date');
if (dateEl) {
var raw = dateEl.textContent.trim().replace(/^<|>$/g, '');
// Parse YYYY-MM-DD and format nicely
var parts = raw.match(/(\\d{4})-(\\d{2})-(\\d{2})/);
var formatted = raw;
if (parts) {
var months = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
var d = parseInt(parts[3], 10);
var m = months[parseInt(parts[2], 10) - 1] || parts[2];
var y = parts[1];
formatted = m + ' ' + d + ', ' + y;
}
var footer = document.createElement('footer');
footer.className = 'site-footer';
footer.textContent = 'Last updated ' + formatted;
document.body.appendChild(footer);
}
});
</script>
"""
# Run Pandoc to get HTML first, then post-process to inject CSS
cmd = ['pandoc', '--from', 'org', '--to', 'html',
'--standalone', '-']
try:
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = proc.communicate(processed_content.encode('utf-8'))
except FileNotFoundError:
print("Error: pandoc not found on PATH", file=sys.stderr)
sys.exit(1)
if proc.returncode != 0:
print(f"Pandoc error: {stderr.decode('utf-8')}", file=sys.stderr)
sys.exit(1)
# Post-process HTML to inject CSS into <head>
html = stdout.decode('utf-8')
head_match = re.search(r'(<head>.*?</head>)', html, re.DOTALL)
if head_match:
head_content = head_match.group(1)
# Insert CSS into head (before </head>)
new_head = head_content.replace('</head>', css_to_inject + '\n </head>')
html = html.replace(head_content, new_head, 1)
else:
# No <head> tag found (unexpected), prepend CSS
html = css_to_inject + '\n' + html
# Write final HTML
with open(args.output, 'w', encoding='utf-8') as f:
f.write(html)
print(f"Successfully exported to {args.output}")
if __name__ == "__main__":
main()