oh-markdown-tool 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.
- oh_markdown_tool/__init__.py +34 -0
- oh_markdown_tool/formatter.py +158 -0
- oh_markdown_tool/numbering.py +384 -0
- oh_markdown_tool/operations.py +411 -0
- oh_markdown_tool/parser.py +254 -0
- oh_markdown_tool/toc.py +300 -0
- oh_markdown_tool/tool.py +956 -0
- oh_markdown_tool-0.1.0.dist-info/METADATA +265 -0
- oh_markdown_tool-0.1.0.dist-info/RECORD +11 -0
- oh_markdown_tool-0.1.0.dist-info/WHEEL +4 -0
- oh_markdown_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Markdown document tool for structural editing and formatting."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .numbering import NumberingIssue, RenumberResult, SectionNumberer, ValidationResult
|
|
6
|
+
from .parser import MarkdownParser, ParseResult, Section
|
|
7
|
+
from .toc import (
|
|
8
|
+
TocAction,
|
|
9
|
+
TocManager,
|
|
10
|
+
TocRemoveResult,
|
|
11
|
+
TocUpdateResult,
|
|
12
|
+
TocValidationResult,
|
|
13
|
+
)
|
|
14
|
+
from .tool import MarkdownAction, MarkdownDocumentTool, MarkdownExecutor, MarkdownObservation
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"MarkdownParser",
|
|
19
|
+
"ParseResult",
|
|
20
|
+
"Section",
|
|
21
|
+
"SectionNumberer",
|
|
22
|
+
"NumberingIssue",
|
|
23
|
+
"RenumberResult",
|
|
24
|
+
"ValidationResult",
|
|
25
|
+
"MarkdownDocumentTool",
|
|
26
|
+
"MarkdownAction",
|
|
27
|
+
"MarkdownObservation",
|
|
28
|
+
"MarkdownExecutor",
|
|
29
|
+
"TocAction",
|
|
30
|
+
"TocManager",
|
|
31
|
+
"TocUpdateResult",
|
|
32
|
+
"TocRemoveResult",
|
|
33
|
+
"TocValidationResult",
|
|
34
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Markdown formatting operations using mdformat and pymarkdownlnt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import mdformat
|
|
8
|
+
from pymarkdown.api import PyMarkdownApi
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class LintIssue:
|
|
13
|
+
"""A single markdown lint issue."""
|
|
14
|
+
|
|
15
|
+
line: int
|
|
16
|
+
column: int
|
|
17
|
+
rule_id: str
|
|
18
|
+
rule_name: str
|
|
19
|
+
message: str
|
|
20
|
+
extra_info: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class LintResult:
|
|
25
|
+
"""Result of linting a markdown document."""
|
|
26
|
+
|
|
27
|
+
issues: list[LintIssue]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def has_issues(self) -> bool:
|
|
31
|
+
"""Return True if there are any lint issues."""
|
|
32
|
+
return len(self.issues) > 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FixResult:
|
|
37
|
+
"""Result of auto-fixing markdown issues."""
|
|
38
|
+
|
|
39
|
+
was_fixed: bool
|
|
40
|
+
content: str
|
|
41
|
+
issues_remaining: list[LintIssue]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def has_remaining_issues(self) -> bool:
|
|
45
|
+
"""Return True if there are issues that couldn't be auto-fixed."""
|
|
46
|
+
return len(self.issues_remaining) > 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class RewrapResult:
|
|
51
|
+
"""Result of rewrapping a markdown document."""
|
|
52
|
+
|
|
53
|
+
content: str
|
|
54
|
+
was_modified: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MarkdownFormatter:
|
|
58
|
+
"""Formats markdown documents using mdformat and pymarkdownlnt.
|
|
59
|
+
|
|
60
|
+
Provides three operations:
|
|
61
|
+
- rewrap: Normalize paragraph line lengths (preserves code blocks, tables, lists)
|
|
62
|
+
- lint: Scan for markdown issues and report them
|
|
63
|
+
- fix: Auto-fix markdown issues where possible
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self):
|
|
67
|
+
"""Initialize the formatter."""
|
|
68
|
+
self._api = PyMarkdownApi()
|
|
69
|
+
|
|
70
|
+
def rewrap(self, content: str, width: int = 80) -> RewrapResult:
|
|
71
|
+
"""Rewrap paragraphs to specified width.
|
|
72
|
+
|
|
73
|
+
Uses mdformat to parse the markdown AST and only wrap paragraph text,
|
|
74
|
+
leaving structural elements (code blocks, tables, lists) intact.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
content: Markdown content to rewrap.
|
|
78
|
+
width: Maximum line width (default 80).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
RewrapResult with the rewrapped content.
|
|
82
|
+
"""
|
|
83
|
+
result = mdformat.text(content, options={"wrap": width})
|
|
84
|
+
return RewrapResult(
|
|
85
|
+
content=result,
|
|
86
|
+
was_modified=result != content,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def lint(self, content: str) -> LintResult:
|
|
90
|
+
"""Scan content for markdown issues.
|
|
91
|
+
|
|
92
|
+
Uses pymarkdownlnt to check for common markdown issues like:
|
|
93
|
+
- MD009: Trailing spaces
|
|
94
|
+
- MD012: Multiple consecutive blank lines
|
|
95
|
+
- MD013: Line length (if not rewrapped first)
|
|
96
|
+
- And many more...
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
content: Markdown content to lint.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
LintResult with list of issues found.
|
|
103
|
+
"""
|
|
104
|
+
if not content:
|
|
105
|
+
return LintResult(issues=[])
|
|
106
|
+
|
|
107
|
+
result = self._api.scan_string(content)
|
|
108
|
+
issues = [
|
|
109
|
+
LintIssue(
|
|
110
|
+
line=failure.line_number,
|
|
111
|
+
column=failure.column_number,
|
|
112
|
+
rule_id=failure.rule_id,
|
|
113
|
+
rule_name=failure.rule_name,
|
|
114
|
+
message=failure.rule_description,
|
|
115
|
+
extra_info=failure.extra_error_information or None,
|
|
116
|
+
)
|
|
117
|
+
for failure in result.scan_failures
|
|
118
|
+
]
|
|
119
|
+
return LintResult(issues=issues)
|
|
120
|
+
|
|
121
|
+
def fix(self, content: str) -> FixResult:
|
|
122
|
+
"""Auto-fix markdown issues where possible.
|
|
123
|
+
|
|
124
|
+
Uses pymarkdownlnt's fix mode to automatically correct issues.
|
|
125
|
+
Not all issues can be auto-fixed - the result includes any
|
|
126
|
+
remaining issues that require manual attention.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
content: Markdown content to fix.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
FixResult with fixed content and any remaining issues.
|
|
133
|
+
"""
|
|
134
|
+
if not content:
|
|
135
|
+
return FixResult(was_fixed=False, content="", issues_remaining=[])
|
|
136
|
+
|
|
137
|
+
fix_result = self._api.fix_string(content)
|
|
138
|
+
fixed_content = fix_result.fixed_file if fix_result.was_fixed else content
|
|
139
|
+
|
|
140
|
+
# Scan the fixed content to find remaining issues
|
|
141
|
+
scan_result = self._api.scan_string(fixed_content)
|
|
142
|
+
remaining_issues = [
|
|
143
|
+
LintIssue(
|
|
144
|
+
line=failure.line_number,
|
|
145
|
+
column=failure.column_number,
|
|
146
|
+
rule_id=failure.rule_id,
|
|
147
|
+
rule_name=failure.rule_name,
|
|
148
|
+
message=failure.rule_description,
|
|
149
|
+
extra_info=failure.extra_error_information or None,
|
|
150
|
+
)
|
|
151
|
+
for failure in scan_result.scan_failures
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
return FixResult(
|
|
155
|
+
was_fixed=fix_result.was_fixed,
|
|
156
|
+
content=fixed_content,
|
|
157
|
+
issues_remaining=remaining_issues,
|
|
158
|
+
)
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Section numbering validation and management for markdown documents."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .parser import MarkdownParser, Section
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class NumberingIssue:
|
|
11
|
+
"""Represents a section numbering issue."""
|
|
12
|
+
|
|
13
|
+
section_title: str
|
|
14
|
+
expected: str
|
|
15
|
+
actual: str | None
|
|
16
|
+
line_number: int
|
|
17
|
+
issue_type: str # 'missing_number', 'wrong_number', 'invalid_format'
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def message(self) -> str:
|
|
21
|
+
"""Generate a descriptive message for this issue."""
|
|
22
|
+
if self.issue_type == "missing_number":
|
|
23
|
+
return f"Section '{self.section_title}' is missing a number (expected: {self.expected})"
|
|
24
|
+
if self.issue_type == "wrong_number":
|
|
25
|
+
return f"Section '{self.section_title}' has wrong number '{self.actual}' (expected: {self.expected})"
|
|
26
|
+
if self.issue_type == "invalid_format":
|
|
27
|
+
return f"Section '{self.section_title}' has invalid number format '{self.actual}'"
|
|
28
|
+
return f"Section '{self.section_title}' has numbering issue: {self.issue_type}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ValidationResult:
|
|
33
|
+
"""Result of section numbering validation."""
|
|
34
|
+
|
|
35
|
+
valid: bool
|
|
36
|
+
issues: list[NumberingIssue]
|
|
37
|
+
recommendations: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RenumberResult:
|
|
42
|
+
"""Result of renumbering operation."""
|
|
43
|
+
|
|
44
|
+
content: str
|
|
45
|
+
sections_renumbered: int
|
|
46
|
+
was_modified: bool
|
|
47
|
+
toc_skipped: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SectionNumberer:
|
|
51
|
+
"""Handles section numbering validation, normalization, and renumbering."""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.toc_section: Section | None = None
|
|
55
|
+
|
|
56
|
+
def validate(
|
|
57
|
+
self, sections: list[Section], toc_section: Section | None = None
|
|
58
|
+
) -> ValidationResult:
|
|
59
|
+
"""
|
|
60
|
+
Validate section numbering consistency.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
sections: List of top-level sections from parser
|
|
64
|
+
toc_section: TOC section to skip during validation
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
ValidationResult with issues and recommendations
|
|
68
|
+
"""
|
|
69
|
+
self.toc_section = toc_section
|
|
70
|
+
issues: list[NumberingIssue] = []
|
|
71
|
+
|
|
72
|
+
# Get all sections in document order, excluding TOC
|
|
73
|
+
all_sections = self._get_numbered_sections(sections)
|
|
74
|
+
|
|
75
|
+
# Validate numbering sequence
|
|
76
|
+
expected_numbers = self._generate_expected_numbers(all_sections)
|
|
77
|
+
|
|
78
|
+
for section, expected in zip(all_sections, expected_numbers, strict=True):
|
|
79
|
+
if section.number != expected:
|
|
80
|
+
issue_type = "missing_number" if section.number is None else "wrong_number"
|
|
81
|
+
issues.append(
|
|
82
|
+
NumberingIssue(
|
|
83
|
+
section_title=section.title,
|
|
84
|
+
expected=expected,
|
|
85
|
+
actual=section.number,
|
|
86
|
+
line_number=section.start_line + 1, # Convert to 1-based
|
|
87
|
+
issue_type=issue_type,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Generate recommendations
|
|
92
|
+
recommendations = self._generate_recommendations(issues)
|
|
93
|
+
|
|
94
|
+
return ValidationResult(
|
|
95
|
+
valid=len(issues) == 0, issues=issues, recommendations=recommendations
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def normalize(self, sections: list[Section]) -> list[Section]:
|
|
99
|
+
"""
|
|
100
|
+
Normalize section numbering to be sequential and properly nested.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
sections: List of sections to normalize
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
List of sections with corrected numbering
|
|
107
|
+
"""
|
|
108
|
+
# Get all sections excluding TOC
|
|
109
|
+
all_sections = self._get_numbered_sections(sections)
|
|
110
|
+
|
|
111
|
+
# Generate correct numbers
|
|
112
|
+
expected_numbers = self._generate_expected_numbers(all_sections)
|
|
113
|
+
|
|
114
|
+
# Update section numbers
|
|
115
|
+
for section, expected_number in zip(all_sections, expected_numbers, strict=True):
|
|
116
|
+
section.number = expected_number
|
|
117
|
+
|
|
118
|
+
return sections
|
|
119
|
+
|
|
120
|
+
def renumber(
|
|
121
|
+
self, sections: list[Section], toc_section: Section | None = None
|
|
122
|
+
) -> dict[str, object]:
|
|
123
|
+
"""Renumber all sections sequentially, skipping TOC.
|
|
124
|
+
|
|
125
|
+
This is the low-level API that operates on Section objects.
|
|
126
|
+
For most use cases, prefer renumber_content() which handles
|
|
127
|
+
the full parse → renumber → reconstruct flow.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
sections: List of sections to renumber
|
|
131
|
+
toc_section: TOC section to skip
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Dictionary with renumbering results
|
|
135
|
+
"""
|
|
136
|
+
self.toc_section = toc_section
|
|
137
|
+
|
|
138
|
+
# Count sections before renumbering
|
|
139
|
+
all_sections = self._get_numbered_sections(sections)
|
|
140
|
+
sections_before = len([s for s in all_sections if s.number is not None])
|
|
141
|
+
|
|
142
|
+
# Normalize numbering
|
|
143
|
+
self.normalize(sections)
|
|
144
|
+
|
|
145
|
+
# Count sections after renumbering
|
|
146
|
+
sections_after = len([s for s in all_sections if s.number is not None])
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"result": "success",
|
|
150
|
+
"sections_renumbered": sections_after,
|
|
151
|
+
"toc_skipped": toc_section is not None,
|
|
152
|
+
"sections_before": sections_before,
|
|
153
|
+
"sections_after": sections_after,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def renumber_content(self, content: str) -> RenumberResult:
|
|
157
|
+
"""Renumber all sections in a document and return the updated content.
|
|
158
|
+
|
|
159
|
+
This is the primary method for renumbering - it handles parsing,
|
|
160
|
+
renumbering, and reconstructing the document in one operation.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
content: The markdown document content.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
RenumberResult with updated content and statistics.
|
|
167
|
+
"""
|
|
168
|
+
parser = MarkdownParser()
|
|
169
|
+
result = parser.parse_content(content)
|
|
170
|
+
|
|
171
|
+
self.toc_section = result.toc_section
|
|
172
|
+
all_sections = self._get_numbered_sections(result.sections)
|
|
173
|
+
|
|
174
|
+
# Normalize numbering (updates Section objects in place)
|
|
175
|
+
self.normalize(result.sections)
|
|
176
|
+
|
|
177
|
+
# Reconstruct document with updated headings
|
|
178
|
+
updated_content = self._reconstruct_document(content, parser.get_all_sections())
|
|
179
|
+
|
|
180
|
+
return RenumberResult(
|
|
181
|
+
content=updated_content,
|
|
182
|
+
sections_renumbered=len(all_sections),
|
|
183
|
+
was_modified=updated_content != content,
|
|
184
|
+
toc_skipped=result.toc_section is not None,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def format_heading(self, section: Section) -> str:
|
|
188
|
+
"""Format a section heading with proper number formatting.
|
|
189
|
+
|
|
190
|
+
Level 2 sections get a trailing period (e.g., "## 1. Title")
|
|
191
|
+
Level 3+ sections don't (e.g., "### 1.1 Title")
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
section: The section to format.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The formatted heading line.
|
|
198
|
+
"""
|
|
199
|
+
hashes = "#" * section.level
|
|
200
|
+
if section.number:
|
|
201
|
+
if section.level == 2:
|
|
202
|
+
return f"{hashes} {section.number}. {section.title}"
|
|
203
|
+
return f"{hashes} {section.number} {section.title}"
|
|
204
|
+
return f"{hashes} {section.title}"
|
|
205
|
+
|
|
206
|
+
def _reconstruct_document(self, content: str, sections: list[Section]) -> str:
|
|
207
|
+
"""Reconstruct document with updated section numbering.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
content: The original document content.
|
|
211
|
+
sections: All sections with updated numbering.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The document with updated section numbers.
|
|
215
|
+
"""
|
|
216
|
+
lines = content.splitlines()
|
|
217
|
+
heading_pattern = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
218
|
+
|
|
219
|
+
for section in sections:
|
|
220
|
+
if section.start_line < len(lines):
|
|
221
|
+
line = lines[section.start_line]
|
|
222
|
+
if heading_pattern.match(line.strip()):
|
|
223
|
+
lines[section.start_line] = self.format_heading(section)
|
|
224
|
+
|
|
225
|
+
result = "\n".join(lines)
|
|
226
|
+
# Preserve trailing newline if original had one
|
|
227
|
+
if content.endswith("\n") and not result.endswith("\n"):
|
|
228
|
+
result += "\n"
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
def _get_numbered_sections(self, sections: list[Section]) -> list[Section]:
|
|
232
|
+
"""Get all sections that should be numbered, excluding TOC."""
|
|
233
|
+
numbered_sections = []
|
|
234
|
+
|
|
235
|
+
for section in sections:
|
|
236
|
+
# Skip TOC section
|
|
237
|
+
if self.toc_section and section == self.toc_section:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Add this section and all its children
|
|
241
|
+
numbered_sections.extend(section.get_all_sections())
|
|
242
|
+
|
|
243
|
+
# Filter out TOC if it appears in children
|
|
244
|
+
if self.toc_section:
|
|
245
|
+
numbered_sections = [s for s in numbered_sections if s != self.toc_section]
|
|
246
|
+
|
|
247
|
+
return numbered_sections
|
|
248
|
+
|
|
249
|
+
def _generate_expected_numbers(self, sections: list[Section]) -> list[str]:
|
|
250
|
+
"""Generate expected section numbers based on hierarchy."""
|
|
251
|
+
if not sections:
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
expected_numbers = []
|
|
255
|
+
counters: dict[int, int] = {} # level -> counter
|
|
256
|
+
|
|
257
|
+
for section in sections:
|
|
258
|
+
level = section.level
|
|
259
|
+
|
|
260
|
+
# Initialize counter for this level if not exists
|
|
261
|
+
if level not in counters:
|
|
262
|
+
counters[level] = 0
|
|
263
|
+
|
|
264
|
+
# Increment counter for this level
|
|
265
|
+
counters[level] += 1
|
|
266
|
+
|
|
267
|
+
# Reset counters for deeper levels
|
|
268
|
+
counters = {lvl: cnt for lvl, cnt in counters.items() if lvl <= level}
|
|
269
|
+
|
|
270
|
+
# Build number string
|
|
271
|
+
number_parts = []
|
|
272
|
+
for lvl in sorted(counters.keys()):
|
|
273
|
+
if lvl <= level:
|
|
274
|
+
number_parts.append(str(counters[lvl]))
|
|
275
|
+
|
|
276
|
+
expected_numbers.append(".".join(number_parts))
|
|
277
|
+
|
|
278
|
+
return expected_numbers
|
|
279
|
+
|
|
280
|
+
def _generate_recommendations(self, issues: list[NumberingIssue]) -> list[str]:
|
|
281
|
+
"""Generate recommendations based on validation issues."""
|
|
282
|
+
recommendations = []
|
|
283
|
+
|
|
284
|
+
if not issues:
|
|
285
|
+
return recommendations
|
|
286
|
+
|
|
287
|
+
# Check for numbering issues
|
|
288
|
+
has_numbering_issues = any(
|
|
289
|
+
issue.issue_type in ["missing_number", "wrong_number"] for issue in issues
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if has_numbering_issues:
|
|
293
|
+
recommendations.append("Run 'renumber' to fix section numbering.")
|
|
294
|
+
|
|
295
|
+
# Check for format issues
|
|
296
|
+
has_format_issues = any(issue.issue_type == "invalid_format" for issue in issues)
|
|
297
|
+
|
|
298
|
+
if has_format_issues:
|
|
299
|
+
recommendations.append("Fix section number formatting manually.")
|
|
300
|
+
|
|
301
|
+
return recommendations
|
|
302
|
+
|
|
303
|
+
def get_section_number_at_level(self, number: str, target_level: int) -> str:
|
|
304
|
+
"""
|
|
305
|
+
Extract section number at a specific level.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
number: Full section number (e.g., "1.2.3")
|
|
309
|
+
target_level: Level to extract (1-based)
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Section number at the specified level
|
|
313
|
+
"""
|
|
314
|
+
if not number:
|
|
315
|
+
return ""
|
|
316
|
+
|
|
317
|
+
parts = number.split(".")
|
|
318
|
+
if target_level <= len(parts):
|
|
319
|
+
return ".".join(parts[:target_level])
|
|
320
|
+
|
|
321
|
+
return number
|
|
322
|
+
|
|
323
|
+
def increment_section_number(self, number: str) -> str:
|
|
324
|
+
"""
|
|
325
|
+
Increment the last part of a section number.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
number: Section number to increment (e.g., "1.2.3")
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Incremented section number (e.g., "1.2.4")
|
|
332
|
+
"""
|
|
333
|
+
if not number:
|
|
334
|
+
return "1"
|
|
335
|
+
|
|
336
|
+
parts = number.split(".")
|
|
337
|
+
if parts:
|
|
338
|
+
try:
|
|
339
|
+
last_part = int(parts[-1])
|
|
340
|
+
parts[-1] = str(last_part + 1)
|
|
341
|
+
return ".".join(parts)
|
|
342
|
+
except ValueError:
|
|
343
|
+
# If last part is not a number, append .1
|
|
344
|
+
return f"{number}.1"
|
|
345
|
+
|
|
346
|
+
return "1"
|
|
347
|
+
|
|
348
|
+
def get_parent_number(self, number: str) -> str:
|
|
349
|
+
"""
|
|
350
|
+
Get the parent section number.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
number: Section number (e.g., "1.2.3")
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Parent section number (e.g., "1.2")
|
|
357
|
+
"""
|
|
358
|
+
if not number:
|
|
359
|
+
return ""
|
|
360
|
+
|
|
361
|
+
parts = number.split(".")
|
|
362
|
+
if len(parts) > 1:
|
|
363
|
+
return ".".join(parts[:-1])
|
|
364
|
+
|
|
365
|
+
return ""
|
|
366
|
+
|
|
367
|
+
def is_valid_number_format(self, number: str) -> bool:
|
|
368
|
+
"""
|
|
369
|
+
Check if a section number has valid format.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
number: Section number to validate
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
True if format is valid
|
|
376
|
+
"""
|
|
377
|
+
if not number:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
parts = number.split(".")
|
|
381
|
+
if not parts:
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
return all(part.isdigit() and int(part) > 0 for part in parts)
|