rolfedh-doc-utils 0.1.24__py3-none-any.whl → 0.1.26__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.
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ convert-callouts-interactive - Interactive AsciiDoc callout converter
4
+
5
+ Interactively converts code blocks with callouts, prompting for format choice
6
+ (definition list, bulleted list, or inline comments) for each code block.
7
+ """
8
+
9
+ import sys
10
+ import argparse
11
+ from pathlib import Path
12
+ from typing import List, Tuple, Optional
13
+
14
+ # Import from callout_lib
15
+ from callout_lib import (
16
+ CalloutDetector,
17
+ DefListConverter,
18
+ BulletListConverter,
19
+ CommentConverter,
20
+ CodeBlock,
21
+ )
22
+
23
+
24
+ # Colors for output
25
+ class Colors:
26
+ RED = '\033[0;31m'
27
+ GREEN = '\033[0;32m'
28
+ YELLOW = '\033[1;33m'
29
+ BLUE = '\033[0;34m'
30
+ CYAN = '\033[0;36m'
31
+ MAGENTA = '\033[0;35m'
32
+ BOLD = '\033[1m'
33
+ NC = '\033[0m' # No Color
34
+
35
+
36
+ def print_colored(message: str, color: str = Colors.NC) -> None:
37
+ """Print message with color"""
38
+ print(f"{color}{message}{Colors.NC}")
39
+
40
+
41
+ def print_separator(char: str = '=', length: int = 80, color: str = Colors.CYAN) -> None:
42
+ """Print a separator line"""
43
+ print_colored(char * length, color)
44
+
45
+
46
+ class InteractiveCalloutConverter:
47
+ """Interactive converter for AsciiDoc callouts."""
48
+
49
+ def __init__(self, dry_run: bool = False, context_lines: int = 3):
50
+ self.dry_run = dry_run
51
+ self.context_lines = context_lines
52
+ self.detector = CalloutDetector()
53
+ self.changes_made = 0
54
+ self.warnings = []
55
+ self.apply_to_all = None # None, 'deflist', 'bullets', 'comments', or 'skip'
56
+
57
+ def show_code_block_preview(self, lines: List[str], block: CodeBlock, file_path: Path) -> None:
58
+ """Display a preview of the code block with context."""
59
+ print_separator('=', 80, Colors.CYAN)
60
+ print_colored(f"\nFile: {file_path}", Colors.BOLD)
61
+ print_colored(f"Code block at lines {block.start_line + 1}-{block.end_line + 1}", Colors.BLUE)
62
+ if block.language:
63
+ print_colored(f"Language: {block.language}", Colors.BLUE)
64
+ print()
65
+
66
+ # Show context before
67
+ context_start = max(0, block.start_line - self.context_lines)
68
+ if context_start < block.start_line:
69
+ print_colored(" ... context before ...", Colors.CYAN)
70
+ for i in range(context_start, block.start_line):
71
+ print_colored(f" {i + 1:4d} | {lines[i]}", Colors.CYAN)
72
+
73
+ # Show the code block itself
74
+ # Check if has [source] prefix
75
+ has_source_prefix = self.detector.CODE_BLOCK_START.match(lines[block.start_line])
76
+ if has_source_prefix:
77
+ print_colored(f" {block.start_line + 1:4d} | {lines[block.start_line]}", Colors.YELLOW)
78
+ print_colored(f" {block.start_line + 2:4d} | {lines[block.start_line + 1]}", Colors.YELLOW)
79
+ content_start_line = block.start_line + 2
80
+ else:
81
+ print_colored(f" {block.start_line + 1:4d} | {lines[block.start_line]}", Colors.YELLOW)
82
+ content_start_line = block.start_line + 1
83
+
84
+ # Show code content with callouts highlighted
85
+ for i, line in enumerate(block.content):
86
+ line_num = content_start_line + i
87
+ if '<' in line and '>' in line and any(f'<{n}>' in line for n in range(1, 100)):
88
+ # Highlight lines with callouts
89
+ print_colored(f" {line_num + 1:4d} | {line}", Colors.MAGENTA)
90
+ else:
91
+ print(f" {line_num + 1:4d} | {line}")
92
+
93
+ # Show closing delimiter
94
+ print_colored(f" {block.end_line + 1:4d} | {lines[block.end_line]}", Colors.YELLOW)
95
+
96
+ # Show context after (callout explanations)
97
+ context_end = min(len(lines), block.end_line + 1 + self.context_lines + 10) # More context for explanations
98
+ if block.end_line + 1 < context_end:
99
+ print_colored(" ... callout explanations ...", Colors.CYAN)
100
+ for i in range(block.end_line + 1, context_end):
101
+ if i >= len(lines):
102
+ break
103
+ # Stop showing context if we hit a new section
104
+ if lines[i].strip() and lines[i].startswith('='):
105
+ break
106
+ print_colored(f" {i + 1:4d} | {lines[i]}", Colors.CYAN)
107
+ print()
108
+
109
+ def get_user_choice_for_long_comments(self, block: CodeBlock, long_warnings) -> Optional[str]:
110
+ """
111
+ Prompt user for choice when comments are too long.
112
+ Returns: 'shorten', 'deflist', 'bullets', 'skip', or None for quit
113
+ """
114
+ print_colored("\n⚠️ WARNING: Long Comment Detected", Colors.YELLOW)
115
+ print()
116
+ for lw in long_warnings:
117
+ print_colored(f" Callout <{lw.callout_num}>: {lw.length} characters", Colors.YELLOW)
118
+ print_colored(f" Text: {lw.text[:100]}{'...' if len(lw.text) > 100 else ''}", Colors.CYAN)
119
+ print()
120
+
121
+ print("This explanation is too long for a readable inline comment.")
122
+ print("\nWhat would you like to do?")
123
+ print(" [s] Use Shortened version (first sentence only)")
124
+ print(" [d] Use Definition list format instead")
125
+ print(" [b] Use Bulleted list format instead")
126
+ print(" [k] Skip this block")
127
+ print(" [q] Quit")
128
+
129
+ while True:
130
+ try:
131
+ choice = input("\nYour choice [s/d/b/k/q]: ").strip().lower()
132
+
133
+ if choice in ['q', 'quit', 'exit']:
134
+ return None
135
+ elif choice in ['s', 'shorten', 'short']:
136
+ return 'shorten'
137
+ elif choice in ['d', 'deflist']:
138
+ return 'deflist'
139
+ elif choice in ['b', 'bullets', 'bullet']:
140
+ return 'bullets'
141
+ elif choice in ['k', 'skip']:
142
+ return 'skip'
143
+ else:
144
+ print_colored("Invalid choice. Please enter s, d, b, k, or q.", Colors.RED)
145
+
146
+ except (KeyboardInterrupt, EOFError):
147
+ print()
148
+ return None
149
+
150
+ def get_user_choice(self, block_num: int, total_blocks: int) -> Optional[str]:
151
+ """
152
+ Prompt user for conversion choice.
153
+ Returns: 'deflist', 'bullets', 'comments', 'skip', or None for quit
154
+ """
155
+ print_colored(f"\n[Code block {block_num}/{total_blocks}]", Colors.BOLD)
156
+
157
+ # Check if user wants to apply same choice to all
158
+ if self.apply_to_all:
159
+ print_colored(f"Applying previous choice to all: {self.apply_to_all}", Colors.GREEN)
160
+ return self.apply_to_all
161
+
162
+ print("\nChoose conversion format:")
163
+ print(" [d] Definition list (where:)")
164
+ print(" [b] Bulleted list")
165
+ print(" [c] Inline comments")
166
+ print(" [s] Skip this block")
167
+ print(" [a] Apply choice to All remaining blocks")
168
+ print(" [q] Quit")
169
+
170
+ while True:
171
+ try:
172
+ choice = input("\nYour choice [d/b/c/s/a/q]: ").strip().lower()
173
+
174
+ if choice in ['q', 'quit', 'exit']:
175
+ return None
176
+ elif choice in ['s', 'skip']:
177
+ return 'skip'
178
+ elif choice in ['d', 'deflist']:
179
+ return 'deflist'
180
+ elif choice in ['b', 'bullets', 'bullet']:
181
+ return 'bullets'
182
+ elif choice in ['c', 'comments', 'comment']:
183
+ return 'comments'
184
+ elif choice in ['a', 'all']:
185
+ # Ask for the format to apply to all
186
+ print("\nWhat format should be applied to all remaining blocks?")
187
+ print(" [d] Definition list")
188
+ print(" [b] Bulleted list")
189
+ print(" [c] Inline comments")
190
+ print(" [s] Skip all")
191
+ format_choice = input("Format [d/b/c/s]: ").strip().lower()
192
+
193
+ if format_choice in ['d', 'deflist']:
194
+ self.apply_to_all = 'deflist'
195
+ return 'deflist'
196
+ elif format_choice in ['b', 'bullets', 'bullet']:
197
+ self.apply_to_all = 'bullets'
198
+ return 'bullets'
199
+ elif format_choice in ['c', 'comments', 'comment']:
200
+ self.apply_to_all = 'comments'
201
+ return 'comments'
202
+ elif format_choice in ['s', 'skip']:
203
+ self.apply_to_all = 'skip'
204
+ return 'skip'
205
+ else:
206
+ print_colored("Invalid choice. Please try again.", Colors.RED)
207
+ else:
208
+ print_colored("Invalid choice. Please enter d, b, c, s, a, or q.", Colors.RED)
209
+
210
+ except (KeyboardInterrupt, EOFError):
211
+ print()
212
+ return None
213
+
214
+ def convert_file(self, input_file: Path) -> Tuple[int, bool]:
215
+ """
216
+ Interactively convert callouts in a file.
217
+ Returns tuple of (number of conversions, whether file was modified).
218
+ """
219
+ # Read input file
220
+ try:
221
+ with open(input_file, 'r', encoding='utf-8') as f:
222
+ lines = [line.rstrip('\n') for line in f]
223
+ except Exception as e:
224
+ print_colored(f"Error reading {input_file}: {e}", Colors.RED)
225
+ return 0, False
226
+
227
+ # Find all code blocks with callouts
228
+ all_blocks = self.detector.find_code_blocks(lines)
229
+ blocks_with_callouts = []
230
+
231
+ for block in all_blocks:
232
+ callout_groups = self.detector.extract_callouts_from_code(block.content)
233
+ if callout_groups:
234
+ blocks_with_callouts.append(block)
235
+
236
+ if not blocks_with_callouts:
237
+ print(f"No code blocks with callouts found in {input_file}")
238
+ return 0, False
239
+
240
+ print_colored(f"\n{'='*80}", Colors.GREEN)
241
+ print_colored(f"Processing: {input_file}", Colors.BOLD)
242
+ print_colored(f"Found {len(blocks_with_callouts)} code block(s) with callouts", Colors.GREEN)
243
+ print_colored(f"{'='*80}\n", Colors.GREEN)
244
+
245
+ # Process blocks and collect conversions
246
+ conversions = [] # List of (block, format_choice) tuples
247
+ total_blocks = len(blocks_with_callouts)
248
+
249
+ for idx, block in enumerate(blocks_with_callouts, 1):
250
+ # Show preview
251
+ self.show_code_block_preview(lines, block, input_file)
252
+
253
+ # Get user choice
254
+ choice = self.get_user_choice(idx, total_blocks)
255
+
256
+ if choice is None:
257
+ print_colored("\nConversion cancelled by user.", Colors.YELLOW)
258
+ return 0, False
259
+ elif choice == 'skip':
260
+ print_colored("Skipping this block.\n", Colors.YELLOW)
261
+ continue
262
+
263
+ # If user chose comments, check for long comments first
264
+ if choice == 'comments':
265
+ # Extract callouts to check comment lengths
266
+ callout_groups = self.detector.extract_callouts_from_code(block.content)
267
+ explanations, _ = self.detector.extract_callout_explanations(lines, block.end_line)
268
+
269
+ # Check for long comments
270
+ long_warnings = CommentConverter.check_comment_lengths(
271
+ explanations, block.language, max_length=120
272
+ )
273
+
274
+ if long_warnings:
275
+ # Prompt user for what to do with long comments
276
+ long_choice = self.get_user_choice_for_long_comments(block, long_warnings)
277
+
278
+ if long_choice is None:
279
+ print_colored("\nConversion cancelled by user.", Colors.YELLOW)
280
+ return 0, False
281
+ elif long_choice == 'skip':
282
+ print_colored("Skipping this block.\n", Colors.YELLOW)
283
+ continue
284
+ elif long_choice == 'shorten':
285
+ # Store that we want shortened comments
286
+ conversions.append((block, 'comments-shorten'))
287
+ print_colored("Will convert to: inline comments (shortened)\n", Colors.GREEN)
288
+ continue
289
+ else:
290
+ # User chose deflist or bullets instead
291
+ choice = long_choice
292
+ print_colored(f"Will convert to: {choice} instead\n", Colors.GREEN)
293
+
294
+ conversions.append((block, choice))
295
+ print_colored(f"Will convert to: {choice}\n", Colors.GREEN)
296
+
297
+ if not conversions:
298
+ print("No blocks selected for conversion.")
299
+ return 0, False
300
+
301
+ # Apply conversions (in reverse order to maintain line numbers)
302
+ new_lines = lines.copy()
303
+
304
+ for block, format_choice in reversed(conversions):
305
+ # Extract callouts
306
+ callout_groups = self.detector.extract_callouts_from_code(block.content)
307
+ explanations, explanation_end = self.detector.extract_callout_explanations(new_lines, block.end_line)
308
+
309
+ if not explanations:
310
+ print_colored(f"Warning: No explanations found for block at line {block.start_line + 1}", Colors.YELLOW)
311
+ continue
312
+
313
+ # Validate
314
+ is_valid, code_nums, explanation_nums = self.detector.validate_callouts(callout_groups, explanations)
315
+ if not is_valid:
316
+ warning_msg = (
317
+ f"WARNING: {input_file.name} lines {block.start_line + 1}-{block.end_line + 1}: "
318
+ f"Callout mismatch: code has {sorted(code_nums)}, explanations have {sorted(explanation_nums)}"
319
+ )
320
+ print_colored(warning_msg, Colors.YELLOW)
321
+ self.warnings.append(warning_msg)
322
+ continue
323
+
324
+ # Convert based on choice
325
+ if format_choice == 'comments' or format_choice == 'comments-shorten':
326
+ shorten = (format_choice == 'comments-shorten')
327
+ converted_content, _ = CommentConverter.convert(
328
+ block.content, callout_groups, explanations, block.language,
329
+ max_length=None, shorten_long=shorten
330
+ )
331
+ output_list = []
332
+ else:
333
+ converted_content = self.detector.remove_callouts_from_code(block.content)
334
+ if format_choice == 'bullets':
335
+ output_list = BulletListConverter.convert(callout_groups, explanations)
336
+ else: # deflist
337
+ output_list = DefListConverter.convert(callout_groups, explanations)
338
+
339
+ # Replace in document
340
+ has_source_prefix = self.detector.CODE_BLOCK_START.match(new_lines[block.start_line])
341
+ if has_source_prefix:
342
+ content_start = block.start_line + 2
343
+ else:
344
+ content_start = block.start_line + 1
345
+ content_end = block.end_line
346
+
347
+ if format_choice == 'comments':
348
+ # Keep everything, just replace code content
349
+ new_section = (
350
+ new_lines[:content_start] +
351
+ converted_content +
352
+ new_lines[content_end:]
353
+ )
354
+ else:
355
+ # Remove old explanations, add new list
356
+ new_section = (
357
+ new_lines[:content_start] +
358
+ converted_content +
359
+ [new_lines[content_end]] +
360
+ output_list +
361
+ new_lines[explanation_end + 1:]
362
+ )
363
+
364
+ new_lines = new_section
365
+ self.changes_made += 1
366
+
367
+ # Write output
368
+ total_conversions = len(conversions)
369
+ if total_conversions > 0 and not self.dry_run:
370
+ try:
371
+ with open(input_file, 'w', encoding='utf-8') as f:
372
+ f.write('\n'.join(new_lines) + '\n')
373
+ print_colored(f"\n✓ Saved changes to {input_file}", Colors.GREEN)
374
+ except Exception as e:
375
+ print_colored(f"Error writing {input_file}: {e}", Colors.RED)
376
+ return 0, False
377
+ elif self.dry_run:
378
+ print_colored(f"\n[DRY RUN] Would save {total_conversions} conversion(s) to {input_file}", Colors.YELLOW)
379
+
380
+ return total_conversions, total_conversions > 0
381
+
382
+
383
+ def find_adoc_files(path: Path, exclude_dirs: List[str] = None) -> List[Path]:
384
+ """Find all .adoc files in the given path."""
385
+ adoc_files = []
386
+ exclude_dirs = exclude_dirs or []
387
+
388
+ # Always exclude .vale directory
389
+ if '.vale' not in exclude_dirs:
390
+ exclude_dirs.append('.vale')
391
+
392
+ if path.is_file():
393
+ if path.suffix == '.adoc':
394
+ adoc_files.append(path)
395
+ elif path.is_dir():
396
+ for adoc_file in path.rglob('*.adoc'):
397
+ if any(excl in str(adoc_file) for excl in exclude_dirs):
398
+ continue
399
+ adoc_files.append(adoc_file)
400
+
401
+ return sorted(adoc_files)
402
+
403
+
404
+ def main():
405
+ """Main entry point"""
406
+ parser = argparse.ArgumentParser(
407
+ description='Interactively convert AsciiDoc callouts to various formats',
408
+ formatter_class=argparse.RawDescriptionHelpFormatter,
409
+ epilog="""
410
+ Interactive AsciiDoc callout converter.
411
+
412
+ This tool scans .adoc files for code blocks with callouts and prompts you
413
+ to choose the conversion format for each block individually:
414
+ - Definition list (where:)
415
+ - Bulleted list
416
+ - Inline comments
417
+
418
+ For each code block with callouts, you'll see:
419
+ - File name and location
420
+ - Code block preview with context
421
+ - Callout explanations
422
+
423
+ Then choose how to convert that specific block, or apply a choice to all
424
+ remaining blocks.
425
+
426
+ Examples:
427
+ %(prog)s myfile.adoc # Process single file interactively
428
+ %(prog)s modules/ # Process all .adoc files in directory
429
+ %(prog)s --dry-run modules/ # Preview without making changes
430
+ %(prog)s --context 5 myfile.adoc # Show 5 lines of context
431
+ """
432
+ )
433
+
434
+ parser.add_argument(
435
+ 'path',
436
+ nargs='?',
437
+ default='.',
438
+ help='File or directory to process (default: current directory)'
439
+ )
440
+ parser.add_argument(
441
+ '-n', '--dry-run',
442
+ action='store_true',
443
+ help='Preview changes without modifying files'
444
+ )
445
+ parser.add_argument(
446
+ '-c', '--context',
447
+ type=int,
448
+ default=3,
449
+ help='Number of context lines to show before/after code blocks (default: 3)'
450
+ )
451
+ parser.add_argument(
452
+ '--exclude-dir',
453
+ action='append',
454
+ dest='exclude_dirs',
455
+ default=[],
456
+ help='Directory to exclude (can be used multiple times)'
457
+ )
458
+
459
+ args = parser.parse_args()
460
+
461
+ # Convert path to Path object
462
+ target_path = Path(args.path)
463
+
464
+ if not target_path.exists():
465
+ print_colored(f"Error: Path does not exist: {target_path}", Colors.RED)
466
+ sys.exit(1)
467
+
468
+ if args.dry_run:
469
+ print_colored("DRY RUN MODE - No files will be modified\n", Colors.YELLOW)
470
+
471
+ # Find all AsciiDoc files
472
+ adoc_files = find_adoc_files(target_path, args.exclude_dirs)
473
+
474
+ if not adoc_files:
475
+ if target_path.is_file():
476
+ print_colored(f"Error: {target_path} is not an AsciiDoc file (.adoc)", Colors.RED)
477
+ else:
478
+ print(f"No AsciiDoc files found in {target_path}")
479
+ sys.exit(1)
480
+
481
+ if len(adoc_files) > 1:
482
+ print(f"Found {len(adoc_files)} AsciiDoc file(s) to process\n")
483
+
484
+ # Create converter
485
+ converter = InteractiveCalloutConverter(dry_run=args.dry_run, context_lines=args.context)
486
+
487
+ # Process each file
488
+ files_modified = 0
489
+ total_conversions = 0
490
+
491
+ for file_path in adoc_files:
492
+ try:
493
+ conversions, modified = converter.convert_file(file_path)
494
+
495
+ if modified:
496
+ files_modified += 1
497
+ total_conversions += conversions
498
+
499
+ except KeyboardInterrupt:
500
+ print_colored("\n\nOperation cancelled by user", Colors.YELLOW)
501
+ break
502
+ except Exception as e:
503
+ print_colored(f"\nUnexpected error processing {file_path}: {e}", Colors.RED)
504
+ import traceback
505
+ traceback.print_exc()
506
+
507
+ # Summary
508
+ print_separator('=', 80, Colors.GREEN)
509
+ print_colored("\nSummary:", Colors.BOLD)
510
+ print(f" Files processed: {len(adoc_files)}")
511
+ if args.dry_run and files_modified > 0:
512
+ print(f" Would modify: {files_modified} file(s)")
513
+ print(f" Total conversions: {total_conversions}")
514
+ elif files_modified > 0:
515
+ print_colored(f" Files modified: {files_modified}", Colors.GREEN)
516
+ print_colored(f" Total conversions: {total_conversions}", Colors.GREEN)
517
+ else:
518
+ print(" No files modified")
519
+
520
+ if converter.warnings:
521
+ print_colored(f"\n⚠ {len(converter.warnings)} Warning(s):", Colors.YELLOW)
522
+ for warning in converter.warnings:
523
+ print_colored(f" {warning}", Colors.YELLOW)
524
+
525
+ if args.dry_run and files_modified > 0:
526
+ print_colored("\nDRY RUN - No files were modified", Colors.YELLOW)
527
+
528
+ print_separator('=', 80, Colors.GREEN)
529
+
530
+
531
+ if __name__ == '__main__':
532
+ main()