eledoctl 1.0.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.
- eledoctl/__init__.py +1 -0
- eledoctl/cli/__init__.py +1 -0
- eledoctl/cli/common.py +25 -0
- eledoctl/cli/documents.py +195 -0
- eledoctl/cli/internal/__init__.py +0 -0
- eledoctl/cli/internal/docs.py +37 -0
- eledoctl/cli/login.py +72 -0
- eledoctl/cli/main.py +30 -0
- eledoctl/cli/profile.py +26 -0
- eledoctl/cli/templates.py +48 -0
- eledoctl/config/__init__.py +1 -0
- eledoctl/config/settings.py +108 -0
- eledoctl/internal/__init__.py +0 -0
- eledoctl/internal/cms/__init__.py +0 -0
- eledoctl/internal/docsync/__init__.py +0 -0
- eledoctl/internal/docsync/slug.py +21 -0
- eledoctl/internal/transform/__init__.py +0 -0
- eledoctl/internal/transform/markdown.py +39 -0
- eledoctl-1.0.0.dist-info/METADATA +154 -0
- eledoctl-1.0.0.dist-info/RECORD +33 -0
- eledoctl-1.0.0.dist-info/WHEEL +4 -0
- eledoctl-1.0.0.dist-info/entry_points.txt +2 -0
- eledoctl-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyeledo/__init__.py +29 -0
- pyeledo/client.py +151 -0
- pyeledo/cms.py +1 -0
- pyeledo/exceptions.py +23 -0
- pyeledo/generate.py +81 -0
- pyeledo/profile.py +22 -0
- pyeledo/schema.py +91 -0
- pyeledo/templates.py +78 -0
- pyeledo/types.py +6 -0
- pyeledo/utils.py +95 -0
eledoctl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line toolkit for Eledo."""
|
eledoctl/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command definitions."""
|
eledoctl/cli/common.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared CLI utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Coroutine
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from eledoctl.config.settings import ConnectionSettings, load_connection_settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
16
|
+
"""Run an asynchronous command implementation."""
|
|
17
|
+
return asyncio.run(coro)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_connection_settings() -> ConnectionSettings:
|
|
21
|
+
"""Load persisted connection settings or raise a user-facing CLI error."""
|
|
22
|
+
try:
|
|
23
|
+
return load_connection_settings()
|
|
24
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
25
|
+
raise click.ClickException(str(exc)) from exc
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""PDF generation CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from eledoctl.cli.common import require_connection_settings, run
|
|
11
|
+
from eledoctl.config.settings import ConnectionSettings
|
|
12
|
+
from pyeledo import EledoClient
|
|
13
|
+
from pyeledo.types import JsonObject
|
|
14
|
+
from pyeledo.utils import parse_json_object
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group("documents")
|
|
18
|
+
def documents_group() -> None:
|
|
19
|
+
"""Document generation commands."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@documents_group.command("generate")
|
|
23
|
+
@click.argument("template_id")
|
|
24
|
+
@click.option("--template-version", type=int, default=None, help="Optional template version.")
|
|
25
|
+
@click.option(
|
|
26
|
+
"--payload",
|
|
27
|
+
type=str,
|
|
28
|
+
default=None,
|
|
29
|
+
help='Inline JSON containing the Eledo "file" object.',
|
|
30
|
+
)
|
|
31
|
+
@click.option(
|
|
32
|
+
"--payload-file",
|
|
33
|
+
type=click.Path(path_type=Path, exists=True, dir_okay=False),
|
|
34
|
+
default=None,
|
|
35
|
+
help='Read the Eledo "file" object from a JSON file.',
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--payload-stdin",
|
|
39
|
+
is_flag=True,
|
|
40
|
+
help='Read the Eledo "file" object from standard input.',
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--add-field",
|
|
44
|
+
"fields",
|
|
45
|
+
multiple=True,
|
|
46
|
+
metavar="KEY=VALUE",
|
|
47
|
+
help=("Add a top-level primitive field. May be repeated. Ignored when a JSON payload source is provided."),
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--output",
|
|
51
|
+
"output_path",
|
|
52
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
53
|
+
default=None,
|
|
54
|
+
help="Document output path. Defaults to filename returned by Eledo.",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--output-dir",
|
|
58
|
+
type=click.Path(path_type=Path, file_okay=False),
|
|
59
|
+
default=None,
|
|
60
|
+
help="Directory for the generated document when --output is not provided.",
|
|
61
|
+
)
|
|
62
|
+
@click.option("--base64-json", is_flag=True, help="Print JSON metadata with base64 PDF content.")
|
|
63
|
+
def generate_pdf(
|
|
64
|
+
template_id: str,
|
|
65
|
+
template_version: int | None,
|
|
66
|
+
payload: str | None,
|
|
67
|
+
payload_file: Path | None,
|
|
68
|
+
payload_stdin: bool,
|
|
69
|
+
fields: tuple[str, ...],
|
|
70
|
+
output_path: Path | None,
|
|
71
|
+
output_dir: Path | None,
|
|
72
|
+
base64_json: bool,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Generate a PDF document from an Eledo template."""
|
|
75
|
+
settings = require_connection_settings()
|
|
76
|
+
run(
|
|
77
|
+
_generate_pdf(
|
|
78
|
+
template_id=template_id,
|
|
79
|
+
settings=settings,
|
|
80
|
+
template_version=template_version,
|
|
81
|
+
file_data=_resolve_payload(
|
|
82
|
+
payload=payload, payload_file=payload_file, payload_stdin=payload_stdin, fields=fields
|
|
83
|
+
),
|
|
84
|
+
output_path=output_path,
|
|
85
|
+
output_dir=output_dir,
|
|
86
|
+
base64_json=base64_json,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _generate_pdf(
|
|
92
|
+
*,
|
|
93
|
+
template_id: str,
|
|
94
|
+
settings: ConnectionSettings,
|
|
95
|
+
template_version: int | None,
|
|
96
|
+
file_data: JsonObject | None,
|
|
97
|
+
output_path: Path | None,
|
|
98
|
+
output_dir: Path | None,
|
|
99
|
+
base64_json: bool,
|
|
100
|
+
) -> None:
|
|
101
|
+
async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
|
|
102
|
+
result = await client.generate_pdf(
|
|
103
|
+
template_id=template_id,
|
|
104
|
+
template_version=template_version,
|
|
105
|
+
file_data=file_data,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if base64_json:
|
|
109
|
+
click.echo(json.dumps(result.as_json(), indent=2))
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
destination = _resolve_output_path(
|
|
113
|
+
filename=result.filename,
|
|
114
|
+
output_path=output_path,
|
|
115
|
+
output_dir=output_dir,
|
|
116
|
+
)
|
|
117
|
+
destination.write_bytes(result.content)
|
|
118
|
+
click.echo(str(destination))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_payload(
|
|
122
|
+
*,
|
|
123
|
+
payload: str | None,
|
|
124
|
+
payload_file: Path | None,
|
|
125
|
+
payload_stdin: bool,
|
|
126
|
+
fields: tuple[str, ...],
|
|
127
|
+
) -> JsonObject | None:
|
|
128
|
+
"""Read and parse document data from one configured input source."""
|
|
129
|
+
source_count = sum(
|
|
130
|
+
(
|
|
131
|
+
payload is not None,
|
|
132
|
+
payload_file is not None,
|
|
133
|
+
payload_stdin,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if source_count > 1:
|
|
138
|
+
raise click.ClickException("Use only one of --payload, --payload-file, or --payload-stdin.")
|
|
139
|
+
|
|
140
|
+
if source_count == 1 and fields:
|
|
141
|
+
click.echo(
|
|
142
|
+
"Warning: --add-field values are ignored because a JSON payload was provided.",
|
|
143
|
+
err=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if payload is not None:
|
|
147
|
+
return parse_json_object(payload) or None
|
|
148
|
+
|
|
149
|
+
if payload_file is not None:
|
|
150
|
+
return parse_json_object(payload_file.read_text(encoding="utf-8")) or None
|
|
151
|
+
|
|
152
|
+
if payload_stdin:
|
|
153
|
+
return parse_json_object(click.get_text_stream("stdin").read()) or None
|
|
154
|
+
|
|
155
|
+
return _build_field_payload(fields)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_field_payload(fields: tuple[str, ...]) -> JsonObject | None:
|
|
159
|
+
"""Build a JSON object from repeated KEY=VALUE field arguments."""
|
|
160
|
+
if not fields:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
payload: JsonObject = {}
|
|
164
|
+
|
|
165
|
+
for field in fields:
|
|
166
|
+
key, separator, value = field.partition("=")
|
|
167
|
+
|
|
168
|
+
if separator == "":
|
|
169
|
+
raise click.ClickException(f"Invalid field {field!r}. Expected KEY=VALUE.")
|
|
170
|
+
|
|
171
|
+
key = key.strip()
|
|
172
|
+
|
|
173
|
+
if not key:
|
|
174
|
+
raise click.ClickException(f"Invalid field {field!r}. Field name cannot be empty.")
|
|
175
|
+
|
|
176
|
+
payload[key] = value
|
|
177
|
+
|
|
178
|
+
return payload
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_output_path(
|
|
182
|
+
*,
|
|
183
|
+
filename: str,
|
|
184
|
+
output_path: Path | None,
|
|
185
|
+
output_dir: Path | None,
|
|
186
|
+
) -> Path:
|
|
187
|
+
"""Resolve the destination path for a generated document."""
|
|
188
|
+
if output_path is not None:
|
|
189
|
+
return output_path
|
|
190
|
+
|
|
191
|
+
if output_dir is not None:
|
|
192
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
return output_dir / filename
|
|
194
|
+
|
|
195
|
+
return Path(filename)
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Internal documentation synchronization CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group("internal")
|
|
11
|
+
def internal_group() -> None:
|
|
12
|
+
"""Internal Eledo operational tooling."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@internal_group.group("docs")
|
|
16
|
+
def internal_docs_group() -> None:
|
|
17
|
+
"""Internal documentation synchronization tooling."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@internal_docs_group.command("sync")
|
|
21
|
+
@click.argument("path", type=click.Path(path_type=Path))
|
|
22
|
+
@click.option(
|
|
23
|
+
"--review-report",
|
|
24
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
25
|
+
default=None,
|
|
26
|
+
help="Optional path where manual review report will be written.",
|
|
27
|
+
)
|
|
28
|
+
@click.option("--dry-run", is_flag=True, help="Analyze without uploading changes.")
|
|
29
|
+
def sync_docs(path: Path, review_report: Path | None, dry_run: bool) -> None:
|
|
30
|
+
"""Synchronize Git documentation into Eledo CMS.
|
|
31
|
+
|
|
32
|
+
Implementation will be added after the Eledo CMS CRUD API is available.
|
|
33
|
+
"""
|
|
34
|
+
click.echo("Documentation sync scaffold is ready, but implementation is pending.")
|
|
35
|
+
click.echo(f"path={path}")
|
|
36
|
+
click.echo(f"review_report={review_report}")
|
|
37
|
+
click.echo(f"dry_run={dry_run}")
|
eledoctl/cli/login.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Login CLI command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from eledoctl.cli.common import run
|
|
11
|
+
from eledoctl.config.settings import DEFAULT_BASE_URL, ConnectionSettings, save_connection_settings
|
|
12
|
+
from pyeledo import EledoApiError, EledoAuthenticationError, EledoClient
|
|
13
|
+
from pyeledo.profile import Profile
|
|
14
|
+
|
|
15
|
+
LOGIN_PATH = "/app/login/start"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command("login")
|
|
19
|
+
@click.option(
|
|
20
|
+
"--base-url",
|
|
21
|
+
default=DEFAULT_BASE_URL,
|
|
22
|
+
show_default=True,
|
|
23
|
+
help="Eledo base URL.",
|
|
24
|
+
)
|
|
25
|
+
def login(base_url: str) -> None:
|
|
26
|
+
"""Authenticate eledoctl with an Eledo API token."""
|
|
27
|
+
normalized_base_url = base_url.rstrip("/")
|
|
28
|
+
login_url = f"{normalized_base_url}{LOGIN_PATH}"
|
|
29
|
+
|
|
30
|
+
click.echo("Opening Eledo in your browser.")
|
|
31
|
+
click.echo()
|
|
32
|
+
click.echo("To obtain your API token:")
|
|
33
|
+
click.echo(" 1. Log in to your Eledo account.")
|
|
34
|
+
click.echo(" 2. Open Profile in the bottom-left corner.")
|
|
35
|
+
click.echo(" 3. Open the API tab under Your Profile.")
|
|
36
|
+
click.echo(" 4. Generate an API key if needed, then copy it.")
|
|
37
|
+
click.echo()
|
|
38
|
+
click.echo(f"Login URL: {login_url}")
|
|
39
|
+
|
|
40
|
+
if not webbrowser.open(login_url, new=2):
|
|
41
|
+
click.echo("The browser could not be opened automatically. Open the URL above manually.", err=True)
|
|
42
|
+
|
|
43
|
+
click.echo()
|
|
44
|
+
token = click.prompt("Paste your Eledo API token", hide_input=True).strip()
|
|
45
|
+
|
|
46
|
+
if not token:
|
|
47
|
+
raise click.ClickException("The API token cannot be empty.")
|
|
48
|
+
|
|
49
|
+
settings = ConnectionSettings(
|
|
50
|
+
base_url=normalized_base_url,
|
|
51
|
+
token=token,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
profile = run(_validate_token(settings))
|
|
56
|
+
except (EledoApiError, EledoAuthenticationError) as exc:
|
|
57
|
+
raise click.ClickException(f"Authentication failed: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
save_connection_settings(ConnectionSettings(base_url=normalized_base_url, token=token))
|
|
61
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
62
|
+
raise click.ClickException(f"Could not save the local configuration: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
click.echo()
|
|
65
|
+
click.echo(f"Authenticated as {profile.account}.")
|
|
66
|
+
click.echo("The API token has been saved to the local eledoctl configuration.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _validate_token(settings: ConnectionSettings) -> Profile:
|
|
70
|
+
"""Validate an Eledo API token and return its profile."""
|
|
71
|
+
async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
|
|
72
|
+
return await client.get_profile()
|
eledoctl/cli/main.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Top-level eledoctl command tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from eledoctl.cli.documents import documents_group
|
|
8
|
+
from eledoctl.cli.internal.docs import internal_group
|
|
9
|
+
from eledoctl.cli.login import login
|
|
10
|
+
from eledoctl.cli.profile import profile
|
|
11
|
+
from eledoctl.cli.templates import templates
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
15
|
+
def main() -> None:
|
|
16
|
+
"""Command-line toolkit for Eledo."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
main.add_command(login)
|
|
20
|
+
main.add_command(profile)
|
|
21
|
+
main.add_command(templates)
|
|
22
|
+
main.add_command(documents_group)
|
|
23
|
+
main.add_command(internal_group)
|
|
24
|
+
|
|
25
|
+
# Compatibility alias for direct imports.
|
|
26
|
+
app = main
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
main()
|
eledoctl/cli/profile.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Profile CLI command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from eledoctl.cli.common import require_connection_settings, run
|
|
10
|
+
from eledoctl.config.settings import ConnectionSettings
|
|
11
|
+
from pyeledo import EledoClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command("profile")
|
|
15
|
+
def profile() -> None:
|
|
16
|
+
"""Fetch current Eledo profile."""
|
|
17
|
+
settings = require_connection_settings()
|
|
18
|
+
run(_profile(settings=settings))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _profile(*, settings: ConnectionSettings) -> None:
|
|
22
|
+
"""Fetch and print the current Eledo profile."""
|
|
23
|
+
async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
|
|
24
|
+
result = await client.get_profile()
|
|
25
|
+
|
|
26
|
+
click.echo(json.dumps({"account": result.account}, indent=2))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Template CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from eledoctl.cli.common import require_connection_settings, run
|
|
10
|
+
from eledoctl.config.settings import ConnectionSettings
|
|
11
|
+
from pyeledo import EledoClient, TemplateScope
|
|
12
|
+
from pyeledo.types import JsonValue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("templates")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--scope",
|
|
18
|
+
type=click.Choice(["private", "public"], case_sensitive=False),
|
|
19
|
+
default="private",
|
|
20
|
+
show_default=True,
|
|
21
|
+
help="Template scope.",
|
|
22
|
+
)
|
|
23
|
+
def templates(scope: str) -> None:
|
|
24
|
+
"""List Eledo templates."""
|
|
25
|
+
settings = require_connection_settings()
|
|
26
|
+
template_scope = TemplateScope.PUBLIC if scope.lower() == "public" else TemplateScope.PRIVATE
|
|
27
|
+
run(_templates(settings=settings, scope=template_scope))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _templates(*, settings: ConnectionSettings, scope: TemplateScope) -> None:
|
|
31
|
+
"""Fetch and print Eledo templates for the selected scope."""
|
|
32
|
+
async with EledoClient(base_url=settings.base_url, token=settings.token) as client:
|
|
33
|
+
result = await client.get_templates(scope=scope)
|
|
34
|
+
|
|
35
|
+
payload: JsonValue = {
|
|
36
|
+
"total": result.total,
|
|
37
|
+
"templates": [
|
|
38
|
+
{
|
|
39
|
+
"id": template.id,
|
|
40
|
+
"name": template.name,
|
|
41
|
+
"version": template.version,
|
|
42
|
+
"bulk": template.bulk,
|
|
43
|
+
}
|
|
44
|
+
for template in result.templates
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
click.echo(json.dumps(payload, indent=2))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local eledoctl configuration."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Local configuration persistence for eledoctl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_config_path
|
|
12
|
+
|
|
13
|
+
from pyeledo.types import JsonObject, JsonValue
|
|
14
|
+
|
|
15
|
+
APP_NAME = "eledoctl"
|
|
16
|
+
CONFIG_FILE = "config.json"
|
|
17
|
+
DEFAULT_BASE_URL = "https://eledo.online"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ConnectionSettings:
|
|
22
|
+
base_url: str
|
|
23
|
+
token: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_connection_settings() -> ConnectionSettings:
|
|
27
|
+
"""Load the default persisted Eledo connection settings."""
|
|
28
|
+
config = load_config()
|
|
29
|
+
default = config.get("default")
|
|
30
|
+
|
|
31
|
+
if not isinstance(default, dict):
|
|
32
|
+
raise ValueError("No Eledo credentials found. Run `eledoctl login` first.")
|
|
33
|
+
|
|
34
|
+
base_url = default.get("base_url")
|
|
35
|
+
token = default.get("token")
|
|
36
|
+
|
|
37
|
+
if not isinstance(base_url, str) or not base_url:
|
|
38
|
+
raise ValueError("Stored Eledo base URL is missing or invalid.")
|
|
39
|
+
|
|
40
|
+
if not isinstance(token, str) or not token:
|
|
41
|
+
raise ValueError("Stored Eledo API token is missing or invalid.")
|
|
42
|
+
|
|
43
|
+
return ConnectionSettings(
|
|
44
|
+
base_url=base_url,
|
|
45
|
+
token=token,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def config_dir() -> Path:
|
|
50
|
+
"""Return the user configuration directory."""
|
|
51
|
+
path = user_config_path(APP_NAME, ensure_exists=True)
|
|
52
|
+
|
|
53
|
+
if os.name == "posix":
|
|
54
|
+
path.chmod(0o700)
|
|
55
|
+
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def config_path() -> Path:
|
|
60
|
+
"""Return the local configuration file path."""
|
|
61
|
+
return config_dir() / CONFIG_FILE
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_config() -> JsonObject:
|
|
65
|
+
"""Load the local eledoctl configuration."""
|
|
66
|
+
path = config_path()
|
|
67
|
+
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
data: JsonValue = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
raise ValueError("Configuration root must be a JSON object.")
|
|
75
|
+
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def save_config(config: JsonObject) -> None:
|
|
80
|
+
"""Persist the local configuration atomically."""
|
|
81
|
+
destination = config_path()
|
|
82
|
+
|
|
83
|
+
with tempfile.NamedTemporaryFile(
|
|
84
|
+
mode="w",
|
|
85
|
+
encoding="utf-8",
|
|
86
|
+
dir=destination.parent,
|
|
87
|
+
prefix=f".{destination.name}.",
|
|
88
|
+
suffix=".tmp",
|
|
89
|
+
delete=False,
|
|
90
|
+
) as temporary_file:
|
|
91
|
+
json.dump(config, temporary_file, indent=2, sort_keys=True)
|
|
92
|
+
temporary_file.write("\n")
|
|
93
|
+
temporary_path = Path(temporary_file.name)
|
|
94
|
+
|
|
95
|
+
if os.name == "posix":
|
|
96
|
+
temporary_path.chmod(0o600)
|
|
97
|
+
|
|
98
|
+
temporary_path.replace(destination)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def save_connection_settings(settings: ConnectionSettings) -> None:
|
|
102
|
+
"""Persist the default Eledo connection and API token."""
|
|
103
|
+
config = load_config()
|
|
104
|
+
config["default"] = {
|
|
105
|
+
"base_url": settings.base_url.rstrip("/"),
|
|
106
|
+
"token": settings.token,
|
|
107
|
+
}
|
|
108
|
+
save_config(config)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Slug generation helpers for documentation synchronization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_slug(path: Path, *, docs_root: Path) -> str:
|
|
9
|
+
"""Build CMS slug from a documentation file path.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
docs/product/template-editor.md -> /product/template-editor
|
|
13
|
+
"""
|
|
14
|
+
relative = path.relative_to(docs_root)
|
|
15
|
+
without_suffix = relative.with_suffix("")
|
|
16
|
+
|
|
17
|
+
parts = list(without_suffix.parts)
|
|
18
|
+
if parts[-1] == "index":
|
|
19
|
+
parts = parts[:-1]
|
|
20
|
+
|
|
21
|
+
return "/" + "/".join(parts)
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Markdown transformation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class GitDocument:
|
|
11
|
+
"""Parsed Git document with frontmatter separated from body."""
|
|
12
|
+
|
|
13
|
+
frontmatter: dict[str, Any]
|
|
14
|
+
content: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_git_document(text: str) -> GitDocument:
|
|
18
|
+
"""Parse a Markdown document and strip simple YAML frontmatter.
|
|
19
|
+
|
|
20
|
+
This intentionally avoids depending on a full YAML parser in the scaffold.
|
|
21
|
+
Complex frontmatter support can be added later if needed.
|
|
22
|
+
"""
|
|
23
|
+
if not text.startswith("---\n"):
|
|
24
|
+
return GitDocument(frontmatter={}, content=text.strip())
|
|
25
|
+
|
|
26
|
+
marker = "\n---\n"
|
|
27
|
+
end = text.find(marker, 4)
|
|
28
|
+
if end == -1:
|
|
29
|
+
return GitDocument(frontmatter={}, content=text.strip())
|
|
30
|
+
|
|
31
|
+
raw_frontmatter = text[4:end]
|
|
32
|
+
body = text[end + len(marker) :]
|
|
33
|
+
frontmatter: dict[str, Any] = {}
|
|
34
|
+
for line in raw_frontmatter.splitlines():
|
|
35
|
+
if not line.strip() or ":" not in line:
|
|
36
|
+
continue
|
|
37
|
+
key, value = line.split(":", 1)
|
|
38
|
+
frontmatter[key.strip()] = value.strip().strip("\"'")
|
|
39
|
+
return GitDocument(frontmatter=frontmatter, content=body.strip())
|