diff --git a/overlays/default.nix b/overlays/default.nix index ea1b2b5..5ffbb72 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -3,5 +3,5 @@ let packages = import ../pkgs { pkgs = prev; }; in { - inherit (packages) example-a example-b pyzotero pyzotero-cli; + inherit (packages) example-a example-b pyzotero pyzotero-cli khal-export org-zotero-export; } diff --git a/pkgs/default.nix b/pkgs/default.nix index fb312c3..3fef26e 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -7,6 +7,8 @@ let pyzotero-cli = pkgs.callPackage ./pyzotero-cli { pyzotero = self.pyzotero; }; + khal-export = pkgs.callPackage ./khal-export { }; + org-zotero-export = pkgs.callPackage ./org-zotero-export { }; }; in self // { default = self.example-a; } diff --git a/pkgs/khal-export/default.nix b/pkgs/khal-export/default.nix new file mode 100644 index 0000000..3244e1b --- /dev/null +++ b/pkgs/khal-export/default.nix @@ -0,0 +1,33 @@ +{ + lib, + khal, + python3Packages, +}: + +python3Packages.buildPythonApplication { + pname = "khal-export"; + version = "0.1.0"; + format = "other"; + + src = ./.; + + dontUnpack = true; + dontBuild = true; + + propagatedBuildInputs = [ + khal + python3Packages.click + python3Packages.icalendar + ]; + + installPhase = '' + mkdir -p $out/bin + install -Dm755 ${./khal-export.py} $out/bin/khal-export + ''; + + meta = { + description = "Export multiple events from khal in .ics format"; + platforms = lib.platforms.all; + mainProgram = "khal-export"; + }; +} diff --git a/pkgs/khal-export/khal-export.py b/pkgs/khal-export/khal-export.py new file mode 100644 index 0000000..e981b2c --- /dev/null +++ b/pkgs/khal-export/khal-export.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from datetime import datetime + +import click +from khal.settings import get_config +from khal.cli import build_collection +from icalendar import Calendar + + +@click.command() +@click.option('--search', 'search_string') +@click.option('--allow-past/--future-only', default=False) +def main(search_string, allow_past): + conf = get_config(None) + collection = build_collection(conf, None) + now = conf['locale']['local_timezone'].localize(datetime.now()) + + vevents = [ + Calendar.from_ical(event.raw) + for event in sorted(collection.search(search_string)) + if ( + allow_past + or event.allday and event.end >= now.date() + or not event.allday and event.end_local >= now + ) + ] + event_collection = Calendar(next(iter(vevents), {})) + for event in vevents: + for component in event.subcomponents: + event_collection.add_component(component) + + click.echo(event_collection.to_ical()) + + +if __name__ == '__main__': + main() diff --git a/pkgs/org-zotero-export/default.nix b/pkgs/org-zotero-export/default.nix new file mode 100644 index 0000000..1ad2fec --- /dev/null +++ b/pkgs/org-zotero-export/default.nix @@ -0,0 +1,36 @@ +{ + lib, + stdenv, + python3, + pandoc, + makeWrapper, +}: + +stdenv.mkDerivation { + pname = "org-zotero-export"; + version = "0.1.0"; + + src = ./.; + + nativeBuildInputs = [ makeWrapper ]; + + dontUnpack = true; + + installPhase = '' + mkdir -p $out/bin + substitute ${./org-zotero-export.py} $out/bin/org-zotero-export \ + --replace-fail "#!/usr/bin/env python3" "#!${python3}/bin/python3" + chmod +x $out/bin/org-zotero-export + ''; + + postFixup = '' + wrapProgram $out/bin/org-zotero-export \ + --prefix PATH : ${lib.makeBinPath [ pandoc ]} + ''; + + meta = { + description = "Export Org-mode files to HTML with Zotero Better BibTeX citation links"; + license = lib.licenses.mit; + platforms = lib.platforms.all; + }; +} diff --git a/pkgs/org-zotero-export/org-zotero-export.py b/pkgs/org-zotero-export/org-zotero-export.py new file mode 100644 index 0000000..293d52f --- /dev/null +++ b/pkgs/org-zotero-export/org-zotero-export.py @@ -0,0 +1,1019 @@ +#!/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 = """ + + + +""" + + # 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 + html = stdout.decode('utf-8') + head_match = re.search(r'(.*?)', html, re.DOTALL) + if head_match: + head_content = head_match.group(1) + # Insert CSS into head (before ) + new_head = head_content.replace('', css_to_inject + '\n ') + html = html.replace(head_content, new_head, 1) + else: + # No 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()