Loading...
No commits yet
Not committed History
Blame
writer.py • 11.7 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# File: src/scitex_writer/writer.py

"""
Writer class for manuscript LaTeX compilation.

Provides object-oriented interface to scitex-writer functionality.
"""

from __future__ import annotations

import logging
import shutil
import subprocess
from pathlib import Path
from typing import Callable, Optional

from ._compile import (
    CompilationResult,
    compile_manuscript,
    compile_revision,
    compile_supplementary,
)
from ._dataclasses import ManuscriptTree, RevisionTree, SupplementaryTree
from ._dataclasses.config import DOC_TYPE_DIRS
from ._dataclasses.tree import ScriptsTree, SharedTree
from ._project._create import clone_writer_project
from ._utils._watch import watch_manuscript

logger = logging.getLogger(__name__)


def _find_git_root(project_dir: Path) -> Optional[Path]:
    """Find git root for project directory."""
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            cwd=str(project_dir),
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode == 0:
            return Path(result.stdout.strip())
    except Exception:
        pass
    return None


class Writer:
    """LaTeX manuscript compiler."""

    def __init__(
        self,
        project_dir: Path,
        name: Optional[str] = None,
        git_strategy: Optional[str] = "child",
        branch: Optional[str] = None,
        tag: Optional[str] = None,
    ):
        """
        Initialize for project directory.

        If directory doesn't exist, creates new project.

        Parameters
        ----------
        project_dir : Path
            Path to project directory.
        name : str, optional
            Project name (used if creating new project).
        git_strategy : str, optional
            Git initialization strategy:
            - 'child': Create isolated git in project directory (default)
            - 'parent': Use parent git repository
            - 'origin': Preserve template's original git history
            - None or 'none': Disable git initialization
        branch : str, optional
            Specific branch of template repository to clone.
            If None, clones the default branch. Mutually exclusive with tag.
        tag : str, optional
            Specific tag/release of template repository to clone.
            If None, clones the default branch. Mutually exclusive with branch.
        """
        self.project_name = name or Path(project_dir).name
        self.project_dir = Path(project_dir)
        self.git_strategy = git_strategy
        self.branch = branch
        self.tag = tag

        ref_info = ""
        if branch:
            ref_info = f" (branch: {branch})"
        elif tag:
            ref_info = f" (tag: {tag})"
        logger.info(
            f"Writer: Initializing with:\n"
            f"    Project Name: {self.project_name}\n"
            f"    Project Directory: {self.project_dir.absolute()}\n"
            f"    Git Strategy: {self.git_strategy}{ref_info}..."
        )

        # Create or attach to project
        self.project_dir = self._attach_or_create_project(name)

        # Find git root (may be the project dir or a parent)
        self.git_root = _find_git_root(self.project_dir) or self.project_dir

        # Document accessors (pass git_root for efficiency)
        self.shared = SharedTree(self.project_dir / "00_shared", git_root=self.git_root)
        self.manuscript = ManuscriptTree(
            self.project_dir / "01_manuscript", git_root=self.git_root
        )
        self.supplementary = SupplementaryTree(
            self.project_dir / "02_supplementary", git_root=self.git_root
        )
        self.revision = RevisionTree(
            self.project_dir / "03_revision", git_root=self.git_root
        )
        self.scripts = ScriptsTree(self.project_dir / "scripts", git_root=self.git_root)

        logger.info(f"Writer: Initialization complete for {self.project_name}")

    def _attach_or_create_project(self, name: Optional[str] = None) -> Path:
        """
        Create new project or attach to existing one.

        If project directory doesn't exist, creates it based on git_strategy:
        - 'child': Full template with git initialization
        - 'parent'/'None': Minimal directory structure

        Parameters
        ----------
        name : str, optional
            Project name (used if creating new project).

        Returns
        -------
        Path
            Path to the project directory.
        """
        if self.project_dir.exists():
            logger.info(
                f"Writer: Attached to existing project at {self.project_dir.absolute()}"
            )
            # Verify existing project structure
            self._verify_project_structure()
            return self.project_dir

        project_name = name or self.project_dir.name

        logger.info(
            f"Writer: Creating new project '{project_name}' at {self.project_dir.absolute()}"
        )

        # Initialize project directory structure
        success = clone_writer_project(
            str(self.project_dir), self.git_strategy, self.branch, self.tag
        )

        if not success:
            logger.error(
                f"Writer: Failed to initialize project directory for {project_name}"
            )
            raise RuntimeError(
                f"Could not create project directory at {self.project_dir}"
            )

        # Verify project directory was created
        if not self.project_dir.exists():
            logger.error(
                f"Writer: Project directory {self.project_dir} was not created"
            )
            raise RuntimeError(f"Project directory {self.project_dir} was not created")

        logger.info(
            f"Writer: Successfully created project at {self.project_dir.absolute()}"
        )
        return self.project_dir

    def _verify_project_structure(self) -> None:
        """
        Verify attached project has expected structure.

        Checks:
        - Required directories exist (01_manuscript, 02_supplementary, 03_revision)

        Raises
        ------
        RuntimeError
            If structure is invalid.
        """
        required_dirs = [
            self.project_dir / "01_manuscript",
            self.project_dir / "02_supplementary",
            self.project_dir / "03_revision",
        ]

        for dir_path in required_dirs:
            if not dir_path.exists():
                logger.error(f"Writer: Expected directory missing: {dir_path}")
                raise RuntimeError(
                    f"Project structure invalid: missing {dir_path.name} directory"
                )

        logger.info(
            f"Writer: Project structure verified at {self.project_dir.absolute()}"
        )

    def compile_manuscript(
        self,
        timeout: int = 300,
        log_callback: Optional[Callable[[str], None]] = None,
        progress_callback: Optional[Callable[[int, str], None]] = None,
    ) -> CompilationResult:
        """
        Compile manuscript to PDF with optional live callbacks.

        Runs scripts/shell/compile_manuscript.sh with configured settings.

        Parameters
        ----------
        timeout : int, optional
            Maximum compilation time in seconds (default: 300).
        log_callback : callable, optional
            Called with each log line: log_callback("Running pdflatex...").
        progress_callback : callable, optional
            Called with progress: progress_callback(50, "Pass 2/3").

        Returns
        -------
        CompilationResult
            With success status, PDF path, and errors/warnings.

        Examples
        --------
        >>> writer = Writer(Path("my_paper"))
        >>> result = writer.compile_manuscript()
        >>> if result.success:
        ...     print(f"PDF created: {result.output_pdf}")
        """
        return compile_manuscript(
            self.project_dir,
            timeout=timeout,
            log_callback=log_callback,
            progress_callback=progress_callback,
        )

    def compile_supplementary(
        self,
        timeout: int = 300,
        log_callback: Optional[Callable[[str], None]] = None,
        progress_callback: Optional[Callable[[int, str], None]] = None,
    ) -> CompilationResult:
        """
        Compile supplementary materials to PDF with optional live callbacks.

        Runs scripts/shell/compile_supplementary.sh with configured settings.

        Parameters
        ----------
        timeout : int, optional
            Maximum compilation time in seconds (default: 300).
        log_callback : callable, optional
            Called with each log line.
        progress_callback : callable, optional
            Called with progress updates.

        Returns
        -------
        CompilationResult
            With success status, PDF path, and errors/warnings.

        Examples
        --------
        >>> writer = Writer(Path("my_paper"))
        >>> result = writer.compile_supplementary()
        >>> if result.success:
        ...     print(f"PDF created: {result.output_pdf}")
        """
        return compile_supplementary(
            self.project_dir,
            timeout=timeout,
            log_callback=log_callback,
            progress_callback=progress_callback,
        )

    def compile_revision(
        self,
        track_changes: bool = False,
        timeout: int = 300,
        log_callback: Optional[Callable[[str], None]] = None,
        progress_callback: Optional[Callable[[int, str], None]] = None,
    ) -> CompilationResult:
        """
        Compile revision document with optional change tracking and live callbacks.

        Runs scripts/shell/compile_revision.sh with configured settings.

        Parameters
        ----------
        track_changes : bool, optional
            Enable change tracking in compiled PDF (default: False).
        timeout : int, optional
            Maximum compilation time in seconds (default: 300).
        log_callback : callable, optional
            Called with each log line.
        progress_callback : callable, optional
            Called with progress updates.

        Returns
        -------
        CompilationResult
            With success status, PDF path, and errors/warnings.

        Examples
        --------
        >>> writer = Writer(Path("my_paper"))
        >>> result = writer.compile_revision(track_changes=True)
        >>> if result.success:
        ...     print(f"Revision PDF: {result.output_pdf}")
        """
        return compile_revision(
            self.project_dir,
            track_changes=track_changes,
            timeout=timeout,
            log_callback=log_callback,
            progress_callback=progress_callback,
        )

    def watch(self, on_compile: Optional[Callable] = None) -> None:
        """Auto-recompile on file changes."""
        watch_manuscript(self.project_dir, on_compile=on_compile)

    def get_pdf(self, doc_type: str = "manuscript") -> Optional[Path]:
        """Get output PDF path (Read)."""
        pdf = self.project_dir / DOC_TYPE_DIRS[doc_type] / f"{doc_type}.pdf"
        return pdf if pdf.exists() else None

    def delete(self) -> bool:
        """Delete project directory (Delete)."""
        try:
            logger.warning(
                f"Writer: Deleting project directory at {self.project_dir.absolute()}"
            )
            shutil.rmtree(self.project_dir)
            logger.info(
                f"Writer: Successfully deleted project at {self.project_dir.absolute()}"
            )
            return True
        except Exception as e:
            logger.error(
                f"Writer: Failed to delete project directory at {self.project_dir.absolute()}: {e}"
            )
            return False


__all__ = ["Writer"]

# EOF