diff --git a/.markdownlint.json b/.markdownlint.json index 92140bd..5fa6c6d 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -3,5 +3,6 @@ "MD033": false, "MD060": false, "MD013": false, - "MD041": false + "MD041": false, + "MD024": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index cd740c2..b175316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,30 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- -## [Unreleased] +## [0.4.0] — 2026-07-06 -## [0.3.0] — 2025-07-06 + + +### Added + +- `qms-kit export --format md|html --config [--output-dir ]`: + renders all QMS templates and writes files to a local directory + - `md`: plain Markdown files, one per document + - `html`: standalone HTML pages with inline CSS, `index.html` with + links to all documents, "← Back" navigation on every page +- `markdown` library dependency for Markdown → HTML conversion +- `_render_all()` and `_render_extra()` helpers extracted in `cli/main.py` + — eliminates render-context duplication across deploy / validate / export +- 14 unit tests for the export command and `_slugify` helper + +### Changed + +- deploy and validate commands refactored to use shared `_render_all()` helper + +## [0.3.0] — 2026-07-06 ### Added + - `qms-kit validate` CLI command — validates config and env vars for a target, renders all templates, exits 0/1 (useful in CI pipelines) - Atlassian Quickstart section in README @@ -19,6 +38,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.2.0] — 2025-06-29 ### Added + - **Phase 2: Atlassian Cloud adapter** (`--target atlassian`) - `ConfluenceAdapter`: deploys QMS document tree via Confluence REST API v2, Markdown → Storage Format (XHTML) converter (`md_to_confluence_storage`) @@ -30,6 +50,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 30 unit tests for Confluence converter and Jira adapter ### Changed + - `--target atlassian` now fully wired in CLI (was "not yet implemented") - `_deploy_atlassian()` in `cli/main.py` calls both adapters in sequence - Added record-template placeholder vars to CLI render context @@ -38,6 +59,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.1] — 2025-06-22 ### Fixed + - Removed `[dependency-groups]` section from `pyproject.toml`; dev deps consolidated into `[project.optional-dependencies] dev` so `uv sync --frozen --extra dev` installs them correctly in CI @@ -45,11 +67,13 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). "QMS → QMS" nav entry caused by intermediate parent page ### Changed + - GitHub Actions PR size check now excludes `*.lock`, `*.md`, `tests/`, `test_*.py` ## [0.1.0] — 2025-06-15 ### Added + - **Phase 1: Self-hosted adapter** (`--target selfhosted`) - `XWikiAdapter`: deploys QMS space + document pages via XWiki REST API, Markdown → XWiki 2.1 syntax converter (`md_to_xwiki`) @@ -66,7 +90,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - GitHub Actions CI: ruff, bandit, mypy, pytest, PR size check (400-line limit) - `uv` package manager, installable as CLI via `pyproject.toml` -[Unreleased]: https://github.com/gerfru/qms-kit/compare/v0.3.0...HEAD +[0.4.0]: https://github.com/gerfru/qms-kit/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/gerfru/qms-kit/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/gerfru/qms-kit/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/gerfru/qms-kit/compare/v0.1.0...v0.1.1 diff --git a/cli/main.py b/cli/main.py index 979dec4..39d848a 100644 --- a/cli/main.py +++ b/cli/main.py @@ -3,16 +3,75 @@ from __future__ import annotations import os +import re from pathlib import Path +from typing import Any import click -from src.core.config import load_config +from src.core.config import CoreConfig, load_config from src.core.renderer import render_template TEMPLATES_DIR = Path(__file__).parent.parent / "templates" CONFIG_DIR = Path(__file__).parent.parent / "config" +_HTML_CSS = """ +body{font-family:system-ui,sans-serif;max-width:860px;margin:2rem auto;padding:0 1.5rem; + line-height:1.6;color:#1a1a1a} +h1{border-bottom:2px solid #0057b8;padding-bottom:.4rem;color:#0057b8} +h2{color:#333;margin-top:2rem}h3{color:#555} +table{border-collapse:collapse;width:100%} +th,td{border:1px solid #ccc;padding:.5rem .75rem;text-align:left} +th{background:#f0f4fa}tr:nth-child(even){background:#fafafa} +code{background:#f3f3f3;padding:.1rem .3rem;border-radius:3px;font-size:.9em} +pre{background:#f3f3f3;padding:1rem;border-radius:4px;overflow-x:auto} +blockquote{border-left:4px solid #0057b8;margin:0;padding:.5rem 1rem;color:#555} +a{color:#0057b8}.back{margin-bottom:1.5rem;font-size:.9em} +""".strip() + + +def _slugify(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + + +def _render_extra(config: CoreConfig, doc_id: str, clause: str, title: str) -> dict[str, Any]: + return { + "procedure_title": title, + "doc_id": f"QMS-{clause}-{doc_id.upper()[:8]}", + "clause": clause, + "owner_role": "Quality Management Officer", + "approver": config.organisation.management, + "author": config.organisation.quality_officer, + "date": "TBD", + "audit_date": "TBD", + "auditor": "TBD", + "audit_scope": "TBD", + "contact": "TBD", + "capa_id": "CAPA-XXXX-NNN", + "opened_date": "TBD", + "opened_by": "TBD", + "source_ref": "TBD", + "participants": "TBD", + } + + +def _render_all(config: CoreConfig) -> dict[str, tuple[str, str]]: + """Render index + all templated documents. Returns {doc_id: (title, md_content)}.""" + rendered: dict[str, tuple[str, str]] = {} + index_content = render_template("qms_index.md.j2", config=config, templates_dir=TEMPLATES_DIR) + rendered["qms_index"] = ("QM Manual", index_content) + for doc in config.documents: + if doc.template is None: + continue + content = render_template( + f"{doc.template}.md.j2", + config=config, + templates_dir=TEMPLATES_DIR, + extra=_render_extra(config, doc.id, doc.clause, doc.title), + ) + rendered[doc.id] = (doc.title, content) + return rendered + @click.group() def cli() -> None: @@ -50,48 +109,9 @@ def deploy(target: str, config_path: Path, dry_run: bool) -> None: f"({config.meta.standard}, {len(config.documents)} documents)" ) - # Render all templates that have a template key set - rendered: dict[str, tuple[str, str]] = {} - - # Space index page - index_content = render_template( - template_name="qms_index.md.j2", - config=config, - templates_dir=TEMPLATES_DIR, - ) - rendered["qms_index"] = ("QM Manual", index_content) - click.echo(" Rendered: qms_index (qms_index.md.j2)") - - for doc in config.documents: - if doc.template is None: - continue - template_file = f"{doc.template}.md.j2" - content = render_template( - template_name=template_file, - config=config, - templates_dir=TEMPLATES_DIR, - extra={ - "procedure_title": doc.title, - "doc_id": f"QMS-{doc.clause}-{doc.id.upper()[:8]}", - "clause": doc.clause, - "owner_role": "Quality Management Officer", - "approver": config.organisation.management, - "author": config.organisation.quality_officer, - "date": "TBD", - # Record-template placeholders — filled in per instance - "audit_date": "TBD", - "auditor": "TBD", - "audit_scope": "TBD", - "contact": "TBD", - "capa_id": "CAPA-XXXX-NNN", - "opened_date": "TBD", - "opened_by": "TBD", - "source_ref": "TBD", - "participants": "TBD", - }, - ) - rendered[doc.id] = (doc.title, content) - click.echo(f" Rendered: {doc.id} ({template_file})") + rendered = _render_all(config) + for doc_id, (title, _) in rendered.items(): + click.echo(f" Rendered: {doc_id} ({title})") if dry_run: click.echo(f"\nDry run complete — {len(rendered)} template(s) rendered, nothing deployed.") @@ -160,46 +180,13 @@ def validate(target: str, config_path: Path) -> None: # Template rendering click.echo("Rendering templates...") render_errors = 0 + templated = sum(1 for d in config.documents if d.template) + 1 # +1 for index try: - render_template("qms_index.md.j2", config=config, templates_dir=TEMPLATES_DIR) + _render_all(config) except Exception as exc: - errors.append(f"qms_index.md.j2: {exc}") + errors.append(f"template rendering failed: {exc}") render_errors += 1 - - for doc in config.documents: - if doc.template is None: - continue - try: - render_template( - f"{doc.template}.md.j2", - config=config, - templates_dir=TEMPLATES_DIR, - extra={ - "procedure_title": doc.title, - "doc_id": f"QMS-{doc.clause}-{doc.id.upper()[:8]}", - "clause": doc.clause, - "owner_role": "Quality Management Officer", - "approver": config.organisation.management, - "author": config.organisation.quality_officer, - "date": "TBD", - "audit_date": "TBD", - "auditor": "TBD", - "audit_scope": "TBD", - "contact": "TBD", - "capa_id": "CAPA-XXXX-NNN", - "opened_date": "TBD", - "opened_by": "TBD", - "source_ref": "TBD", - "participants": "TBD", - }, - ) - except Exception as exc: - errors.append(f"{doc.template}.md.j2: {exc}") - render_errors += 1 - - templated = sum(1 for d in config.documents if d.template) + 1 # +1 for index - rendered_ok = templated - render_errors - click.echo(f" {rendered_ok}/{templated} templates rendered successfully") + click.echo(f" {templated - render_errors}/{templated} templates rendered successfully") if errors: click.echo("\nValidation FAILED:") @@ -210,6 +197,113 @@ def validate(target: str, config_path: Path) -> None: click.echo(f"\nValidation OK — ready to deploy --target {target}") +@cli.command() +@click.option( + "--format", + "fmt", + required=True, + type=click.Choice(["md", "html"]), + help="Output format: md (Markdown files) or html (standalone HTML pages).", +) +@click.option( + "--config", + "config_path", + required=True, + type=click.Path(exists=True, path_type=Path), + help="Path to the client overlay config.", +) +@click.option( + "--output-dir", + "output_dir", + default="qms-export", + show_default=True, + type=click.Path(path_type=Path), + help="Directory to write exported files into.", +) +def export(fmt: str, config_path: Path, output_dir: Path) -> None: + """Export QMS documents as Markdown or standalone HTML files. + + Useful for uploading to SharePoint document libraries, email distribution, + or local archiving. The output directory is created if it does not exist. + """ + import markdown as md_lib + + core_path = CONFIG_DIR / "core.yaml" + click.echo(f"Loading config: core={core_path}, overlay={config_path}") + try: + config = load_config(core_path, config_path) + except Exception as exc: + raise click.ClickException(f"Config invalid: {exc}") from exc + click.echo( + f"Config loaded: {config.organisation.name} " + f"({config.meta.standard}, {len(config.documents)} documents)" + ) + + click.echo("Rendering templates...") + try: + rendered = _render_all(config) + except Exception as exc: + raise click.ClickException(f"Template rendering failed: {exc}") from exc + + output_dir.mkdir(parents=True, exist_ok=True) + + if fmt == "md": + _export_md(rendered, output_dir) + else: + _export_html(rendered, output_dir, config.organisation.name, md_lib) + + click.echo(f"\nExport complete — {len(rendered)} file(s) written to {output_dir}/") + + +def _export_md(rendered: dict[str, tuple[str, str]], output_dir: Path) -> None: + for _doc_id, (title, content) in rendered.items(): + slug = _slugify(title) + out_path = output_dir / f"{slug}.md" + out_path.write_text(content, encoding="utf-8") + click.echo(f" Written: {out_path.name}") + + +def _export_html( + rendered: dict[str, tuple[str, str]], + output_dir: Path, + org_name: str, + md_lib: object, +) -> None: + # Build index entries while writing per-document files + index_entries: list[tuple[str, str]] = [] # (filename, title) + + for _doc_id, (title, md_content) in rendered.items(): + slug = _slugify(title) + filename = f"{slug}.html" + body_html = md_lib.markdown(md_content, extensions=["tables", "fenced_code"]) # type: ignore[attr-defined] + back = '

