docspecbridge 0.2.0b1__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.
- docspecbridge/__init__.py +1 -0
- docspecbridge/cli.py +196 -0
- docspecbridge/config.py +213 -0
- docspecbridge/config_ui.py +186 -0
- docspecbridge/confluence.py +178 -0
- docspecbridge/doctor.py +46 -0
- docspecbridge/extractor.py +824 -0
- docspecbridge/geometry.py +478 -0
- docspecbridge/ooxml.py +132 -0
- docspecbridge/rag.py +168 -0
- docspecbridge/utils.py +58 -0
- docspecbridge-0.2.0b1.dist-info/METADATA +242 -0
- docspecbridge-0.2.0b1.dist-info/RECORD +15 -0
- docspecbridge-0.2.0b1.dist-info/WHEEL +4 -0
- docspecbridge-0.2.0b1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0b1"
|
docspecbridge/cli.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Prompt
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import confluence_instances, load_config
|
|
13
|
+
from .config_ui import config_menu
|
|
14
|
+
from .confluence import list_spaces, publish
|
|
15
|
+
from .doctor import doctor_info
|
|
16
|
+
from .extractor import run_extract
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(add_completion=False, no_args_is_help=False, help="DocSpecBridge - document ETL bridge")
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cfg(config: Optional[Path]):
|
|
24
|
+
return load_config(config)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _apply_overwrite(cfg: dict, overwrite: bool) -> None:
|
|
28
|
+
if overwrite:
|
|
29
|
+
cfg["app"]["overwrite"] = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _select_instance(cfg: dict, default: str | None = None) -> str:
|
|
33
|
+
names = list(confluence_instances(cfg))
|
|
34
|
+
if not names:
|
|
35
|
+
raise RuntimeError("Aucune instance Confluence configurée. Utiliser le menu Configuration YAML.")
|
|
36
|
+
if len(names) == 1:
|
|
37
|
+
return names[0]
|
|
38
|
+
default_name = default or str(cfg.get("confluence", {}).get("default_instance") or names[0])
|
|
39
|
+
for idx, name in enumerate(names, 1):
|
|
40
|
+
marker = " *" if name == default_name else ""
|
|
41
|
+
console.print(f"[{idx}] {name}{marker}")
|
|
42
|
+
default_idx = str(names.index(default_name) + 1) if default_name in names else "1"
|
|
43
|
+
idx = int(Prompt.ask("Instance Confluence", default=default_idx))
|
|
44
|
+
return names[idx - 1]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback(invoke_without_command=True)
|
|
48
|
+
def main(ctx: typer.Context) -> None:
|
|
49
|
+
"""Sans sous-commande, ouvre le menu interactif."""
|
|
50
|
+
if ctx.invoked_subcommand is None:
|
|
51
|
+
menu()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def extract(
|
|
56
|
+
source: Annotated[Optional[Path], typer.Option("--source", "-s", help="Fichier ou répertoire source")] = None,
|
|
57
|
+
dest: Annotated[Optional[Path], typer.Option("--dest", "-d", help="Répertoire de destination")] = None,
|
|
58
|
+
config: Annotated[Optional[Path], typer.Option("--config", "-c", help="YAML de configuration")] = None,
|
|
59
|
+
overwrite: Annotated[bool, typer.Option("--overwrite", help="Autoriser la réécriture des packages existants")] = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Extrait DOCX/PDF/PPTX vers publication Markdown + RAG + images."""
|
|
62
|
+
cfg = _cfg(config)
|
|
63
|
+
_apply_overwrite(cfg, overwrite)
|
|
64
|
+
source = source or Path(cfg["app"]["source"])
|
|
65
|
+
dest = dest or Path(cfg["app"]["destination"])
|
|
66
|
+
outcomes = run_extract(cfg, source, dest)
|
|
67
|
+
|
|
68
|
+
table = Table(title="Extraction DocSpecBridge")
|
|
69
|
+
table.add_column("Source")
|
|
70
|
+
table.add_column("Etat")
|
|
71
|
+
table.add_column("Images", justify="right")
|
|
72
|
+
table.add_column("Package")
|
|
73
|
+
for item in outcomes:
|
|
74
|
+
state = f"ERROR: {item.error}" if item.error else ("WARNING" if item.warnings else "OK")
|
|
75
|
+
table.add_row(item.source.name, state, str(len(item.images)), str(item.package_dir))
|
|
76
|
+
for warning in item.warnings:
|
|
77
|
+
console.print(f"[yellow] ! {item.source.name}: {warning}[/yellow]")
|
|
78
|
+
console.print(table)
|
|
79
|
+
if not outcomes:
|
|
80
|
+
console.print("[yellow]Aucun fichier correspondant aux extensions configurées.[/yellow]")
|
|
81
|
+
if any(item.error for item in outcomes):
|
|
82
|
+
raise typer.Exit(2)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("spaces")
|
|
86
|
+
def spaces_cmd(
|
|
87
|
+
instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Nom d'instance Confluence YAML")] = None,
|
|
88
|
+
config: Annotated[Optional[Path], typer.Option("--config", "-c")] = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Liste les espaces Confluence Cloud visibles pour une instance."""
|
|
91
|
+
cfg = _cfg(config)
|
|
92
|
+
selected = instance or _select_instance(cfg)
|
|
93
|
+
spaces = list_spaces(cfg, selected)
|
|
94
|
+
table = Table(title=f"Espaces Confluence - {selected}")
|
|
95
|
+
table.add_column("Key")
|
|
96
|
+
table.add_column("Name")
|
|
97
|
+
table.add_column("ID")
|
|
98
|
+
for space in spaces:
|
|
99
|
+
table.add_row(str(space.get("key", "")), str(space.get("name", "")), str(space.get("id", "")))
|
|
100
|
+
console.print(table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("publish")
|
|
104
|
+
def publish_cmd(
|
|
105
|
+
source: Annotated[Path, typer.Option("--source", "-s", help="Package, corpus ou fichier .md")],
|
|
106
|
+
instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Nom d'instance Confluence YAML")] = None,
|
|
107
|
+
space: Annotated[Optional[str], typer.Option("--space", help="Clé espace Confluence")] = None,
|
|
108
|
+
parent: Annotated[Optional[str], typer.Option("--parent", help="Page ID parent/root")] = None,
|
|
109
|
+
config: Annotated[Optional[Path], typer.Option("--config", "-c")] = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Publie les Markdown publication dans Confluence Cloud avec leurs images inline."""
|
|
112
|
+
cfg = _cfg(config)
|
|
113
|
+
selected = instance or _select_instance(cfg)
|
|
114
|
+
published = publish(cfg, source, space_key=space, root_page=parent, instance_name=selected)
|
|
115
|
+
console.print(f"[green]{len(published)} page(s) traitée(s) par md2conf sur {selected}.[/green]")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
app.command("import-confluence")(publish_cmd)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("config")
|
|
122
|
+
def config_cmd(
|
|
123
|
+
config: Annotated[Optional[Path], typer.Option("--config", "-c")] = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Ouvre le petit éditeur interactif du YAML DocSpecBridge."""
|
|
126
|
+
config_menu(config)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def doctor(
|
|
131
|
+
config: Annotated[Optional[Path], typer.Option("--config", "-c")] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Affiche versions, proxy et instances/token Confluence."""
|
|
134
|
+
cfg = _cfg(config)
|
|
135
|
+
table = Table(title="DocSpecBridge doctor")
|
|
136
|
+
table.add_column("Elément")
|
|
137
|
+
table.add_column("Valeur")
|
|
138
|
+
for key, value in doctor_info(cfg).items():
|
|
139
|
+
table.add_row(key, value)
|
|
140
|
+
console.print(table)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def menu() -> None:
|
|
144
|
+
console.print(f"\n[bold cyan]DocSpecBridge {__version__}[/bold cyan]")
|
|
145
|
+
console.print("Document ETL: Office/PDF -> publication + RAG -> Confluence Cloud\n")
|
|
146
|
+
while True:
|
|
147
|
+
console.print("[1] Extract")
|
|
148
|
+
console.print("[2] Import Confluence")
|
|
149
|
+
console.print("[3] Lister les espaces Confluence")
|
|
150
|
+
console.print("[4] Doctor")
|
|
151
|
+
console.print("[5] Configuration YAML")
|
|
152
|
+
console.print("[0] Quitter")
|
|
153
|
+
choice = Prompt.ask("Action", choices=["0", "1", "2", "3", "4", "5"], default="1")
|
|
154
|
+
try:
|
|
155
|
+
if choice == "0":
|
|
156
|
+
return
|
|
157
|
+
if choice == "1":
|
|
158
|
+
cfg = load_config()
|
|
159
|
+
source = Path(Prompt.ask("Source", default=str(cfg["app"]["source"])))
|
|
160
|
+
dest = Path(Prompt.ask("Destination", default=str(cfg["app"]["destination"])))
|
|
161
|
+
outcomes = run_extract(cfg, source, dest)
|
|
162
|
+
for item in outcomes:
|
|
163
|
+
if item.error:
|
|
164
|
+
console.print(f"[red]ERROR[/red] {item.source}: {item.error}")
|
|
165
|
+
else:
|
|
166
|
+
console.print(f"[green]OK[/green] {item.source.name} -> {item.package_dir} ({len(item.images)} image(s))")
|
|
167
|
+
for warning in item.warnings:
|
|
168
|
+
console.print(f"[yellow] ! {warning}[/yellow]")
|
|
169
|
+
elif choice == "2":
|
|
170
|
+
cfg = load_config()
|
|
171
|
+
selected = _select_instance(cfg)
|
|
172
|
+
source = Path(Prompt.ask("Package/corpus à publier", default=str(cfg["app"]["destination"])))
|
|
173
|
+
spaces = list_spaces(cfg, selected)
|
|
174
|
+
for idx, item in enumerate(spaces, start=1):
|
|
175
|
+
console.print(f"[{idx}] {item.get('key', '')} - {item.get('name', '')}")
|
|
176
|
+
selected_space = int(Prompt.ask("Numéro espace"))
|
|
177
|
+
space = str(spaces[selected_space - 1].get("key"))
|
|
178
|
+
instance_cfg = confluence_instances(cfg)[selected]
|
|
179
|
+
default_parent = str(instance_cfg.get("root_page") or "")
|
|
180
|
+
parent = Prompt.ask("Page ID parent (vide = accueil espace)", default=default_parent).strip() or None
|
|
181
|
+
pages = publish(cfg, source, space_key=space, root_page=parent, instance_name=selected)
|
|
182
|
+
console.print(f"[green]{len(pages)} page(s) publiée(s)/synchronisée(s) sur {selected}.[/green]")
|
|
183
|
+
elif choice == "3":
|
|
184
|
+
cfg = load_config()
|
|
185
|
+
selected = _select_instance(cfg)
|
|
186
|
+
for item in list_spaces(cfg, selected):
|
|
187
|
+
console.print(f"{item.get('key', ''):15} {item.get('name', '')}")
|
|
188
|
+
elif choice == "4":
|
|
189
|
+
cfg = load_config()
|
|
190
|
+
for key, value in doctor_info(cfg).items():
|
|
191
|
+
console.print(f"{key:30} {value}")
|
|
192
|
+
elif choice == "5":
|
|
193
|
+
config_menu()
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
console.print(f"[red]Erreur: {exc}[/red]")
|
|
196
|
+
console.print()
|
docspecbridge/config.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
11
|
+
"app": {
|
|
12
|
+
"source": "./input",
|
|
13
|
+
"destination": "./output",
|
|
14
|
+
"extensions": [".docx", ".pdf", ".pptx"],
|
|
15
|
+
"recursive": True,
|
|
16
|
+
"preserve_source_tree": True,
|
|
17
|
+
"copy_source": True,
|
|
18
|
+
"overwrite": False,
|
|
19
|
+
},
|
|
20
|
+
"extract": {
|
|
21
|
+
"engine": "xberg",
|
|
22
|
+
"use_cache": True,
|
|
23
|
+
"enable_quality_processing": True,
|
|
24
|
+
"output_format": "markdown",
|
|
25
|
+
"extraction_timeout_secs": 600,
|
|
26
|
+
"images": {
|
|
27
|
+
"extract_images": True,
|
|
28
|
+
"target_dpi": 300,
|
|
29
|
+
"max_image_dimension": 4096,
|
|
30
|
+
"auto_adjust_dpi": True,
|
|
31
|
+
"inject_placeholders": True,
|
|
32
|
+
"include_data_base64": True,
|
|
33
|
+
"run_ocr_on_images": False,
|
|
34
|
+
},
|
|
35
|
+
"deduplicate_assets": True,
|
|
36
|
+
"docx_layout": {
|
|
37
|
+
"include_header_images_once": True,
|
|
38
|
+
"include_footer_images_once": False,
|
|
39
|
+
},
|
|
40
|
+
"pdf_layout": {
|
|
41
|
+
"collapse_repeated_images": True,
|
|
42
|
+
"repeat_threshold": 3,
|
|
43
|
+
},
|
|
44
|
+
"pdf_options": {
|
|
45
|
+
"extract_images": True,
|
|
46
|
+
"extract_tables": True,
|
|
47
|
+
"extract_metadata": True,
|
|
48
|
+
},
|
|
49
|
+
"diagnostics": {
|
|
50
|
+
"inspect_ooxml": True,
|
|
51
|
+
"warn_on_unresolved_images": True,
|
|
52
|
+
"keep_raw_xberg_markdown": True,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"profiles": {
|
|
56
|
+
"publication": {
|
|
57
|
+
"enabled": True,
|
|
58
|
+
"preserve_image_display_size": True,
|
|
59
|
+
"display_image_directory": "publication_images",
|
|
60
|
+
"min_display_px": 12,
|
|
61
|
+
"max_display_px": 1800,
|
|
62
|
+
"avoid_upscale": False,
|
|
63
|
+
"preserve_formatting": True,
|
|
64
|
+
},
|
|
65
|
+
"rag": {
|
|
66
|
+
"enabled": True,
|
|
67
|
+
"write_descriptor": True,
|
|
68
|
+
"keep_image_references": True,
|
|
69
|
+
"include_header_images": False,
|
|
70
|
+
"include_footer_images": False,
|
|
71
|
+
"collapse_repeated_images": True,
|
|
72
|
+
"token_reduction": "off",
|
|
73
|
+
"chunking": {
|
|
74
|
+
"enabled": True,
|
|
75
|
+
"max_characters": 1600,
|
|
76
|
+
"overlap": 150,
|
|
77
|
+
"prepend_heading_context": True,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"confluence": {
|
|
82
|
+
"default_instance": "",
|
|
83
|
+
"instances": {},
|
|
84
|
+
"layout": {
|
|
85
|
+
"image_alignment": "center",
|
|
86
|
+
"image_max_width": 1600,
|
|
87
|
+
"table_display_mode": "responsive",
|
|
88
|
+
},
|
|
89
|
+
"keep_hierarchy": False,
|
|
90
|
+
"overwrite_manual_changes": False,
|
|
91
|
+
"write_page_id_to_markdown": False,
|
|
92
|
+
"render_mermaid": False,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
result = deepcopy(base)
|
|
99
|
+
for key, value in override.items():
|
|
100
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
101
|
+
result[key] = _deep_merge(result[key], value)
|
|
102
|
+
else:
|
|
103
|
+
result[key] = value
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _migrate_legacy(data: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
data = deepcopy(data)
|
|
109
|
+
|
|
110
|
+
# 0.1.x had a top-level `rag` section.
|
|
111
|
+
legacy_rag = data.pop("rag", None)
|
|
112
|
+
if isinstance(legacy_rag, dict):
|
|
113
|
+
profiles = data.setdefault("profiles", {})
|
|
114
|
+
profiles["rag"] = _deep_merge(profiles.get("rag", {}), legacy_rag)
|
|
115
|
+
|
|
116
|
+
# 0.1.x described one Confluence endpoint directly under `confluence`.
|
|
117
|
+
cf = data.get("confluence")
|
|
118
|
+
if isinstance(cf, dict) and not cf.get("instances"):
|
|
119
|
+
legacy_keys = {
|
|
120
|
+
"domain",
|
|
121
|
+
"base_path",
|
|
122
|
+
"api_url",
|
|
123
|
+
"user_name",
|
|
124
|
+
"token_env",
|
|
125
|
+
"api_version",
|
|
126
|
+
"default_space",
|
|
127
|
+
"root_page",
|
|
128
|
+
}
|
|
129
|
+
if any(cf.get(key) not in (None, "") for key in legacy_keys):
|
|
130
|
+
instance = {key: cf.get(key) for key in legacy_keys if key in cf}
|
|
131
|
+
instance.setdefault("base_path", "/wiki/")
|
|
132
|
+
instance.setdefault("token_env", "ATLASSIAN_API_TOKEN")
|
|
133
|
+
instance.setdefault("api_version", "v2")
|
|
134
|
+
cf["instances"] = {"default": instance}
|
|
135
|
+
cf["default_instance"] = cf.get("default_instance") or "default"
|
|
136
|
+
for key in legacy_keys:
|
|
137
|
+
cf.pop(key, None)
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def find_default_config() -> Path | None:
|
|
142
|
+
for name in ("docspecbridge.yaml", "docspecbridge.yml", "config.yaml", "config.yml"):
|
|
143
|
+
path = Path.cwd() / name
|
|
144
|
+
if path.is_file():
|
|
145
|
+
return path
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def selected_config_path(path: Path | None = None) -> Path:
|
|
150
|
+
return (path or find_default_config() or (Path.cwd() / "docspecbridge.yaml")).resolve()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_config(path: Path | None = None) -> dict[str, Any]:
|
|
154
|
+
selected = path or find_default_config()
|
|
155
|
+
if selected is None:
|
|
156
|
+
return deepcopy(DEFAULT_CONFIG)
|
|
157
|
+
with selected.open("r", encoding="utf-8") as handle:
|
|
158
|
+
data = yaml.safe_load(handle) or {}
|
|
159
|
+
if not isinstance(data, dict):
|
|
160
|
+
raise ValueError(f"Configuration YAML invalide: {selected}")
|
|
161
|
+
return _deep_merge(DEFAULT_CONFIG, _migrate_legacy(data))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def save_config(config: dict[str, Any], path: Path | None = None) -> Path:
|
|
165
|
+
selected = selected_config_path(path)
|
|
166
|
+
selected.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
selected.write_text(
|
|
168
|
+
yaml.safe_dump(config, allow_unicode=True, sort_keys=False, width=120),
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
)
|
|
171
|
+
return selected
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def init_config(path: Path | None = None, *, overwrite: bool = False) -> Path:
|
|
175
|
+
selected = selected_config_path(path)
|
|
176
|
+
if selected.exists() and not overwrite:
|
|
177
|
+
return selected
|
|
178
|
+
return save_config(deepcopy(DEFAULT_CONFIG), selected)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def confluence_instances(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
182
|
+
cf = config.get("confluence") or {}
|
|
183
|
+
instances = cf.get("instances") or {}
|
|
184
|
+
return {str(name): dict(value or {}) for name, value in instances.items()}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_confluence_instance(config: dict[str, Any], name: str | None = None) -> tuple[str, dict[str, Any]]:
|
|
188
|
+
cf = config.get("confluence") or {}
|
|
189
|
+
instances = confluence_instances(config)
|
|
190
|
+
selected = (name or cf.get("default_instance") or "").strip()
|
|
191
|
+
if not selected:
|
|
192
|
+
if len(instances) == 1:
|
|
193
|
+
selected = next(iter(instances))
|
|
194
|
+
else:
|
|
195
|
+
raise RuntimeError("Aucune instance Confluence sélectionnée et confluence.default_instance est vide.")
|
|
196
|
+
if selected not in instances:
|
|
197
|
+
raise RuntimeError(f"Instance Confluence inconnue: {selected}")
|
|
198
|
+
instance = _deep_merge(
|
|
199
|
+
{
|
|
200
|
+
"domain": "",
|
|
201
|
+
"base_path": "/wiki/",
|
|
202
|
+
"api_url": "",
|
|
203
|
+
"user_name": "",
|
|
204
|
+
"token_env": "ATLASSIAN_API_TOKEN",
|
|
205
|
+
"api_version": "v2",
|
|
206
|
+
"default_space": "",
|
|
207
|
+
"root_page": "",
|
|
208
|
+
},
|
|
209
|
+
instances[selected],
|
|
210
|
+
)
|
|
211
|
+
# DocSpecBridge 0.2 targets Cloud only.
|
|
212
|
+
instance["api_version"] = "v2"
|
|
213
|
+
return selected, instance
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.prompt import Confirm, Prompt
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .config import confluence_instances, init_config, load_config, save_config, selected_config_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _show_summary(cfg: dict[str, Any], path: Path) -> None:
|
|
17
|
+
table = Table(title=f"Configuration DocSpecBridge - {path}")
|
|
18
|
+
table.add_column("Clé")
|
|
19
|
+
table.add_column("Valeur")
|
|
20
|
+
app = cfg["app"]
|
|
21
|
+
pub = cfg["profiles"]["publication"]
|
|
22
|
+
rag = cfg["profiles"]["rag"]
|
|
23
|
+
cf = cfg["confluence"]
|
|
24
|
+
table.add_row("app.source", str(app.get("source")))
|
|
25
|
+
table.add_row("app.destination", str(app.get("destination")))
|
|
26
|
+
table.add_row("app.extensions", ", ".join(app.get("extensions") or []))
|
|
27
|
+
table.add_row("app.recursive", str(bool(app.get("recursive"))))
|
|
28
|
+
table.add_row("publication.preserve_image_display_size", str(bool(pub.get("preserve_image_display_size"))))
|
|
29
|
+
table.add_row("rag.enabled", str(bool(rag.get("enabled"))))
|
|
30
|
+
table.add_row("rag.chunking.enabled", str(bool((rag.get("chunking") or {}).get("enabled"))))
|
|
31
|
+
table.add_row("confluence.default_instance", str(cf.get("default_instance") or ""))
|
|
32
|
+
table.add_row("confluence.instances", ", ".join(confluence_instances(cfg)) or "(aucune)")
|
|
33
|
+
console.print(table)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _edit_app(cfg: dict[str, Any]) -> None:
|
|
37
|
+
app = cfg["app"]
|
|
38
|
+
app["source"] = Prompt.ask("Source par défaut", default=str(app.get("source") or "./input"))
|
|
39
|
+
app["destination"] = Prompt.ask("Destination par défaut", default=str(app.get("destination") or "./output"))
|
|
40
|
+
ext_default = ",".join(app.get("extensions") or [".docx", ".pdf", ".pptx"])
|
|
41
|
+
extensions = Prompt.ask("Extensions (séparées par des virgules)", default=ext_default)
|
|
42
|
+
app["extensions"] = [e.strip().lower() if e.strip().startswith(".") else "." + e.strip().lower() for e in extensions.split(",") if e.strip()]
|
|
43
|
+
app["recursive"] = Confirm.ask("Recherche récursive", default=bool(app.get("recursive", True)))
|
|
44
|
+
app["preserve_source_tree"] = Confirm.ask(
|
|
45
|
+
"Conserver l'arborescence source", default=bool(app.get("preserve_source_tree", True))
|
|
46
|
+
)
|
|
47
|
+
app["copy_source"] = Confirm.ask("Copier le fichier source dans le package", default=bool(app.get("copy_source", True)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _edit_profiles(cfg: dict[str, Any]) -> None:
|
|
51
|
+
profiles = cfg["profiles"]
|
|
52
|
+
pub = profiles["publication"]
|
|
53
|
+
rag = profiles["rag"]
|
|
54
|
+
pub["enabled"] = Confirm.ask("Générer le Markdown publication", default=bool(pub.get("enabled", True)))
|
|
55
|
+
pub["preserve_image_display_size"] = Confirm.ask(
|
|
56
|
+
"Préserver la taille d'affichage des images", default=bool(pub.get("preserve_image_display_size", True))
|
|
57
|
+
)
|
|
58
|
+
rag["enabled"] = Confirm.ask("Générer le profil RAG", default=bool(rag.get("enabled", True)))
|
|
59
|
+
rag["keep_image_references"] = Confirm.ask(
|
|
60
|
+
"Conserver les références images dans rag.md", default=bool(rag.get("keep_image_references", True))
|
|
61
|
+
)
|
|
62
|
+
rag["include_header_images"] = Confirm.ask(
|
|
63
|
+
"Conserver les images de header dans rag.md", default=bool(rag.get("include_header_images", False))
|
|
64
|
+
)
|
|
65
|
+
chunking = rag.setdefault("chunking", {})
|
|
66
|
+
chunking["enabled"] = Confirm.ask("Générer chunks.jsonl", default=bool(chunking.get("enabled", True)))
|
|
67
|
+
if chunking["enabled"]:
|
|
68
|
+
chunking["max_characters"] = int(Prompt.ask("Taille max chunk (caractères)", default=str(chunking.get("max_characters", 1600))))
|
|
69
|
+
chunking["overlap"] = int(Prompt.ask("Overlap (caractères)", default=str(chunking.get("overlap", 150))))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _list_instances(cfg: dict[str, Any]) -> None:
|
|
73
|
+
instances = confluence_instances(cfg)
|
|
74
|
+
default_name = str((cfg.get("confluence") or {}).get("default_instance") or "")
|
|
75
|
+
table = Table(title="Instances Confluence Cloud")
|
|
76
|
+
table.add_column("Nom")
|
|
77
|
+
table.add_column("Défaut")
|
|
78
|
+
table.add_column("Domaine")
|
|
79
|
+
table.add_column("Utilisateur")
|
|
80
|
+
table.add_column("Token env")
|
|
81
|
+
table.add_column("Espace")
|
|
82
|
+
for name, item in instances.items():
|
|
83
|
+
table.add_row(
|
|
84
|
+
name,
|
|
85
|
+
"*" if name == default_name else "",
|
|
86
|
+
str(item.get("domain") or item.get("api_url") or ""),
|
|
87
|
+
str(item.get("user_name") or "Bearer"),
|
|
88
|
+
str(item.get("token_env") or "ATLASSIAN_API_TOKEN"),
|
|
89
|
+
str(item.get("default_space") or ""),
|
|
90
|
+
)
|
|
91
|
+
console.print(table)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _edit_instance(cfg: dict[str, Any]) -> None:
|
|
95
|
+
cf = cfg.setdefault("confluence", {})
|
|
96
|
+
instances = cf.setdefault("instances", {})
|
|
97
|
+
current_names = list(instances)
|
|
98
|
+
default_name = current_names[0] if current_names else "production"
|
|
99
|
+
name = Prompt.ask("Nom logique de l'instance", default=default_name).strip()
|
|
100
|
+
current = dict(instances.get(name) or {})
|
|
101
|
+
current["domain"] = Prompt.ask("Domaine Cloud (ex: company.atlassian.net)", default=str(current.get("domain") or ""))
|
|
102
|
+
current["base_path"] = "/wiki/"
|
|
103
|
+
current["api_url"] = Prompt.ask(
|
|
104
|
+
"API URL Atlassian scoped token (optionnel, vide pour domaine classique)",
|
|
105
|
+
default=str(current.get("api_url") or ""),
|
|
106
|
+
).strip()
|
|
107
|
+
current["user_name"] = Prompt.ask(
|
|
108
|
+
"Email Atlassian (vide = Bearer/scoped token)", default=str(current.get("user_name") or "")
|
|
109
|
+
).strip()
|
|
110
|
+
current["token_env"] = Prompt.ask("Variable d'environnement du token", default=str(current.get("token_env") or "ATLASSIAN_API_TOKEN")).strip()
|
|
111
|
+
current["api_version"] = "v2"
|
|
112
|
+
current["default_space"] = Prompt.ask("Espace par défaut (optionnel)", default=str(current.get("default_space") or "")).strip()
|
|
113
|
+
current["root_page"] = Prompt.ask("Page racine ID (optionnel)", default=str(current.get("root_page") or "")).strip()
|
|
114
|
+
instances[name] = current
|
|
115
|
+
if not cf.get("default_instance"):
|
|
116
|
+
cf["default_instance"] = name
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _instances_menu(cfg: dict[str, Any]) -> None:
|
|
120
|
+
while True:
|
|
121
|
+
console.print("\n[1] Lister")
|
|
122
|
+
console.print("[2] Ajouter / modifier")
|
|
123
|
+
console.print("[3] Définir l'instance par défaut")
|
|
124
|
+
console.print("[4] Supprimer")
|
|
125
|
+
console.print("[0] Retour")
|
|
126
|
+
choice = Prompt.ask("Action Confluence", choices=["0", "1", "2", "3", "4"], default="1")
|
|
127
|
+
if choice == "0":
|
|
128
|
+
return
|
|
129
|
+
if choice == "1":
|
|
130
|
+
_list_instances(cfg)
|
|
131
|
+
elif choice == "2":
|
|
132
|
+
_edit_instance(cfg)
|
|
133
|
+
elif choice == "3":
|
|
134
|
+
names = list(confluence_instances(cfg))
|
|
135
|
+
if not names:
|
|
136
|
+
console.print("[yellow]Aucune instance définie.[/yellow]")
|
|
137
|
+
continue
|
|
138
|
+
for idx, name in enumerate(names, 1):
|
|
139
|
+
console.print(f"[{idx}] {name}")
|
|
140
|
+
idx = int(Prompt.ask("Numéro", default="1"))
|
|
141
|
+
cfg["confluence"]["default_instance"] = names[idx - 1]
|
|
142
|
+
elif choice == "4":
|
|
143
|
+
names = list(confluence_instances(cfg))
|
|
144
|
+
if not names:
|
|
145
|
+
continue
|
|
146
|
+
for idx, name in enumerate(names, 1):
|
|
147
|
+
console.print(f"[{idx}] {name}")
|
|
148
|
+
idx = int(Prompt.ask("Numéro à supprimer"))
|
|
149
|
+
name = names[idx - 1]
|
|
150
|
+
if Confirm.ask(f"Supprimer {name} ?", default=False):
|
|
151
|
+
cfg["confluence"]["instances"].pop(name, None)
|
|
152
|
+
if cfg["confluence"].get("default_instance") == name:
|
|
153
|
+
cfg["confluence"]["default_instance"] = next(iter(cfg["confluence"]["instances"]), "")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def config_menu(path: Path | None = None) -> Path:
|
|
157
|
+
config_path = selected_config_path(path)
|
|
158
|
+
if not config_path.exists():
|
|
159
|
+
if Confirm.ask(f"Créer {config_path} ?", default=True):
|
|
160
|
+
init_config(config_path)
|
|
161
|
+
cfg = load_config(config_path) if config_path.exists() else load_config(None)
|
|
162
|
+
|
|
163
|
+
while True:
|
|
164
|
+
console.print("\n[bold cyan]Configuration YAML[/bold cyan]")
|
|
165
|
+
console.print("[1] Afficher le résumé")
|
|
166
|
+
console.print("[2] Source / destination / extensions")
|
|
167
|
+
console.print("[3] Profils publication / RAG")
|
|
168
|
+
console.print("[4] Instances Confluence Cloud")
|
|
169
|
+
console.print("[5] Sauvegarder")
|
|
170
|
+
console.print("[0] Sauvegarder et retour")
|
|
171
|
+
choice = Prompt.ask("Action", choices=["0", "1", "2", "3", "4", "5"], default="1")
|
|
172
|
+
if choice == "1":
|
|
173
|
+
_show_summary(cfg, config_path)
|
|
174
|
+
elif choice == "2":
|
|
175
|
+
_edit_app(cfg)
|
|
176
|
+
elif choice == "3":
|
|
177
|
+
_edit_profiles(cfg)
|
|
178
|
+
elif choice == "4":
|
|
179
|
+
_instances_menu(cfg)
|
|
180
|
+
elif choice == "5":
|
|
181
|
+
save_config(cfg, config_path)
|
|
182
|
+
console.print(f"[green]Sauvegardé: {config_path}[/green]")
|
|
183
|
+
elif choice == "0":
|
|
184
|
+
save_config(cfg, config_path)
|
|
185
|
+
console.print(f"[green]Sauvegardé: {config_path}[/green]")
|
|
186
|
+
return config_path
|