initial version
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
result
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770841267,
|
||||
"narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ec7c70d12ce2fc37cb92aff673dcdca89d187bae",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
102
flake.nix
Normal file
102
flake.nix
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
description = "org-to-quartz: Convert org notes to Quartz-compatible markdown";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
python = pkgs.python311;
|
||||
pythonPackages = python.pkgs;
|
||||
|
||||
org-to-quartz = pythonPackages.buildPythonApplication {
|
||||
pname = "org-to-quartz";
|
||||
version = "0.1.0";
|
||||
format = "pyproject";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pythonPackages.setuptools
|
||||
pythonPackages.wheel
|
||||
];
|
||||
|
||||
propagatedBuildInputs = [
|
||||
pythonPackages.pybtex
|
||||
pythonPackages.requests
|
||||
pythonPackages.pyyaml
|
||||
pkgs.pandoc
|
||||
];
|
||||
|
||||
# Make pandoc available at runtime
|
||||
makeWrapperArgs = [
|
||||
"--prefix" "PATH" ":" "${pkgs.pandoc}/bin"
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "Convert org notes to Quartz-compatible markdown";
|
||||
mainProgram = "org-to-quartz";
|
||||
};
|
||||
};
|
||||
|
||||
# Script to serve quartz with converted notes
|
||||
quartz-serve = pkgs.writeShellScriptBin "quartz-serve" ''
|
||||
set -e
|
||||
NOTES_DIR="''${1:-.}"
|
||||
PORT="''${2:-8080}"
|
||||
WORK_DIR=$(mktemp -d)
|
||||
|
||||
echo "Cloning Quartz..."
|
||||
${pkgs.git}/bin/git clone --depth 1 https://github.com/jackyzha0/quartz.git "$WORK_DIR/quartz" 2>/dev/null
|
||||
|
||||
echo "Installing dependencies..."
|
||||
cd "$WORK_DIR/quartz"
|
||||
${pkgs.nodejs}/bin/npm install --silent
|
||||
|
||||
echo "Converting org notes from $NOTES_DIR..."
|
||||
${org-to-quartz}/bin/org-to-quartz "$NOTES_DIR" "$WORK_DIR/quartz/content" -v
|
||||
|
||||
# Enable OxHugo plugin
|
||||
${pkgs.gnused}/bin/sed -i 's/Plugin.GitHubFlavoredMarkdown()/Plugin.OxHugoFlavouredMarkdown(),\n Plugin.GitHubFlavoredMarkdown()/' quartz.config.ts
|
||||
|
||||
echo ""
|
||||
echo "Starting Quartz on http://localhost:$PORT"
|
||||
${pkgs.nodejs}/bin/npx quartz build --serve --port "$PORT"
|
||||
'';
|
||||
|
||||
in {
|
||||
packages = {
|
||||
default = org-to-quartz;
|
||||
org-to-quartz = org-to-quartz;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
python
|
||||
pythonPackages.pybtex
|
||||
pythonPackages.requests
|
||||
pythonPackages.pyyaml
|
||||
pythonPackages.pytest
|
||||
pkgs.pandoc
|
||||
pkgs.nodejs
|
||||
];
|
||||
};
|
||||
|
||||
apps = {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${org-to-quartz}/bin/org-to-quartz";
|
||||
};
|
||||
serve = {
|
||||
type = "app";
|
||||
program = "${quartz-serve}/bin/quartz-serve";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "org-to-quartz"
|
||||
version = "0.1.0"
|
||||
description = "Convert org notes to Quartz-compatible markdown"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pybtex",
|
||||
"requests",
|
||||
"pyyaml",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
org-to-quartz = "org_to_quartz.main:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
3
src/org_to_quartz/__init__.py
Normal file
3
src/org_to_quartz/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""org-to-quartz: Convert org notes to Quartz-compatible markdown."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
src/org_to_quartz/citations/__init__.py
Normal file
5
src/org_to_quartz/citations/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Citation resolution module."""
|
||||
|
||||
from .resolver import CitationResolver
|
||||
|
||||
__all__ = ["CitationResolver"]
|
||||
91
src/org_to_quartz/citations/bibtex.py
Normal file
91
src/org_to_quartz/citations/bibtex.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Local BibTeX file resolver for citations."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from pybtex.database import parse_file, BibliographyData
|
||||
|
||||
|
||||
@dataclass
|
||||
class CitationInfo:
|
||||
"""Resolved citation information."""
|
||||
|
||||
key: str
|
||||
authors: str
|
||||
year: str
|
||||
title: str
|
||||
doi: str | None = None
|
||||
url: str | None = None
|
||||
|
||||
def format_link(self, link: str | None = None) -> str:
|
||||
"""Format as markdown link [Author, Year](link)."""
|
||||
display = f"{self.authors}, {self.year}" if self.authors else self.key
|
||||
if link:
|
||||
return f"[{display}]({link})"
|
||||
if self.doi:
|
||||
return f"[{display}](https://doi.org/{self.doi})"
|
||||
if self.url:
|
||||
return f"[{display}]({self.url})"
|
||||
return f"[{display}]"
|
||||
|
||||
|
||||
class BibtexResolver:
|
||||
"""Resolve citations from a local BibTeX file."""
|
||||
|
||||
def __init__(self, bib_path: Path | str | None = None):
|
||||
"""Initialize with optional path to .bib file."""
|
||||
self.bib_data: BibliographyData | None = None
|
||||
self.bib_path = Path(bib_path) if bib_path else None
|
||||
|
||||
if self.bib_path and self.bib_path.exists():
|
||||
self._load_bib()
|
||||
|
||||
def _load_bib(self) -> None:
|
||||
"""Load BibTeX file."""
|
||||
if self.bib_path is None:
|
||||
return
|
||||
try:
|
||||
self.bib_data = parse_file(str(self.bib_path))
|
||||
except Exception:
|
||||
self.bib_data = None
|
||||
|
||||
def _extract_authors(self, entry) -> str:
|
||||
"""Extract author names from BibTeX entry."""
|
||||
if "author" not in entry.persons:
|
||||
return ""
|
||||
|
||||
authors = entry.persons["author"]
|
||||
if len(authors) == 1:
|
||||
return str(authors[0].last_names[0]) if authors[0].last_names else ""
|
||||
elif len(authors) == 2:
|
||||
names = [str(a.last_names[0]) for a in authors if a.last_names]
|
||||
return " & ".join(names)
|
||||
else:
|
||||
first = authors[0].last_names[0] if authors[0].last_names else ""
|
||||
return f"{first} et al."
|
||||
|
||||
def _extract_year(self, entry) -> str:
|
||||
"""Extract year from BibTeX entry."""
|
||||
return entry.fields.get("year", "")
|
||||
|
||||
def try_resolve(self, cite_key: str) -> CitationInfo | None:
|
||||
"""Try to resolve a citation key from the BibTeX file.
|
||||
|
||||
Returns:
|
||||
CitationInfo if found, None otherwise
|
||||
"""
|
||||
if self.bib_data is None:
|
||||
return None
|
||||
|
||||
if cite_key not in self.bib_data.entries:
|
||||
return None
|
||||
|
||||
entry = self.bib_data.entries[cite_key]
|
||||
return CitationInfo(
|
||||
key=cite_key,
|
||||
authors=self._extract_authors(entry),
|
||||
year=self._extract_year(entry),
|
||||
title=entry.fields.get("title", ""),
|
||||
doi=entry.fields.get("doi"),
|
||||
url=entry.fields.get("url"),
|
||||
)
|
||||
33
src/org_to_quartz/citations/doi.py
Normal file
33
src/org_to_quartz/citations/doi.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""DOI/URL fallback resolver for citations."""
|
||||
|
||||
from .bibtex import CitationInfo
|
||||
|
||||
|
||||
class DOIResolver:
|
||||
"""Fallback resolver that formats citation keys as-is."""
|
||||
|
||||
def try_resolve(self, cite_key: str) -> CitationInfo:
|
||||
"""Create a minimal citation info with just the key.
|
||||
|
||||
This is the final fallback - always returns something.
|
||||
|
||||
Returns:
|
||||
CitationInfo with just the key (no link)
|
||||
"""
|
||||
# Check if the key looks like a DOI
|
||||
doi = None
|
||||
if cite_key.startswith("10."):
|
||||
doi = cite_key
|
||||
|
||||
return CitationInfo(
|
||||
key=cite_key,
|
||||
authors="",
|
||||
year="",
|
||||
title="",
|
||||
doi=doi,
|
||||
url=None,
|
||||
)
|
||||
|
||||
def format_raw(self, cite_key: str) -> str:
|
||||
"""Format as raw citation without link."""
|
||||
return f"[{cite_key}]"
|
||||
55
src/org_to_quartz/citations/resolver.py
Normal file
55
src/org_to_quartz/citations/resolver.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Citation resolver orchestrator - chains multiple resolvers."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .zotero import ZoteroResolver
|
||||
from .bibtex import BibtexResolver
|
||||
from .doi import DOIResolver
|
||||
|
||||
|
||||
class CitationResolver:
|
||||
"""Orchestrates citation resolution across multiple sources.
|
||||
|
||||
Resolution order:
|
||||
1. Zotero Better BibTeX (if running) -> zotero://select/items/@key
|
||||
2. Local BibTeX file (if provided) -> DOI link or raw
|
||||
3. DOI fallback -> raw key
|
||||
"""
|
||||
|
||||
def __init__(self, bib_path: Path | str | None = None):
|
||||
"""Initialize citation resolver.
|
||||
|
||||
Args:
|
||||
bib_path: Optional path to local .bib file for fallback
|
||||
"""
|
||||
self.zotero = ZoteroResolver()
|
||||
self.bibtex = BibtexResolver(bib_path)
|
||||
self.doi = DOIResolver()
|
||||
|
||||
def resolve(self, cite_key: str) -> str:
|
||||
"""Resolve a citation key to a markdown link.
|
||||
|
||||
Tries resolvers in order:
|
||||
1. Zotero (returns zotero://select link)
|
||||
2. BibTeX (returns DOI link if available)
|
||||
3. DOI fallback (returns raw key)
|
||||
|
||||
Args:
|
||||
cite_key: Citation key (e.g., "smith2020")
|
||||
|
||||
Returns:
|
||||
Markdown formatted citation link
|
||||
"""
|
||||
# Try Zotero first
|
||||
info = self.zotero.try_resolve(cite_key)
|
||||
if info is not None:
|
||||
return info.format_link(info.url) # zotero:// URL
|
||||
|
||||
# Try BibTeX
|
||||
info = self.bibtex.try_resolve(cite_key)
|
||||
if info is not None:
|
||||
return info.format_link() # DOI or URL from bib entry
|
||||
|
||||
# Final fallback
|
||||
info = self.doi.try_resolve(cite_key)
|
||||
return info.format_link()
|
||||
126
src/org_to_quartz/citations/zotero.py
Normal file
126
src/org_to_quartz/citations/zotero.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Zotero Better BibTeX JSON-RPC resolver."""
|
||||
|
||||
import requests
|
||||
from typing import Any
|
||||
|
||||
from .bibtex import CitationInfo
|
||||
|
||||
|
||||
ZOTERO_RPC_URL = "http://localhost:23119/better-bibtex/json-rpc"
|
||||
|
||||
|
||||
class ZoteroResolver:
|
||||
"""Resolve citations via Zotero Better BibTeX JSON-RPC API."""
|
||||
|
||||
def __init__(self, timeout: float = 2.0):
|
||||
"""Initialize Zotero resolver.
|
||||
|
||||
Args:
|
||||
timeout: Request timeout in seconds (short since it's local)
|
||||
"""
|
||||
self.timeout = timeout
|
||||
self._available: bool | None = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if Zotero with Better BibTeX is running."""
|
||||
if self._available is not None:
|
||||
return self._available
|
||||
|
||||
try:
|
||||
# Simple ping to check if server is up
|
||||
response = requests.post(
|
||||
ZOTERO_RPC_URL,
|
||||
json={"jsonrpc": "2.0", "method": "item.citationkey", "params": ["__test__"], "id": 1},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
self._available = response.status_code == 200
|
||||
except (requests.RequestException, ConnectionError):
|
||||
self._available = False
|
||||
|
||||
return self._available
|
||||
|
||||
def _rpc_call(self, method: str, params: list[Any]) -> Any | None:
|
||||
"""Make a JSON-RPC call to Better BibTeX."""
|
||||
try:
|
||||
response = requests.post(
|
||||
ZOTERO_RPC_URL,
|
||||
json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
data = response.json()
|
||||
return data.get("result")
|
||||
except (requests.RequestException, ValueError):
|
||||
return None
|
||||
|
||||
def _get_item_by_citekey(self, cite_key: str) -> dict | None:
|
||||
"""Get Zotero item data by citation key."""
|
||||
# Better BibTeX method to search by citekey
|
||||
result = self._rpc_call("item.search", [f"citekey:{cite_key}"])
|
||||
if result and isinstance(result, list) and len(result) > 0:
|
||||
return result[0]
|
||||
return None
|
||||
|
||||
def _extract_authors(self, item: dict) -> str:
|
||||
"""Extract author string from Zotero item."""
|
||||
creators = item.get("creators", [])
|
||||
authors = [c for c in creators if c.get("creatorType") == "author"]
|
||||
|
||||
if not authors:
|
||||
return ""
|
||||
|
||||
if len(authors) == 1:
|
||||
return authors[0].get("lastName", "")
|
||||
elif len(authors) == 2:
|
||||
return f"{authors[0].get('lastName', '')} & {authors[1].get('lastName', '')}"
|
||||
else:
|
||||
return f"{authors[0].get('lastName', '')} et al."
|
||||
|
||||
def _extract_year(self, item: dict) -> str:
|
||||
"""Extract year from Zotero item."""
|
||||
date = item.get("date", "")
|
||||
# Try to extract year from date string
|
||||
if date:
|
||||
# Common formats: "2024", "2024-01-15", "January 2024"
|
||||
import re
|
||||
|
||||
match = re.search(r"(\d{4})", date)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
def try_resolve(self, cite_key: str) -> CitationInfo | None:
|
||||
"""Try to resolve a citation key via Zotero.
|
||||
|
||||
Returns:
|
||||
CitationInfo with zotero:// link if found, None otherwise
|
||||
"""
|
||||
if not self.is_available():
|
||||
return None
|
||||
|
||||
item = self._get_item_by_citekey(cite_key)
|
||||
if item is None:
|
||||
return None
|
||||
|
||||
# Build zotero:// select URL
|
||||
item_key = item.get("key", "")
|
||||
library_id = item.get("libraryID", 1)
|
||||
|
||||
# Format: zotero://select/items/@citekey or zotero://select/library/items/ITEMKEY
|
||||
zotero_url = f"zotero://select/items/@{cite_key}"
|
||||
|
||||
info = CitationInfo(
|
||||
key=cite_key,
|
||||
authors=self._extract_authors(item),
|
||||
year=self._extract_year(item),
|
||||
title=item.get("title", ""),
|
||||
doi=item.get("DOI"),
|
||||
url=zotero_url,
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
def format_zotero_link(self, info: CitationInfo) -> str:
|
||||
"""Format citation with Zotero select URL."""
|
||||
return info.format_link(info.url)
|
||||
120
src/org_to_quartz/image_handler.py
Normal file
120
src/org_to_quartz/image_handler.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Detect and copy images referenced in org files."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .org_parser import OrgDocument
|
||||
|
||||
|
||||
# Patterns for image references in org-mode
|
||||
IMAGE_PATTERNS = [
|
||||
# [[file:path/to/image.png]]
|
||||
re.compile(r"\[\[file:([^\]]+\.(?:png|jpg|jpeg|gif|svg|webp|bmp))\]\]", re.IGNORECASE),
|
||||
# [[./path/to/image.png]]
|
||||
re.compile(r"\[\[(\./[^\]]+\.(?:png|jpg|jpeg|gif|svg|webp|bmp))\]\]", re.IGNORECASE),
|
||||
# [[path/to/image.png]] (without file: prefix)
|
||||
re.compile(r"\[\[([^\]:]+\.(?:png|jpg|jpeg|gif|svg|webp|bmp))\]\]", re.IGNORECASE),
|
||||
# Inline image references: ./image.png or path/image.png
|
||||
re.compile(r"(?<!\[)(\./[^\s\]]+\.(?:png|jpg|jpeg|gif|svg|webp|bmp))(?!\])", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def find_images(content: str) -> list[str]:
|
||||
"""Find all image references in org content."""
|
||||
images: set[str] = set()
|
||||
for pattern in IMAGE_PATTERNS:
|
||||
matches = pattern.findall(content)
|
||||
images.update(matches)
|
||||
return list(images)
|
||||
|
||||
|
||||
def resolve_image_path(image_ref: str, source_dir: Path) -> Path | None:
|
||||
"""Resolve image reference to absolute path.
|
||||
|
||||
Args:
|
||||
image_ref: Image reference from org file (e.g., './img.png', 'images/fig.png')
|
||||
source_dir: Directory containing the source org file
|
||||
|
||||
Returns:
|
||||
Absolute path to image if found, None otherwise
|
||||
"""
|
||||
# Remove file: prefix if present
|
||||
clean_ref = image_ref.removeprefix("file:")
|
||||
|
||||
# Try relative to source directory
|
||||
candidate = source_dir / clean_ref
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
|
||||
# Try without ./ prefix
|
||||
if clean_ref.startswith("./"):
|
||||
candidate = source_dir / clean_ref[2:]
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def copy_images(doc: OrgDocument, output_dir: Path) -> dict[str, str]:
|
||||
"""Copy images referenced in document to output directory.
|
||||
|
||||
Args:
|
||||
doc: Parsed org document
|
||||
output_dir: Directory where the note's folder will be created
|
||||
|
||||
Returns:
|
||||
Mapping of original image references to new relative paths
|
||||
"""
|
||||
if doc.source_path is None:
|
||||
return {}
|
||||
|
||||
source_dir = doc.source_path.parent
|
||||
note_dir = output_dir / doc.slug
|
||||
note_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
image_refs = find_images(doc.body)
|
||||
path_mapping: dict[str, str] = {}
|
||||
|
||||
for ref in image_refs:
|
||||
src_path = resolve_image_path(ref, source_dir)
|
||||
if src_path is None:
|
||||
# Image not found, skip but could log warning
|
||||
continue
|
||||
|
||||
# Copy to note directory with original filename
|
||||
dest_path = note_dir / src_path.name
|
||||
if not dest_path.exists() or src_path.stat().st_mtime > dest_path.stat().st_mtime:
|
||||
shutil.copy2(src_path, dest_path)
|
||||
|
||||
# Map original reference to new relative path (just filename since it's in same dir)
|
||||
path_mapping[ref] = src_path.name
|
||||
|
||||
return path_mapping
|
||||
|
||||
|
||||
def update_image_paths(content: str, path_mapping: dict[str, str]) -> str:
|
||||
"""Update image references in markdown content.
|
||||
|
||||
Args:
|
||||
content: Markdown content (after pandoc conversion)
|
||||
path_mapping: Mapping from original refs to new filenames
|
||||
|
||||
Returns:
|
||||
Content with updated image paths
|
||||
"""
|
||||
for old_ref, new_path in path_mapping.items():
|
||||
# After pandoc conversion, images become  or 
|
||||
# We need to replace various forms of the old reference
|
||||
|
||||
# file:path -> new_path
|
||||
content = content.replace(f"file:{old_ref}", new_path)
|
||||
|
||||
# ./path -> new_path
|
||||
if old_ref.startswith("./"):
|
||||
content = content.replace(old_ref, new_path)
|
||||
content = content.replace(old_ref[2:], new_path) # without ./
|
||||
else:
|
||||
content = content.replace(old_ref, new_path)
|
||||
|
||||
return content
|
||||
141
src/org_to_quartz/main.py
Normal file
141
src/org_to_quartz/main.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""CLI entry point for org-to-quartz converter."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .org_parser import parse_org_file
|
||||
from .markdown_writer import convert_document, write_markdown
|
||||
from .image_handler import copy_images, update_image_paths
|
||||
from .citations import CitationResolver
|
||||
|
||||
|
||||
def find_org_files(input_dir: Path) -> list[Path]:
|
||||
"""Find all .org files in directory (non-recursive)."""
|
||||
return list(input_dir.glob("*.org"))
|
||||
|
||||
|
||||
def convert_file(
|
||||
org_path: Path,
|
||||
output_dir: Path,
|
||||
citation_resolver: CitationResolver | None,
|
||||
verbose: bool = False,
|
||||
) -> Path | None:
|
||||
"""Convert a single org file to Quartz markdown.
|
||||
|
||||
Returns:
|
||||
Path to created note directory, or None on error
|
||||
"""
|
||||
try:
|
||||
# Parse org file
|
||||
doc = parse_org_file(org_path)
|
||||
|
||||
if verbose:
|
||||
print(f" Parsed: {doc.title or org_path.stem}")
|
||||
|
||||
# Create output directory for this note
|
||||
note_dir = output_dir / doc.slug
|
||||
note_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy images first (before conversion, to get path mapping)
|
||||
image_mapping = copy_images(doc, output_dir)
|
||||
|
||||
if verbose and image_mapping:
|
||||
print(f" Copied {len(image_mapping)} images")
|
||||
|
||||
# Convert document
|
||||
md_content = convert_document(doc, citation_resolver)
|
||||
|
||||
# Update image paths in converted content
|
||||
md_content = update_image_paths(md_content, image_mapping)
|
||||
|
||||
# Write output
|
||||
output_path = note_dir / "index.md"
|
||||
output_path.write_text(md_content, encoding="utf-8")
|
||||
|
||||
return note_dir
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error converting {org_path.name}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="org-to-quartz",
|
||||
description="Convert org-mode notes to Quartz-compatible markdown",
|
||||
)
|
||||
parser.add_argument(
|
||||
"input_dir",
|
||||
type=Path,
|
||||
help="Directory containing .org files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"output_dir",
|
||||
type=Path,
|
||||
help="Output directory (e.g., quartz/content)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bib",
|
||||
type=Path,
|
||||
metavar="FILE",
|
||||
help="Path to .bib file for citation resolution",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Verbose output",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate input directory
|
||||
if not args.input_dir.is_dir():
|
||||
print(f"Error: Input directory does not exist: {args.input_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Create output directory
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find org files
|
||||
org_files = find_org_files(args.input_dir)
|
||||
if not org_files:
|
||||
print(f"No .org files found in {args.input_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Found {len(org_files)} org files")
|
||||
|
||||
# Initialize citation resolver
|
||||
citation_resolver = CitationResolver(args.bib)
|
||||
|
||||
if args.verbose:
|
||||
if citation_resolver.zotero.is_available():
|
||||
print("Zotero Better BibTeX: available")
|
||||
else:
|
||||
print("Zotero Better BibTeX: not available")
|
||||
if args.bib:
|
||||
print(f"BibTeX file: {args.bib}")
|
||||
|
||||
# Convert each file
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
for org_path in org_files:
|
||||
print(f"Converting: {org_path.name}")
|
||||
result = convert_file(org_path, args.output_dir, citation_resolver, args.verbose)
|
||||
if result:
|
||||
success_count += 1
|
||||
if args.verbose:
|
||||
print(f" -> {result}")
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
# Summary
|
||||
print(f"\nDone: {success_count} converted, {error_count} errors")
|
||||
|
||||
return 0 if error_count == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
148
src/org_to_quartz/markdown_writer.py
Normal file
148
src/org_to_quartz/markdown_writer.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Convert org content to Quartz-compatible markdown with YAML front matter."""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from .org_parser import OrgDocument
|
||||
|
||||
|
||||
def build_front_matter(doc: OrgDocument) -> dict:
|
||||
"""Build YAML front matter dict from OrgDocument."""
|
||||
fm: dict = {}
|
||||
|
||||
if doc.title:
|
||||
fm["title"] = doc.title
|
||||
if doc.date:
|
||||
fm["date"] = doc.date
|
||||
if doc.lastmod:
|
||||
fm["lastmod"] = doc.lastmod
|
||||
if doc.tags:
|
||||
fm["tags"] = doc.tags
|
||||
if doc.draft:
|
||||
fm["draft"] = True
|
||||
|
||||
return fm
|
||||
|
||||
|
||||
def front_matter_to_yaml(fm: dict) -> str:
|
||||
"""Convert front matter dict to YAML string with delimiters."""
|
||||
if not fm:
|
||||
return ""
|
||||
yaml_str = yaml.dump(fm, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
|
||||
def convert_org_to_markdown(org_content: str) -> str:
|
||||
"""Convert org-mode content to GitHub-flavored markdown using pandoc."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pandoc", "-f", "org", "-t", "gfm", "--wrap=none"],
|
||||
input=org_content,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Pandoc conversion failed: {e.stderr}") from e
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError("Pandoc not found. Please install pandoc.")
|
||||
|
||||
|
||||
def process_wikilinks(content: str) -> str:
|
||||
"""Convert org-roam style links (after pandoc) to Quartz wikilinks.
|
||||
|
||||
Pandoc converts org links to markdown links:
|
||||
- [[roam:Title]] -> [roam:Title](roam:Title) -> [[Title]]
|
||||
- [[id:uuid][Desc]] -> [Desc](id:uuid) -> [[Desc]]
|
||||
- [[file:x.org][Desc]] -> [Desc](file:x.org) -> [[Desc]]
|
||||
"""
|
||||
# [roam:Title](roam:Title) -> [[Title]]
|
||||
content = re.sub(r"\[roam:([^\]]+)\]\(roam:[^)]+\)", r"[[\1]]", content)
|
||||
|
||||
# [Description](id:uuid) -> [[Description]]
|
||||
content = re.sub(r"\[([^\]]+)\]\(id:[a-f0-9-]+\)", r"[[\1]]", content)
|
||||
|
||||
# [Description](file:something.org) -> [[Description]]
|
||||
content = re.sub(r"\[([^\]]+)\]\(file:[^)]+\.org\)", r"[[\1]]", content)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def process_citations(content: str, resolver) -> str:
|
||||
"""Process org-mode citations and resolve them.
|
||||
|
||||
Handles:
|
||||
- cite:key
|
||||
- [cite:@key]
|
||||
- [[cite:key][description]]
|
||||
"""
|
||||
if resolver is None:
|
||||
return content
|
||||
|
||||
def replace_cite(match) -> str:
|
||||
cite_key = match.group(1)
|
||||
return resolver.resolve(cite_key)
|
||||
|
||||
# cite:key pattern
|
||||
content = re.sub(r"cite:([a-zA-Z0-9_-]+)", replace_cite, content)
|
||||
|
||||
# [cite:@key] pattern
|
||||
content = re.sub(r"\[cite:@([a-zA-Z0-9_-]+)\]", replace_cite, content)
|
||||
|
||||
# [[cite:key][description]] - use resolved link but keep description context
|
||||
def replace_cite_with_desc(match) -> str:
|
||||
cite_key = match.group(1)
|
||||
return resolver.resolve(cite_key)
|
||||
|
||||
content = re.sub(r"\[\[cite:([a-zA-Z0-9_-]+)\]\[[^\]]*\]\]", replace_cite_with_desc, content)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def generate_draft_todo(doc: OrgDocument) -> str:
|
||||
"""Generate TODO comment for draft/noexport files."""
|
||||
if doc.draft:
|
||||
return "<!-- TODO: This note was marked as noexport. Review before publishing. -->\n\n"
|
||||
return ""
|
||||
|
||||
|
||||
def convert_document(doc: OrgDocument, citation_resolver=None) -> str:
|
||||
"""Convert OrgDocument to complete markdown string."""
|
||||
# Build front matter
|
||||
fm = build_front_matter(doc)
|
||||
yaml_header = front_matter_to_yaml(fm)
|
||||
|
||||
# Convert body
|
||||
md_body = convert_org_to_markdown(doc.body)
|
||||
|
||||
# Process links
|
||||
md_body = process_wikilinks(md_body)
|
||||
|
||||
# Process citations
|
||||
md_body = process_citations(md_body, citation_resolver)
|
||||
|
||||
# Add TODO comment if draft
|
||||
todo_comment = generate_draft_todo(doc)
|
||||
|
||||
return yaml_header + todo_comment + md_body
|
||||
|
||||
|
||||
def write_markdown(doc: OrgDocument, output_dir: Path, citation_resolver=None) -> Path:
|
||||
"""Write converted document to output directory.
|
||||
|
||||
Creates: output_dir/<slug>/index.md
|
||||
Returns: Path to created directory
|
||||
"""
|
||||
# Create note directory
|
||||
note_dir = output_dir / doc.slug
|
||||
note_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert and write
|
||||
md_content = convert_document(doc, citation_resolver)
|
||||
output_path = note_dir / "index.md"
|
||||
output_path.write_text(md_content, encoding="utf-8")
|
||||
|
||||
return note_dir
|
||||
99
src/org_to_quartz/org_parser.py
Normal file
99
src/org_to_quartz/org_parser.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Parse org-mode files to extract front matter and body."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgDocument:
|
||||
"""Parsed org document with front matter and body."""
|
||||
|
||||
title: str = ""
|
||||
tags: list[str] = field(default_factory=list)
|
||||
date: str = ""
|
||||
lastmod: str = ""
|
||||
draft: bool = False
|
||||
hugo_section: str = ""
|
||||
raw_front_matter: dict[str, str] = field(default_factory=dict)
|
||||
body: str = ""
|
||||
source_path: Path | None = None
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
"""Generate URL-friendly slug from title or filename."""
|
||||
if self.source_path:
|
||||
return self.source_path.stem
|
||||
return re.sub(r"[^a-z0-9]+", "-", self.title.lower()).strip("-")
|
||||
|
||||
|
||||
# Pattern for org front matter: #+KEY: value
|
||||
FRONT_MATTER_PATTERN = re.compile(r"^#\+(\w+):\s*(.*)$", re.IGNORECASE)
|
||||
|
||||
# Pattern for filetags: :tag1:tag2:tag3:
|
||||
# Uses word boundary to capture tags between colons
|
||||
FILETAGS_PATTERN = re.compile(r"(?<=:)([^:]+)(?=:)")
|
||||
|
||||
|
||||
def parse_filetags(value: str) -> list[str]:
|
||||
"""Parse org filetags format ':tag1:tag2:' into list."""
|
||||
return FILETAGS_PATTERN.findall(value)
|
||||
|
||||
|
||||
def parse_date(value: str) -> str:
|
||||
"""Parse org date format [2024-02-21] or <2024-02-21> to ISO date."""
|
||||
# Remove brackets/angles and any day names
|
||||
match = re.search(r"(\d{4}-\d{2}-\d{2})", value)
|
||||
return match.group(1) if match else value.strip("[]<>")
|
||||
|
||||
|
||||
def parse_org_file(path: Path) -> OrgDocument:
|
||||
"""Parse an org file into OrgDocument."""
|
||||
content = path.read_text(encoding="utf-8")
|
||||
return parse_org_content(content, source_path=path)
|
||||
|
||||
|
||||
def parse_org_content(content: str, source_path: Path | None = None) -> OrgDocument:
|
||||
"""Parse org content string into OrgDocument."""
|
||||
lines = content.splitlines()
|
||||
front_matter: dict[str, str] = {}
|
||||
body_start = 0
|
||||
|
||||
# Parse front matter (lines starting with #+)
|
||||
for i, line in enumerate(lines):
|
||||
match = FRONT_MATTER_PATTERN.match(line)
|
||||
if match:
|
||||
key = match.group(1).lower()
|
||||
value = match.group(2).strip()
|
||||
front_matter[key] = value
|
||||
elif line.strip() and not line.startswith("#"):
|
||||
# First non-empty, non-comment line starts the body
|
||||
body_start = i
|
||||
break
|
||||
elif not line.strip():
|
||||
# Empty line, continue looking for more front matter or body start
|
||||
continue
|
||||
else:
|
||||
body_start = i
|
||||
break
|
||||
|
||||
# Find actual body start (skip leading empty lines after front matter)
|
||||
while body_start < len(lines) and not lines[body_start].strip():
|
||||
body_start += 1
|
||||
|
||||
body = "\n".join(lines[body_start:])
|
||||
|
||||
# Build OrgDocument
|
||||
doc = OrgDocument(
|
||||
title=front_matter.get("title", ""),
|
||||
tags=parse_filetags(front_matter.get("filetags", "")),
|
||||
date=parse_date(front_matter.get("date", "")),
|
||||
lastmod=parse_date(front_matter.get("hugo_lastmod", "")),
|
||||
draft="noexport" in front_matter.get("hugo_tags", "").lower(),
|
||||
hugo_section=front_matter.get("hugo_section", ""),
|
||||
raw_front_matter=front_matter,
|
||||
body=body,
|
||||
source_path=source_path,
|
||||
)
|
||||
|
||||
return doc
|
||||
8
test/notes/draft-note.org
Normal file
8
test/notes/draft-note.org
Normal file
@@ -0,0 +1,8 @@
|
||||
#+title: Draft Note
|
||||
#+filetags: :draft:
|
||||
#+date: [2024-02-20]
|
||||
#+hugo_tags: noexport
|
||||
|
||||
* Work in Progress
|
||||
|
||||
This note is marked as noexport.
|
||||
24
test/notes/example-note.org
Normal file
24
test/notes/example-note.org
Normal file
@@ -0,0 +1,24 @@
|
||||
#+title: Example Note
|
||||
#+filetags: :test:example:
|
||||
#+date: [2024-02-21]
|
||||
#+hugo_lastmod: [2024-02-22]
|
||||
#+hugo_section: notes
|
||||
|
||||
* Introduction
|
||||
|
||||
This is an example org note with some features:
|
||||
|
||||
- A link to [[roam:Another Note]]
|
||||
- A citation cite:smith2020
|
||||
- Some *bold* and /italic/ text
|
||||
|
||||
* Code Example
|
||||
|
||||
#+begin_src python
|
||||
def hello():
|
||||
print("Hello, world!")
|
||||
#+end_src
|
||||
|
||||
* Conclusion
|
||||
|
||||
See also [[id:abc-123][Related Concept]] for more info.
|
||||
Reference in New Issue
Block a user