← Back to QM Manual

' + page = ( + f"" + f"" + f"" + f"{title} — {org_name}" + f"" + f"{back}{body_html}" + ) + (output_dir / filename).write_text(page, encoding="utf-8") + click.echo(f" Written: {filename}") + index_entries.append((filename, title)) + + # Write index.html + items = "\n".join(f'
  • {title}
  • ' for fn, title in index_entries) + index_page = ( + f"" + f"" + f"" + f"QM Manual — {org_name}" + f"" + f"

    QM Manual — {org_name}

    " + f"" + ) + (output_dir / "index.html").write_text(index_page, encoding="utf-8") + click.echo(" Written: index.html") + + def _deploy_atlassian(config: object, rendered: dict[str, tuple[str, str]]) -> None: from adapters.atlassian.confluence import ConfluenceAdapter from adapters.atlassian.jira import JiraAdapter diff --git a/pyproject.toml b/pyproject.toml index aa07b96..654ca29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pyyaml>=6.0", "requests>=2.31", "click>=8.1", + "markdown>=3.10.2", ] [project.optional-dependencies] @@ -31,6 +32,7 @@ dev = [ "hatchling>=1.25", "types-pyyaml>=6.0", "types-requests>=2.31", + "types-markdown>=3.10.2.20260518", ] [project.scripts] diff --git a/readme.md b/readme.md index 7da2563..5236e72 100644 --- a/readme.md +++ b/readme.md @@ -121,6 +121,21 @@ qms-kit deploy --target atlassian --config config/clients/acme.yaml > Confluence and Jira. The email in your overlay config must match the > token owner's Atlassian account. +### Export (M365 / local archive) + +No API credentials needed — generates files you can upload to any document library. + +```bash +# Export as standalone HTML (recommended for SharePoint document libraries) +qms-kit export --format html --config config/clients/acme.yaml --output-dir ./qms-export + +# Export as plain Markdown (version control, email, local archive) +qms-kit export --format md --config config/clients/acme.yaml --output-dir ./qms-export +``` + +The `--output-dir` defaults to `./qms-export/`. HTML output includes an `index.html` +with links to all documents and a "← Back" navigation link on every page. + --- ## 🎯 Deployment targets @@ -129,7 +144,7 @@ qms-kit deploy --target atlassian --config config/clients/acme.yaml |--------|--------|-------| | **Self-hosted** | ✅ Phase 1 complete | XWiki (docs) + Redmine (records) via Docker | | **Atlassian** | ✅ Phase 2 complete | Confluence + Jira via REST API | -| **Microsoft 365** | 🔜 Phase 3 | SharePoint + Planner via Microsoft Graph API | +| **Export (M365 / archive)** | ✅ Phase 3 complete | Markdown + standalone HTML via `qms-kit export` | > **Atlassian note:** Approval workflows and versioning are not native to Confluence Cloud > — a Marketplace app (e.g. Comala) is required; that part is only partially automatable. diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..93f7977 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,98 @@ +"""Unit tests for the qms-kit export command.""" + +from pathlib import Path + +from click.testing import CliRunner + +from cli.main import _slugify, cli + +SELFHOSTED_YAML = Path(__file__).parent.parent / "config" / "selfhosted.yaml" + +_MD_ARGS = ["export", "--format", "md", "--config", str(SELFHOSTED_YAML)] +_HTML_ARGS = ["export", "--format", "html", "--config", str(SELFHOSTED_YAML)] + + +class TestSlugify: + def test_lowercase(self) -> None: + assert _slugify("Quality Policy") == "quality-policy" + + def test_special_chars_replaced(self) -> None: + assert _slugify("ISO 9001:2015") == "iso-9001-2015" + + def test_leading_trailing_stripped(self) -> None: + assert _slugify(" hello ") == "hello" + + def test_multiple_separators_collapsed(self) -> None: + assert _slugify("a -- b") == "a-b" + + +class TestExportMd: + def test_export_md_exits_zero(self, tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(cli, [*_MD_ARGS, "--output-dir", str(tmp_path / "out")]) + assert result.exit_code == 0, result.output + + def test_export_md_creates_files(self, tmp_path: Path) -> None: + out = tmp_path / "out" + runner = CliRunner() + runner.invoke(cli, [*_MD_ARGS, "--output-dir", str(out)]) + assert len(list(out.glob("*.md"))) > 0 + + def test_export_md_files_contain_content(self, tmp_path: Path) -> None: + out = tmp_path / "out" + runner = CliRunner() + runner.invoke(cli, [*_MD_ARGS, "--output-dir", str(out)]) + for f in out.glob("*.md"): + assert f.read_text(encoding="utf-8").strip() != "" + + def test_export_md_prints_count(self, tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(cli, [*_MD_ARGS, "--output-dir", str(tmp_path)]) + assert "Export complete" in result.output + assert "file(s) written" in result.output + + +class TestExportHtml: + def test_export_html_exits_zero(self, tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(cli, [*_HTML_ARGS, "--output-dir", str(tmp_path / "out")]) + assert result.exit_code == 0, result.output + + def test_export_html_creates_index(self, tmp_path: Path) -> None: + out = tmp_path / "out" + runner = CliRunner() + runner.invoke(cli, [*_HTML_ARGS, "--output-dir", str(out)]) + assert (out / "index.html").exists() + + def test_export_html_index_contains_links(self, tmp_path: Path) -> None: + out = tmp_path / "out" + runner = CliRunner() + runner.invoke(cli, [*_HTML_ARGS, "--output-dir", str(out)]) + index = (out / "index.html").read_text(encoding="utf-8") + assert " None: + out = tmp_path / "out" + runner = CliRunner() + runner.invoke(cli, [*_HTML_ARGS, "--output-dir", str(out)]) + for f in out.glob("*.html"): + if f.name == "index.html": + continue + assert "index.html" in f.read_text(encoding="utf-8") + + def test_export_html_valid_structure(self, tmp_path: Path) -> None: + out = tmp_path / "out" + runner = CliRunner() + runner.invoke(cli, [*_HTML_ARGS, "--output-dir", str(out)]) + for f in out.glob("*.html"): + content = f.read_text(encoding="utf-8") + assert "" in content + assert "