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
1020 lines
28 KiB
Python
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()
|