"""``scrivcli`` command-line interface (read-only Scrivener project viewer).""" from __future__ import annotations import argparse import sys from pathlib import Path from typing import Never from scriv import __version__ from scriv.project import ProjectError, ResolveError, ScrivenerProject from scriv.render import ( display_title, render_flat, render_meta, render_tree, ) def build_parser() -> argparse.ArgumentParser: formatter = argparse.RawDescriptionHelpFormatter parser = argparse.ArgumentParser( prog="scrivcli", description="""Read a Scrivener project without modifying or locking it. Supported projects: - Scrivener 2 and 3 for macOS - Scrivener 1.x for Windows Command output is written to stdout; errors are written to stderr.""", epilog="""document references: Commands accepting support an ID/UUID, an exact title (case-insensitive), or a slash-path printed by `ls --flat`. exit status: 0 success 1 project, document, or I/O error 2 invalid command-line usage Run `scrivcli --help` for command-specific examples.""", formatter_class=formatter, ) parser.add_argument( "--version", action="version", version=f"%(prog)s {__version__}" ) sub = parser.add_subparsers(dest="command", required=True, metavar="") p_ls = sub.add_parser( "ls", help="list the binder (document tree)", description="""List every binder item in the project. By default, output is a tree with metadata badges. Flat output uses slash-paths that can be passed to `show` and `meta`.""", epilog="""examples: scrivcli ls Novel.scriv scrivcli ls Novel.scriv --ids scrivcli ls Novel.scriv --flat --text-only --no-badges""", formatter_class=formatter, ) p_ls.add_argument("project", help="path to a .scriv bundle") p_ls.add_argument("--flat", action="store_true", help="print slash-paths instead of a tree") p_ls.add_argument("--ids", action="store_true", help="include document IDs/UUIDs") p_ls.add_argument("--no-badges", action="store_true", help="hide metadata badges") p_ls.add_argument( "--text-only", action="store_true", help="list only text documents (requires --flat)" ) p_ls.set_defaults(func=cmd_ls) p_show = sub.add_parser( "show", help="print a document's text", description="""Print the body of one text document. RTF is converted to plain text unless --raw is given. Synopsis and metadata are not included; use `meta` to inspect them.""", epilog="""examples: scrivcli show Novel.scriv "Draft/Chapter 1/Waking Up" scrivcli show Novel.scriv 01234567-89AB-CDEF-0123-456789ABCDEF scrivcli show Novel.scriv "Draft/Chapter 1" --raw""", formatter_class=formatter, ) p_show.add_argument("project", help="path to a .scriv bundle") p_show.add_argument("doc", help="ID/UUID, exact title, slash-path, or (Untitled)") p_show.add_argument( "--raw", action="store_true", help="write the stored RTF bytes without conversion" ) p_show.set_defaults(func=cmd_show) p_meta = sub.add_parser( "meta", help="show a document's metadata", description="""Print metadata for one binder item. Output includes its title, type, ID, path, dates, label, status, keywords, compile setting, and icon. Text documents also include their word count and synopsis.""", epilog="""examples: scrivcli meta Novel.scriv "Draft/Chapter 1/Waking Up" scrivcli meta Novel.scriv 01234567-89AB-CDEF-0123-456789ABCDEF""", formatter_class=formatter, ) p_meta.add_argument("project", help="path to a .scriv bundle") p_meta.add_argument("doc", help="ID/UUID, exact title, slash-path, or (Untitled)") p_meta.set_defaults(func=cmd_meta) p_info = sub.add_parser( "info", help="show project overview", description="""Print an overview of the project. The document count includes text documents throughout the binder. The word count is the sum of their converted plain-text word counts.""", epilog="""example: scrivcli info Novel.scriv""", formatter_class=formatter, ) p_info.add_argument("project", help="path to a .scriv bundle") p_info.set_defaults(func=cmd_info) return parser def open_project(path: str) -> ScrivenerProject: try: return ScrivenerProject(Path(path)) except ProjectError as exc: _die(str(exc)) def cmd_ls(args: argparse.Namespace) -> int: project = open_project(args.project) if args.flat: out = render_flat( project, ids=args.ids, show_badges=not args.no_badges, text_only=args.text_only, ) else: out = render_tree(project, ids=args.ids, show_badges=not args.no_badges) _print(out) return 0 def cmd_show(args: argparse.Namespace) -> int: project = open_project(args.project) item = _resolve(project, args.doc) if item is None: _die(f"Could not resolve {args.doc!r}.") if not item.is_text: _die(f"{display_title(item)!r} is a {item.type}, not a text document.") if args.raw: _write_bytes(project.raw_bytes(item)) else: _print(project.document_text(item)) return 0 def cmd_meta(args: argparse.Namespace) -> int: project = open_project(args.project) item = _resolve(project, args.doc) _print(render_meta(project, item)) return 0 def cmd_info(args: argparse.Namespace) -> int: project = open_project(args.project) docs = project.documents() total_words = sum(project.word_count(d) for d in docs) info = project.info lines = [ f"Project {project.bundle}", f"Identifier {info.identifier}", f"Version {info.version} (creator {info.creator})", f"Author {info.author or '-'}", f"Device {info.device or '-'}", f"Modified {info.modified or '-'}", f"Format {project.format.value}", f"Documents {len(docs)}", f"Words {total_words}", ] _print("\n".join(lines)) return 0 def _resolve(project: ScrivenerProject, ref: str): try: return project.resolve(ref) except ResolveError as exc: if exc.candidates: _print(f"Multiple documents match {ref!r}:", file=sys.stderr) for c in exc.candidates: _print(f" - {c.slash_path()} ", file=sys.stderr) _die("Refine the reference (use a slash-path or ID).") _die(str(exc)) def _print(msg: str, *, file=None) -> None: print(msg, file=file) # noqa: T201 def _write_bytes(data: bytes) -> None: sys.stdout.flush() sys.stdout.buffer.write(data) sys.stdout.buffer.flush() def _die(msg: str) -> Never: print(f"scrivcli: {msg}", file=sys.stderr) raise SystemExit(1) def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if args.command == "ls" and args.text_only and not args.flat: parser.error("ls: --text-only requires --flat") try: return args.func(args) except (OSError, ProjectError) as exc: _die(str(exc)) if __name__ == "__main__": raise SystemExit(main())