kb2md 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kb2md/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """kb2md: Convert Confluence pages to Markdown with attachments."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
kb2md/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m kb2md`."""
2
+
3
+ from kb2md.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
kb2md/cli.py ADDED
@@ -0,0 +1,223 @@
1
+ """Command-line interface for kb2md."""
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from kb2md import __version__, config, utils
11
+ from kb2md.converter import ConfluenceToMarkdown
12
+ from kb2md.subpage import SubpageDownloader
13
+
14
+ LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="kb2md",
20
+ description="Convert Confluence pages to local Markdown with attachments.",
21
+ )
22
+ parser.add_argument(
23
+ "page_ref",
24
+ nargs="?",
25
+ help="Confluence page ID or URL",
26
+ )
27
+ parser.add_argument(
28
+ "--url",
29
+ default=os.getenv("CONFLUENCE_URL"),
30
+ help="Confluence base URL (or set CONFLUENCE_URL)",
31
+ )
32
+ parser.add_argument(
33
+ "--token",
34
+ help="Confluence token/PAT (or set CONFLUENCE_TOKEN, or use --set-default-token)",
35
+ )
36
+ parser.add_argument(
37
+ "--output-dir",
38
+ default="./output",
39
+ help="Directory to save generated markdown and attachments",
40
+ )
41
+ parser.add_argument(
42
+ "--subpage",
43
+ action="store_true",
44
+ help="Download direct subpages into {page_id}_page/",
45
+ )
46
+ parser.add_argument(
47
+ "--all-subpage",
48
+ action="store_true",
49
+ help="Recursively download all subpages",
50
+ )
51
+ parser.add_argument(
52
+ "--no-chrome-cookies",
53
+ action="store_true",
54
+ help="Disable loading Chrome cookies for attachment download fallback",
55
+ )
56
+ parser.add_argument(
57
+ "--set-default-url",
58
+ dest="set_default_url",
59
+ metavar="URL",
60
+ help="Set the default Confluence URL and save it for future runs",
61
+ )
62
+ parser.add_argument(
63
+ "--set-default-token",
64
+ dest="set_default_token",
65
+ metavar="TOKEN",
66
+ help="Set the default Confluence token/PAT and save it for future runs",
67
+ )
68
+ parser.add_argument(
69
+ "--get-default-url",
70
+ action="store_true",
71
+ dest="get_default_url",
72
+ help="Show the currently saved default Confluence URL",
73
+ )
74
+ parser.add_argument(
75
+ "--verbose",
76
+ action="store_true",
77
+ help="Enable verbose logging",
78
+ )
79
+ parser.add_argument(
80
+ "--version",
81
+ action="version",
82
+ version=f"%(prog)s {__version__}",
83
+ )
84
+ return parser
85
+
86
+
87
+ def _handle_default_config_args(args: argparse.Namespace) -> int | None:
88
+ """Handle config commands; return exit code or None."""
89
+ has_config_command = (
90
+ args.set_default_url is not None
91
+ or args.set_default_token is not None
92
+ or args.get_default_url
93
+ )
94
+ if not has_config_command:
95
+ return None
96
+
97
+ if args.page_ref:
98
+ LOGGER.error("Config commands cannot be used with a page reference.")
99
+ return 1
100
+
101
+ if args.set_default_url:
102
+ config.set_default_url(args.set_default_url)
103
+ print(f"Default URL set to: {config.get_default_url()}")
104
+
105
+ if args.set_default_token is not None:
106
+ token = args.set_default_token.strip()
107
+ if not token:
108
+ LOGGER.error("--set-default-token requires a non-empty token.")
109
+ return 1
110
+ config.set_default_token(token)
111
+ print("Default token saved.")
112
+
113
+ if args.get_default_url:
114
+ default_url = config.get_default_url()
115
+ if default_url:
116
+ print(default_url)
117
+ else:
118
+ print("No default URL configured.", file=sys.stderr)
119
+ return 1
120
+
121
+ return 0
122
+
123
+
124
+ def _validate_args(args: argparse.Namespace) -> int | None:
125
+ """Validate positional/mutually-exclusive arguments; return exit code or None."""
126
+ # Using LOGGER.error instead of parser.error because ArgumentParser.error()
127
+ # raises SystemExit, making return-code assertions in unit tests harder.
128
+ if not args.page_ref:
129
+ LOGGER.error("Missing page reference. Provide a page ID or URL.")
130
+ return 1
131
+
132
+ if args.subpage and args.all_subpage:
133
+ LOGGER.error("--subpage and --all-subpage are mutually exclusive.")
134
+ return 1
135
+
136
+ return None
137
+
138
+
139
+ def _resolve_conversion_inputs(args: argparse.Namespace) -> tuple[str, int] | int:
140
+ """Resolve URL and page ID from CLI args; return (url, page_id) or exit code."""
141
+ try:
142
+ base_url, page_id_str = utils.parse_page_ref(args.page_ref)
143
+ except ValueError as e:
144
+ LOGGER.error("Invalid page reference: %s", e)
145
+ return 1
146
+
147
+ try:
148
+ confluence_url = config.resolve_url(args.url, base_url)
149
+ except ValueError as e:
150
+ LOGGER.error("%s", e)
151
+ return 1
152
+
153
+ try:
154
+ page_id = int(page_id_str)
155
+ except ValueError:
156
+ LOGGER.error("Invalid page ID: %r is not a number.", page_id_str)
157
+ return 1
158
+
159
+ return confluence_url, page_id
160
+
161
+
162
+ def _run_conversion(
163
+ confluence_url: str,
164
+ page_id: int,
165
+ token: str,
166
+ args: argparse.Namespace,
167
+ ) -> int:
168
+ """Run the main conversion and optional subpage download."""
169
+ try:
170
+ output_dir = Path(args.output_dir)
171
+ converter = ConfluenceToMarkdown(
172
+ confluence_url=confluence_url,
173
+ token=token,
174
+ use_chrome_cookies=not args.no_chrome_cookies,
175
+ )
176
+ output_dir.mkdir(parents=True, exist_ok=True)
177
+ result_file = converter.confluence_to_markdown(page_id, output_dir=output_dir)
178
+ LOGGER.info("Saved main page to: %s", result_file)
179
+
180
+ if args.subpage or args.all_subpage:
181
+ downloader = SubpageDownloader(converter)
182
+ downloader.download_subpages(page_id, output_dir, recursive=args.all_subpage)
183
+ except Exception as e: # noqa: BLE001 - top-level CLI error handler
184
+ LOGGER.error("Conversion failed: %s", e, exc_info=args.verbose)
185
+ return 1
186
+
187
+ return 0
188
+
189
+
190
+ def main(argv: Sequence[str] | None = None) -> int:
191
+ """Entry point for the kb2md CLI."""
192
+ parser = build_parser()
193
+ args = parser.parse_args(argv)
194
+
195
+ logging.basicConfig(
196
+ level=logging.DEBUG if args.verbose else logging.INFO,
197
+ format="%(asctime)s [%(levelname)s] %(message)s",
198
+ )
199
+
200
+ if (result := _handle_default_config_args(args)) is not None:
201
+ return result
202
+
203
+ if (result := _validate_args(args)) is not None:
204
+ return result
205
+
206
+ resolved = _resolve_conversion_inputs(args)
207
+ if isinstance(resolved, int):
208
+ return resolved
209
+ confluence_url, page_id = resolved
210
+
211
+ token = config.resolve_token(args.token, os.getenv("CONFLUENCE_TOKEN"))
212
+ if not token:
213
+ LOGGER.error(
214
+ "Missing Confluence token. Use --token, set CONFLUENCE_TOKEN, "
215
+ "or use --set-default-token."
216
+ )
217
+ return 1
218
+
219
+ return _run_conversion(confluence_url, page_id, token, args)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ raise SystemExit(main())
kb2md/config.py ADDED
@@ -0,0 +1,113 @@
1
+ """Persistent configuration for kb2md."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import stat
7
+ import uuid
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from kb2md.utils import normalize_url
13
+
14
+ CONFIG_DIR = Path.home() / ".kb2md"
15
+ CONFIG_FILE = CONFIG_DIR / "config.json"
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _load_config() -> dict[str, Any]:
21
+ """Load the config file, returning an empty dict if it is missing or invalid."""
22
+ if not CONFIG_FILE.exists():
23
+ return {}
24
+ try:
25
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
26
+ return data if isinstance(data, dict) else {}
27
+ except (json.JSONDecodeError, OSError) as exc:
28
+ logger.warning("Failed to read config file %s: %s", CONFIG_FILE, exc)
29
+ return {}
30
+
31
+
32
+ def _atomic_write(path: Path, content: str, mode: int) -> None:
33
+ """Write ``content`` to ``path`` atomically with the given permissions.
34
+
35
+ Writes to a temporary file in the same directory with ``mode``, then
36
+ atomically replaces ``path``. This ensures the file is never world-readable,
37
+ even transiently, and that readers always see a complete file.
38
+ """
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ path.parent.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
41
+
42
+ tmp_path = path.with_suffix(f".tmp.{uuid.uuid4().hex}")
43
+ fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
44
+ try:
45
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
46
+ f.write(content)
47
+ os.replace(tmp_path, path)
48
+ # Ensure final permissions are restrictive even if replace preserved old ones.
49
+ path.chmod(mode)
50
+ except Exception:
51
+ with suppress(OSError):
52
+ tmp_path.unlink()
53
+ raise
54
+
55
+
56
+ def _save_config(data: dict[str, Any]) -> None:
57
+ """Save ``data`` to the config file with restrictive permissions."""
58
+ try:
59
+ _atomic_write(
60
+ CONFIG_FILE,
61
+ json.dumps(data, indent=2),
62
+ stat.S_IRUSR | stat.S_IWUSR,
63
+ )
64
+ except OSError as exc:
65
+ logger.error("Failed to write config file %s: %s", CONFIG_FILE, exc)
66
+ raise RuntimeError(f"Could not save configuration to {CONFIG_FILE}: {exc}") from exc
67
+
68
+
69
+ def get_default_url() -> str | None:
70
+ """Return the configured default Confluence URL, or None."""
71
+ url = _load_config().get("default_url")
72
+ return normalize_url(url) if url else None
73
+
74
+
75
+ def set_default_url(url: str) -> None:
76
+ """Save ``url`` as the default Confluence URL."""
77
+ data = _load_config()
78
+ data["default_url"] = normalize_url(url)
79
+ _save_config(data)
80
+
81
+
82
+ def get_default_token() -> str | None:
83
+ """Return the configured default Confluence token, or None."""
84
+ token = _load_config().get("default_token")
85
+ return token if token else None
86
+
87
+
88
+ def set_default_token(token: str) -> None:
89
+ """Save ``token`` as the default Confluence token."""
90
+ data = _load_config()
91
+ data["default_token"] = token
92
+ _save_config(data)
93
+
94
+
95
+ def resolve_url(cli_url: str | None, env_url: str | None) -> str:
96
+ """Resolve the effective Confluence URL from CLI, env, or config."""
97
+ if cli_url:
98
+ return normalize_url(cli_url)
99
+ if env_url:
100
+ return normalize_url(env_url)
101
+ config_url = get_default_url()
102
+ if config_url:
103
+ return config_url
104
+ raise ValueError("No Confluence URL provided")
105
+
106
+
107
+ def resolve_token(cli_token: str | None, env_token: str | None) -> str | None:
108
+ """Resolve the effective Confluence token from CLI, env, or config."""
109
+ if cli_token:
110
+ return cli_token
111
+ if env_token:
112
+ return env_token
113
+ return get_default_token()