@jakkrichm/create-nexus-devflow 2.0.23 → 2.0.24

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.
@@ -494,7 +494,7 @@ const DASHBOARD_HTML = `<!doctype html>
494
494
  <body>
495
495
  <main class="shell">
496
496
  <div class="brand">
497
- <svg class="brand-mark" viewBox="0 0 48 48" role="img" aria-label="Nexus-DevFlow">
497
+ <svg class="brand-mark" viewBox="0 0 48 48" role="img" aria-label="Nexus-DevFlow" aria-hidden="true">
498
498
  <path fill="#155eef" d="M4 4h25.2L16.3 44H4zM39.7 4H44v40H26.8z"></path>
499
499
  </svg>
500
500
  <span>Nexus-DevFlow</span>
@@ -523,7 +523,7 @@ const DASHBOARD_HTML = `<!doctype html>
523
523
  <article class="card wide card-enter" style="animation-delay: 0.08s;">
524
524
  <div class="card-head"><span class="label">Current Work</span><span class="pill" id="work-pill">Loading</span></div>
525
525
  <div class="value" id="work-title">Reading living spec...</div>
526
- <div class="progress"><span class="progress-bar" id="work-progress-bar"></span></div>
526
+ <div class="progress"><span class="progress-bar" id="work-progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-valuetext="Loading checklist steps..."></span></div>
527
527
  <div class="muted" id="work-meta">Loading checklist steps...</div>
528
528
  </article>
529
529
 
@@ -538,28 +538,28 @@ const DASHBOARD_HTML = `<!doctype html>
538
538
 
539
539
  <article class="card card-enter" style="animation-delay: 0.16s;">
540
540
  <div class="card-head"><span class="label">Findings</span><span class="value" id="findings-count">-</span></div>
541
- <ul id="findings-list"><li class="empty">Loading findings...</li></ul>
541
+ <ul id="findings-list" aria-live="polite"><li class="empty">Loading findings...</li></ul>
542
542
  </article>
543
543
 
544
544
  <article class="card card-enter" style="animation-delay: 0.20s;">
545
545
  <div class="card-head"><span class="label">Completion</span><span class="pill" id="completion-pill">Loading</span></div>
546
- <ul id="completion-list"><li class="empty">Checking readiness...</li></ul>
546
+ <ul id="completion-list" aria-live="polite"><li class="empty">Checking readiness...</li></ul>
547
547
  </article>
548
548
 
549
549
  <article class="card card-enter" style="animation-delay: 0.24s;">
550
550
  <div class="card-head"><span class="label">Attention</span><span class="value" id="warnings-count">-</span></div>
551
- <ul id="warnings-list"><li class="empty">Loading warnings...</li></ul>
551
+ <ul id="warnings-list" aria-live="polite"><li class="empty">Loading warnings...</li></ul>
552
552
  </article>
553
553
 
554
554
  <article class="card full card-enter" style="animation-delay: 0.28s;">
555
555
  <div class="card-head"><span class="label">Completed Work Archive</span><span class="value" id="history-count">-</span></div>
556
556
  <div class="muted">Categorized history of features, fixes, and rollbacks.</div>
557
- <ul id="history-list"><li class="empty">Loading history archive...</li></ul>
557
+ <ul id="history-list" tabindex="0" aria-label="Completed work archive"><li class="empty">Loading history archive...</li></ul>
558
558
  </article>
559
559
 
560
560
  <article class="card full code-panel next-action card-enter" style="animation-delay: 0.32s;" id="next-panel">
561
561
  <span class="label">Next Action</span>
562
- <div class="command" id="next-command">Loading...</div>
562
+ <div class="command" id="next-command" aria-live="polite">Loading...</div>
563
563
  <div class="muted" id="next-reason"></div>
564
564
  </article>
565
565
  </section>
@@ -573,10 +573,10 @@ const DASHBOARD_HTML = `<!doctype html>
573
573
  let isFirstLoad = true;
574
574
  let refreshing = false;
575
575
 
576
- function setPill(id, value) {
576
+ function setPill(id, value, label = value) {
577
577
  const node = byId(id);
578
578
  if (!node) return;
579
- const formatted = String(value || '').replaceAll("_", " ");
579
+ const formatted = String(label || '').replaceAll("_", " ");
580
580
  const newClass = "pill " + (value || 'idle');
581
581
 
582
582
  if (!isFirstLoad && (node.textContent !== formatted || !node.className.includes(value))) {
@@ -654,24 +654,36 @@ const DASHBOARD_HTML = `<!doctype html>
654
654
 
655
655
  byId('live-dot').style.background = '#0b7a53';
656
656
  byId('live-dot').classList.add('is-live');
657
- byId('live-label').textContent = 'Live';
657
+ byId('live-label').textContent = 'Connected';
658
658
 
659
659
  byId('project-name').textContent = data.project?.name || 'Nexus-DevFlow';
660
660
  byId('project-path').textContent = data.project?.root || '';
661
661
 
662
- setPill('health-pill', data.health || 'ok');
662
+ const healthIssues = (data.warnings || []).map((warning) => warning.message).concat(
663
+ (data.findings?.blockers || []).map((finding) => "Blocking finding " + finding.id + ": " + finding.title)
664
+ );
665
+ const healthCount = healthIssues.length;
666
+ setPill(
667
+ 'health-pill',
668
+ data.health || 'ok',
669
+ healthCount === 0 ? 'Clear' : healthCount + (healthCount === 1 ? ' issue' : ' issues')
670
+ );
671
+
663
672
  byId('val-version').textContent = data.devflow?.version ? 'v' + data.devflow.version : '-';
664
673
  byId('val-adapters').textContent = (data.devflow?.adapters || []).join(', ') || 'none';
665
674
 
666
675
  const work = data.currentWork || {};
667
676
  setPill('work-pill', work.state || 'idle');
668
677
  byId('work-title').textContent = work.title || 'No active run in progress';
669
- byId('work-meta').textContent = work.state === 'active'
678
+ const workSummary = work.state === 'active'
670
679
  ? (work.completed + ' of ' + work.total + ' steps completed')
671
680
  : 'Workspace is idle. Run /feature or /fix to start a new delivery run.';
681
+ byId('work-meta').textContent = workSummary;
672
682
 
673
683
  const pct = work.total > 0 ? Math.round((work.completed / work.total) * 100) : 0;
674
684
  const progressBar = byId('work-progress-bar');
685
+ progressBar.setAttribute('aria-valuenow', String(pct));
686
+ progressBar.setAttribute('aria-valuetext', workSummary);
675
687
 
676
688
  const prevPct = prevData?.currentWork ? (prevData.currentWork.total > 0 ? Math.round((prevData.currentWork.completed / prevData.currentWork.total) * 100) : 0) : 0;
677
689
  if (!isFirstLoad && pct > prevPct) {
@@ -689,7 +701,7 @@ const DASHBOARD_HTML = `<!doctype html>
689
701
  progressBar.style.width = pct + '%';
690
702
 
691
703
  const git = data.git || {};
692
- setPill('git-pill', git.clean ? 'ok' : 'warning');
704
+ setPill('git-pill', git.clean ? 'ok' : 'warning', !git.branch ? 'Unavailable' : git.clean ? 'Clean' : (git.changedFiles + ' changed'));
693
705
  byId('git-branch').textContent = git.branch || 'unknown';
694
706
  byId('git-changed').textContent = git.clean ? 'clean' : (git.changedFiles + ' changed files');
695
707
  byId('git-upstream').textContent = git.upstream || 'none';
@@ -748,6 +760,10 @@ const DASHBOARD_HTML = `<!doctype html>
748
760
  cmdNode.textContent = next.command || '/feature';
749
761
  byId('next-reason').textContent = next.reason || 'Ready for next feature';
750
762
 
763
+ if (isFirstLoad) {
764
+ requestAnimationFrame(() => document.body.classList.add('hydrated'));
765
+ }
766
+
751
767
  prevData = {
752
768
  ...data,
753
769
  historyCount: historyItems.length
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard.js","sourceRoot":"","sources":["../../lib/dashboard.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dashboard.js","sourceRoot":"","sources":["../../lib/dashboard.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jakkrichm/create-nexus-devflow",
3
- "version": "2.0.23",
3
+ "version": "2.0.24",
4
4
  "description": "Installer package for Nexus-DevFlow agentic workflow layer.",
5
5
  "license": "MIT",
6
6
  "author": "Jakkrich",
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: convert-any-to-md
3
+ description: '[Devflow] Converts any document (.xlsx, .pdf, .docx, .txt, .csv, .log, .json, .yaml, etc.) or mixed folders into clean Markdown in devflow/reference. Use whenever documents need to be analyzed, summarized, searched, or extracted from.'
4
+ ---
5
+
6
+ # Convert Any Document to Markdown
7
+
8
+ ## When to use this skill
9
+
10
+ Trigger this skill any time there is a document (`.xlsx`, `.pdf`, `.docx`, `.txt`, `.csv`, `.log`, `.json`, `.yaml`, etc.) or a folder of documents that needs to be analyzed, summarized, reviewed, or extracted from.
11
+
12
+ Instead of parsing complex binary XML formats (`.docx`, `.xlsx`) or print layouts (`.pdf`) directly, this skill automatically detects the file type, extracts embedded text and images, and outputs standardized Markdown into **`devflow/reference/`** (or a specified output path).
13
+
14
+ Use this skill for:
15
+ - Single files: `.xlsx`, `.pdf`, `.docx`, `.txt`, `.csv`, `.log`, `.json`, `.yaml`, etc.
16
+ - Folder batch mode: A directory containing a mix of multiple file types.
17
+
18
+ ## Setup (once per environment)
19
+
20
+ Before the first conversion in a given environment, follow [`references/setup.md`](references/setup.md) to ensure Python, `markitdown`, `pymupdf`, and `openpyxl` are installed:
21
+
22
+ ```powershell
23
+ python -m pip install -r .agents/skills/convert-any-to-md/scripts/requirements.txt
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ The conversion script lives at `.agents/skills/convert-any-to-md/scripts/convert_any_to_md.py`.
29
+
30
+ ### Default Destination (`devflow/reference/`)
31
+
32
+ By default, all converted Markdown files and extracted image folders (`img/`) are placed inside **`devflow/reference/`**:
33
+
34
+ ```powershell
35
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\document.pdf"
36
+ ```
37
+
38
+ Output:
39
+ ```text
40
+ devflow/reference/
41
+ └── document/
42
+ ├── img/
43
+ │ ├── page001_img001.png
44
+ │ └── ...
45
+ └── document.md
46
+ ```
47
+
48
+ ### Specifying Custom Output Destination (`-o`)
49
+
50
+ To direct output to a specific folder:
51
+
52
+ ```powershell
53
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\document.docx" -o "C:\custom\path"
54
+ ```
55
+
56
+ ### Folder Batch Mode (`--recursive`)
57
+
58
+ To convert an entire folder (including mixed file types):
59
+
60
+ ```powershell
61
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\documents_folder"
62
+ ```
63
+
64
+ Add `--recursive` to scan subdirectories:
65
+
66
+ ```powershell
67
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\documents_folder" --recursive
68
+ ```
69
+
70
+ ## Format Support & Behavior
71
+
72
+ | Format | Handler | Image Extraction | Output Structure |
73
+ | :--- | :--- | :--- | :--- |
74
+ | `.xlsx` | MarkItDown + OpenPyXL | Extracts sheet embedded images | `<name>/<name>.md` + `img/` |
75
+ | `.pdf` | MarkItDown + PyMuPDF | Extracts page images into appendix | `<name>/<name>.md` + `img/` |
76
+ | `.docx` | MarkItDown + ZIP Media | Extracts word media images | `<name>/<name>.md` + `img/` |
77
+ | `.txt`, `.csv`, `.json`, `.yaml`, `.log` | Plaintext Formatter | N/A | `<name>/<name>.md` |
78
+
79
+ > [!NOTE]
80
+ > Legacy binary formats (`.xls`, `.doc`) are not supported directly. Ask the user to re-save them as `.xlsx` / `.docx` first.
@@ -0,0 +1,29 @@
1
+ # Environment Setup for convert-any-to-md
2
+
3
+ Follow these steps to set up the Python dependencies for document conversion.
4
+
5
+ ## 1. Prerequisites
6
+
7
+ - Python 3.10+
8
+ - pip
9
+
10
+ ## 2. Installation
11
+
12
+ Run:
13
+
14
+ ```powershell
15
+ python -m pip install -r .agents/skills/convert-any-to-md/scripts/requirements.txt
16
+ ```
17
+
18
+ This installs:
19
+ - `markitdown[xlsx]`
20
+ - `pymupdf` (for PDF image extraction)
21
+ - `openpyxl` (for Excel parsing)
22
+
23
+ ## 3. Verification
24
+
25
+ ```powershell
26
+ python -c "from markitdown import MarkItDown; import fitz; import openpyxl; print('Dependencies OK')"
27
+ ```
28
+
29
+ Expect `Dependencies OK` to print cleanly.
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env python3
2
+ """Convert any document (.xlsx, .pdf, .docx, .txt, .csv, .log, .json, .yaml, etc.)
3
+ or a directory of mixed files into clean Markdown, outputting by default into `devflow/reference/`.
4
+
5
+ Usage:
6
+ python convert_any_to_md.py <input> [-o OUTPUT] [--recursive]
7
+
8
+ Arguments:
9
+ <input> Path to a single document or a directory.
10
+ -o, --output Target output directory (default: devflow/reference).
11
+ --recursive Recursively process subdirectories when input is a directory.
12
+
13
+ Exit codes:
14
+ 0 - All requested conversions succeeded
15
+ 1 - One or more conversions failed (partial success in batch mode)
16
+ 2 - Required dependency missing
17
+ 3 - Invalid input path
18
+ """
19
+
20
+ import argparse
21
+ import hashlib
22
+ import posixpath
23
+ import re
24
+ import shutil
25
+ import sys
26
+ import zipfile
27
+ from pathlib import Path
28
+ from xml.etree import ElementTree as ET
29
+
30
+ EXIT_OK = 0
31
+ EXIT_CONVERSION_FAILED = 1
32
+ EXIT_MISSING_DEPENDENCY = 2
33
+ EXIT_INVALID_INPUT = 3
34
+
35
+ # Namespaces for OOXML
36
+ _REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
37
+ _MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
38
+ _R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
39
+ _A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
40
+
41
+ # MarkItDown placeholder pattern for docx
42
+ _PLACEHOLDER_IMAGE_RE = re.compile(
43
+ r'!\[([^\]]*)\]\(data:image/[a-zA-Z0-9.+-]+;base64[^)]*\)'
44
+ )
45
+
46
+ # Sheet header pattern for xlsx
47
+ _SHEET_HEADER_RE = re.compile(r"^## (.+)$", re.MULTILINE)
48
+
49
+ SUPPORTED_TEXT_EXTS = {
50
+ ".txt", ".log", ".csv", ".tsv", ".json", ".yaml", ".yml",
51
+ ".md", ".markdown", ".py", ".js", ".ts", ".html", ".xml", ".css"
52
+ }
53
+
54
+ IGNORE_EXTS = {
55
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".zip", ".tar", ".gz",
56
+ ".7z", ".rar", ".iso", ".pyc", ".class", ".o", ".obj"
57
+ }
58
+
59
+ def get_markitdown():
60
+ """Lazy import MarkItDown if available."""
61
+ try:
62
+ from markitdown import MarkItDown
63
+ return MarkItDown()
64
+ except ImportError:
65
+ return None
66
+
67
+ # ============================================================================
68
+ # Excel Handler
69
+ # ============================================================================
70
+
71
+ def _normalize_rel_path(base_dir: str, target: str) -> str:
72
+ if target.startswith("/"):
73
+ return target.lstrip("/")
74
+ return posixpath.normpath(posixpath.join(base_dir, target))
75
+
76
+ def _xlsx_sheet_media(xlsx_path: Path):
77
+ try:
78
+ with zipfile.ZipFile(xlsx_path) as z:
79
+ names = set(z.namelist())
80
+ if "xl/workbook.xml" not in names or "xl/_rels/workbook.xml.rels" not in names:
81
+ return {}
82
+ workbook_xml = z.read("xl/workbook.xml")
83
+ workbook_rels_xml = z.read("xl/_rels/workbook.xml.rels")
84
+
85
+ sheet_rid = {}
86
+ for sheet_el in ET.fromstring(workbook_xml).iter(f"{{{_MAIN_NS}}}sheet"):
87
+ name = sheet_el.get("name")
88
+ rid = sheet_el.get(f"{{{_R_NS}}}id")
89
+ if name and rid:
90
+ sheet_rid[name] = rid
91
+
92
+ rid_target = {}
93
+ for rel in ET.fromstring(workbook_rels_xml).findall(f"{{{_REL_NS}}}Relationship"):
94
+ rid_target[rel.get("Id")] = rel.get("Target")
95
+
96
+ result = {}
97
+ for sheet_name, rid in sheet_rid.items():
98
+ target = rid_target.get(rid)
99
+ if not target:
100
+ continue
101
+ sheet_path = _normalize_rel_path("xl", target)
102
+ if sheet_path not in names or "/" not in sheet_path:
103
+ continue
104
+ sheet_dir, sheet_file = sheet_path.rsplit("/", 1)
105
+ sheet_rels_path = f"{sheet_dir}/_rels/{sheet_file}.rels"
106
+ if sheet_rels_path not in names:
107
+ continue
108
+
109
+ drawing_rid = None
110
+ for d in ET.fromstring(z.read(sheet_path)).iter(f"{{{_MAIN_NS}}}drawing"):
111
+ drawing_rid = d.get(f"{{{_R_NS}}}id")
112
+ break
113
+ if not drawing_rid:
114
+ continue
115
+
116
+ drawing_target = None
117
+ for rel in ET.fromstring(z.read(sheet_rels_path)).findall(f"{{{_REL_NS}}}Relationship"):
118
+ if rel.get("Id") == drawing_rid:
119
+ drawing_target = rel.get("Target")
120
+ break
121
+ if not drawing_target:
122
+ continue
123
+ drawing_path = _normalize_rel_path(sheet_dir, drawing_target)
124
+ if drawing_path not in names or "/" not in drawing_path:
125
+ continue
126
+ drawing_dir, drawing_file = drawing_path.rsplit("/", 1)
127
+ drawing_rels_path = f"{drawing_dir}/_rels/{drawing_file}.rels"
128
+ if drawing_rels_path not in names:
129
+ continue
130
+
131
+ drawing_rel_map = {}
132
+ for rel in ET.fromstring(z.read(drawing_rels_path)).findall(f"{{{_REL_NS}}}Relationship"):
133
+ drawing_rel_map[rel.get("Id")] = rel.get("Target")
134
+
135
+ media_paths = []
136
+ for blip in ET.fromstring(z.read(drawing_path)).iter(f"{{{_A_NS}}}blip"):
137
+ embed_rid = blip.get(f"{{{_R_NS}}}embed")
138
+ if not embed_rid:
139
+ continue
140
+ rel_target = drawing_rel_map.get(embed_rid)
141
+ if not rel_target:
142
+ continue
143
+ media_path = _normalize_rel_path(drawing_dir, rel_target)
144
+ if media_path in names:
145
+ media_paths.append(media_path)
146
+
147
+ if media_paths:
148
+ result[sheet_name] = media_paths
149
+ return result
150
+ except Exception:
151
+ return {}
152
+
153
+ def extract_xlsx_images(xlsx_path: Path, img_dir: Path):
154
+ sheet_media = _xlsx_sheet_media(xlsx_path)
155
+ if not sheet_media:
156
+ return {}
157
+
158
+ written = {}
159
+ with zipfile.ZipFile(xlsx_path) as z:
160
+ names_in_zip = set(z.namelist())
161
+ for sheet_idx, (sheet_name, media_paths) in enumerate(sheet_media.items(), start=1):
162
+ safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", sheet_name).strip("_") or "sheet"
163
+ safe_prefix = f"sheet{sheet_idx:03d}_{safe_name}"
164
+ files = []
165
+ for idx, media_path in enumerate(media_paths, start=1):
166
+ if media_path not in names_in_zip:
167
+ continue
168
+ ext = Path(media_path).suffix.lstrip(".").lower() or "bin"
169
+ if ext == "jpg":
170
+ ext = "jpeg"
171
+ fname = f"{safe_prefix}_img{idx:03d}.{ext}"
172
+ dest = img_dir / fname
173
+ img_dir.mkdir(parents=True, exist_ok=True)
174
+ with z.open(media_path) as src, open(dest, "wb") as dst:
175
+ shutil.copyfileobj(src, dst)
176
+ files.append(fname)
177
+ if files:
178
+ written[sheet_name] = files
179
+ return written
180
+
181
+ def convert_excel(file_path: Path, out_dir: Path, md_engine) -> bool:
182
+ img_dir = out_dir / "img"
183
+ sheet_images = extract_xlsx_images(file_path, img_dir)
184
+
185
+ if md_engine:
186
+ res = md_engine.convert(str(file_path))
187
+ text = res.text_content
188
+ else:
189
+ text = f"# {file_path.stem}\n\n*(MarkItDown engine not available. Basic conversion applied)*\n"
190
+
191
+ if sheet_images:
192
+ matches = list(_SHEET_HEADER_RE.finditer(text))
193
+ if matches:
194
+ parts = []
195
+ last_end = 0
196
+ for i, m in enumerate(matches):
197
+ sheet_name = m.group(1).strip()
198
+ next_start = matches[i + 1].start() if i + 1 < len(matches) else len(text)
199
+ section = text[last_end:next_start]
200
+ last_end = next_start
201
+
202
+ if sheet_name in sheet_images:
203
+ img_lines = ["\n\n#### Images in this sheet\n"]
204
+ for fname in sheet_images[sheet_name]:
205
+ img_lines.append(f"![{sheet_name} image](img/{fname})\n")
206
+ section = section.rstrip() + "\n" + "".join(img_lines)
207
+ parts.append(section)
208
+ text = "".join(parts)
209
+
210
+ out_md = out_dir / f"{file_path.stem}.md"
211
+ out_dir.mkdir(parents=True, exist_ok=True)
212
+ out_md.write_text(text, encoding="utf-8")
213
+ return True
214
+
215
+ # ============================================================================
216
+ # PDF Handler
217
+ # ============================================================================
218
+
219
+ def extract_pdf_images(pdf_path: Path, img_dir: Path):
220
+ try:
221
+ import fitz
222
+ except ImportError:
223
+ return {}
224
+
225
+ written_by_page = {}
226
+ try:
227
+ doc = fitz.open(str(pdf_path))
228
+ except Exception:
229
+ return written_by_page
230
+
231
+ try:
232
+ for page_index in range(len(doc)):
233
+ page = doc[page_index]
234
+ page_label = page_index + 1
235
+ raw_images = []
236
+ try:
237
+ xobjects = page.get_images(full=True)
238
+ except Exception:
239
+ xobjects = []
240
+
241
+ for img in xobjects:
242
+ xref = img[0]
243
+ try:
244
+ base_image = doc.extract_image(xref)
245
+ except Exception:
246
+ continue
247
+ img_bytes = base_image.get("image") or b""
248
+ if not img_bytes:
249
+ continue
250
+ ext = (base_image.get("ext") or "png").lower()
251
+ raw_images.append((img_bytes, ext))
252
+
253
+ if not raw_images:
254
+ continue
255
+
256
+ saved_files = []
257
+ img_dir.mkdir(parents=True, exist_ok=True)
258
+ for idx, (img_bytes, ext) in enumerate(raw_images, start=1):
259
+ fname = f"page{page_label:03d}_img{idx:03d}.{ext}"
260
+ dest = img_dir / fname
261
+ dest.write_bytes(img_bytes)
262
+ saved_files.append(fname)
263
+
264
+ if saved_files:
265
+ written_by_page[page_label] = saved_files
266
+ finally:
267
+ doc.close()
268
+ return written_by_page
269
+
270
+ def convert_pdf(file_path: Path, out_dir: Path, md_engine) -> bool:
271
+ img_dir = out_dir / "img"
272
+ page_images = extract_pdf_images(file_path, img_dir)
273
+
274
+ if md_engine:
275
+ res = md_engine.convert(str(file_path))
276
+ text = res.text_content
277
+ else:
278
+ text = f"# {file_path.stem}\n\n*(MarkItDown engine not available)*\n"
279
+
280
+ if page_images:
281
+ appendix = ["\n\n## Extracted Images\n"]
282
+ for page_num in sorted(page_images.keys()):
283
+ appendix.append(f"\n### Page {page_num}\n")
284
+ for fname in page_images[page_num]:
285
+ appendix.append(f"![Page {page_num} image](img/{fname})\n")
286
+ text = text.rstrip() + "".join(appendix)
287
+
288
+ out_md = out_dir / f"{file_path.stem}.md"
289
+ out_dir.mkdir(parents=True, exist_ok=True)
290
+ out_md.write_text(text, encoding="utf-8")
291
+ return True
292
+
293
+ # ============================================================================
294
+ # Word Handler
295
+ # ============================================================================
296
+
297
+ def _word_ordered_media(docx_path: Path):
298
+ try:
299
+ with zipfile.ZipFile(docx_path) as z:
300
+ if "word/document.xml" not in z.namelist() or "word/_rels/document.xml.rels" not in z.namelist():
301
+ return []
302
+ rels_xml = z.read("word/_rels/document.xml.rels")
303
+ doc_xml = z.read("word/document.xml")
304
+ except Exception:
305
+ return []
306
+
307
+ try:
308
+ rels_root = ET.fromstring(rels_xml)
309
+ doc_root = ET.fromstring(doc_xml)
310
+ except Exception:
311
+ return []
312
+
313
+ rel_map = {rel.get("Id"): rel.get("Target") for rel in rels_root.findall(f"{{{_REL_NS}}}Relationship")}
314
+ ordered_rel_ids = []
315
+ for elem in doc_root.iter():
316
+ tag = elem.tag.rsplit("}", 1)[-1]
317
+ rid = elem.get(f"{{{_R_NS}}}embed") if tag == "blip" else (elem.get(f"{{{_R_NS}}}id") if tag == "imagedata" else None)
318
+ if rid:
319
+ ordered_rel_ids.append(rid)
320
+
321
+ ordered_media = []
322
+ for rid in ordered_rel_ids:
323
+ target = rel_map.get(rid)
324
+ if not target or "media/" not in target:
325
+ continue
326
+ media_path = target.lstrip("/") if target.startswith("/") else posixpath.normpath(target if target.startswith("word/") else posixpath.join("word", target))
327
+ ordered_media.append((rid, media_path))
328
+ return ordered_media
329
+
330
+ def extract_word_images(docx_path: Path, img_dir: Path):
331
+ ordered_media = _word_ordered_media(docx_path)
332
+ if not ordered_media:
333
+ return []
334
+
335
+ written = []
336
+ with zipfile.ZipFile(docx_path) as z:
337
+ names_in_zip = set(z.namelist())
338
+ for idx, (rid, media_path) in enumerate(ordered_media, start=1):
339
+ if media_path not in names_in_zip:
340
+ continue
341
+ ext = Path(media_path).suffix.lstrip(".").lower() or "bin"
342
+ if ext == "jpg":
343
+ ext = "jpeg"
344
+ fname = f"img{idx:03d}.{ext}"
345
+ dest = img_dir / fname
346
+ img_dir.mkdir(parents=True, exist_ok=True)
347
+ with z.open(media_path) as src, open(dest, "wb") as dst:
348
+ shutil.copyfileobj(src, dst)
349
+ written.append(fname)
350
+ return written
351
+
352
+ def convert_word(file_path: Path, out_dir: Path, md_engine) -> bool:
353
+ img_dir = out_dir / "img"
354
+ images = extract_word_images(file_path, img_dir)
355
+
356
+ if md_engine:
357
+ res = md_engine.convert(str(file_path))
358
+ text = res.text_content
359
+ else:
360
+ text = f"# {file_path.stem}\n\n*(MarkItDown engine not available)*\n"
361
+
362
+ if images:
363
+ img_idx = 0
364
+ def replace_img(match):
365
+ nonlocal img_idx
366
+ alt_text = match.group(1)
367
+ if img_idx < len(images):
368
+ fname = images[img_idx]
369
+ img_idx += 1
370
+ return f"![{alt_text}](img/{fname})"
371
+ return match.group(0)
372
+
373
+ text = _PLACEHOLDER_IMAGE_RE.sub(replace_img, text)
374
+
375
+ out_md = out_dir / f"{file_path.stem}.md"
376
+ out_dir.mkdir(parents=True, exist_ok=True)
377
+ out_md.write_text(text, encoding="utf-8")
378
+ return True
379
+
380
+ # ============================================================================
381
+ # Plaintext Handler
382
+ # ============================================================================
383
+
384
+ def convert_text(file_path: Path, out_dir: Path) -> bool:
385
+ try:
386
+ content = file_path.read_text(encoding="utf-8", errors="replace")
387
+ except Exception as exc:
388
+ print(f"ERROR reading {file_path}: {exc}", file=sys.stderr)
389
+ return False
390
+
391
+ ext = file_path.suffix.lower()
392
+ if ext in {".md", ".markdown"}:
393
+ md_text = content
394
+ else:
395
+ lang_map = {
396
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
397
+ ".json": "json", ".yaml": "yaml", ".yml": "yaml",
398
+ ".html": "html", ".xml": "xml", ".css": "css", ".csv": "csv"
399
+ }
400
+ lang = lang_map.get(ext, "")
401
+ if lang:
402
+ md_text = f"# {file_path.name}\n\n```{lang}\n{content}\n```\n"
403
+ else:
404
+ md_text = f"# {file_path.name}\n\n{content}\n"
405
+
406
+ out_md = out_dir / f"{file_path.stem}.md"
407
+ out_dir.mkdir(parents=True, exist_ok=True)
408
+ out_md.write_text(md_text, encoding="utf-8")
409
+ return True
410
+
411
+ # ============================================================================
412
+ # Main Router
413
+ # ============================================================================
414
+
415
+ def convert_single_file(file_path: Path, base_out_dir: Path, md_engine) -> bool:
416
+ ext = file_path.suffix.lower()
417
+ out_dir = base_out_dir / file_path.stem
418
+
419
+ if ext in IGNORE_EXTS:
420
+ return True
421
+
422
+ print(f"Converting [{ext or 'text'}] {file_path.name} -> {out_dir}")
423
+
424
+ if ext == ".xlsx":
425
+ return convert_excel(file_path, out_dir, md_engine)
426
+ elif ext == ".pdf":
427
+ return convert_pdf(file_path, out_dir, md_engine)
428
+ elif ext == ".docx":
429
+ return convert_word(file_path, out_dir, md_engine)
430
+ elif ext in SUPPORTED_TEXT_EXTS or ext == "":
431
+ return convert_text(file_path, out_dir)
432
+ else:
433
+ # Fallback to text conversion for unknown extensions
434
+ return convert_text(file_path, out_dir)
435
+
436
+ def main():
437
+ parser = argparse.ArgumentParser(
438
+ description="Convert documents to Markdown and save to devflow/reference/"
439
+ )
440
+ parser.add_argument("input", help="Path to a file or directory to convert")
441
+ parser.add_argument(
442
+ "-o", "--output",
443
+ default="devflow/reference",
444
+ help="Target output directory (default: devflow/reference)"
445
+ )
446
+ parser.add_argument(
447
+ "--recursive",
448
+ action="store_true",
449
+ help="Recursively process subdirectories"
450
+ )
451
+
452
+ args = parser.parse_args()
453
+ input_path = Path(args.input).resolve()
454
+ base_out_dir = Path(args.output).resolve()
455
+
456
+ if not input_path.exists():
457
+ print(f"ERROR: Input path not found: {input_path}", file=sys.stderr)
458
+ sys.exit(EXIT_INVALID_INPUT)
459
+
460
+ md_engine = get_markitdown()
461
+
462
+ if input_path.is_file():
463
+ success = convert_single_file(input_path, base_out_dir, md_engine)
464
+ sys.exit(EXIT_OK if success else EXIT_CONVERSION_FAILED)
465
+ elif input_path.is_dir():
466
+ pattern = "**/*" if args.recursive else "*"
467
+ files = [p for p in input_path.glob(pattern) if p.is_file()]
468
+
469
+ if not files:
470
+ print(f"No files found in {input_path}")
471
+ sys.exit(EXIT_OK)
472
+
473
+ success_count = 0
474
+ fail_count = 0
475
+ for f in files:
476
+ if f.suffix.lower() in IGNORE_EXTS:
477
+ continue
478
+ if convert_single_file(f, base_out_dir, md_engine):
479
+ success_count += 1
480
+ else:
481
+ fail_count += 1
482
+
483
+ print(f"\nBatch summary: {success_count} succeeded, {fail_count} failed.")
484
+ sys.exit(EXIT_OK if fail_count == 0 else EXIT_CONVERSION_FAILED)
485
+
486
+ if __name__ == "__main__":
487
+ main()
@@ -0,0 +1,3 @@
1
+ markitdown[xlsx]
2
+ pymupdf
3
+ openpyxl
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: convert-any-to-md
3
+ description: '[Devflow] Converts any document (.xlsx, .pdf, .docx, .txt, .csv, .log, .json, .yaml, etc.) or mixed folders into clean Markdown in devflow/reference. Use whenever documents need to be analyzed, summarized, searched, or extracted from.'
4
+ ---
5
+
6
+ # Convert Any Document to Markdown
7
+
8
+ ## When to use this skill
9
+
10
+ Trigger this skill any time there is a document (`.xlsx`, `.pdf`, `.docx`, `.txt`, `.csv`, `.log`, `.json`, `.yaml`, etc.) or a folder of documents that needs to be analyzed, summarized, reviewed, or extracted from.
11
+
12
+ Instead of parsing complex binary XML formats (`.docx`, `.xlsx`) or print layouts (`.pdf`) directly, this skill automatically detects the file type, extracts embedded text and images, and outputs standardized Markdown into **`devflow/reference/`** (or a specified output path).
13
+
14
+ Use this skill for:
15
+ - Single files: `.xlsx`, `.pdf`, `.docx`, `.txt`, `.csv`, `.log`, `.json`, `.yaml`, etc.
16
+ - Folder batch mode: A directory containing a mix of multiple file types.
17
+
18
+ ## Setup (once per environment)
19
+
20
+ Before the first conversion in a given environment, follow [`references/setup.md`](references/setup.md) to ensure Python, `markitdown`, `pymupdf`, and `openpyxl` are installed:
21
+
22
+ ```powershell
23
+ python -m pip install -r .agents/skills/convert-any-to-md/scripts/requirements.txt
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ The conversion script lives at `.agents/skills/convert-any-to-md/scripts/convert_any_to_md.py`.
29
+
30
+ ### Default Destination (`devflow/reference/`)
31
+
32
+ By default, all converted Markdown files and extracted image folders (`img/`) are placed inside **`devflow/reference/`**:
33
+
34
+ ```powershell
35
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\document.pdf"
36
+ ```
37
+
38
+ Output:
39
+ ```text
40
+ devflow/reference/
41
+ └── document/
42
+ ├── img/
43
+ │ ├── page001_img001.png
44
+ │ └── ...
45
+ └── document.md
46
+ ```
47
+
48
+ ### Specifying Custom Output Destination (`-o`)
49
+
50
+ To direct output to a specific folder:
51
+
52
+ ```powershell
53
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\document.docx" -o "C:\custom\path"
54
+ ```
55
+
56
+ ### Folder Batch Mode (`--recursive`)
57
+
58
+ To convert an entire folder (including mixed file types):
59
+
60
+ ```powershell
61
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\documents_folder"
62
+ ```
63
+
64
+ Add `--recursive` to scan subdirectories:
65
+
66
+ ```powershell
67
+ python .agents/skills/convert-any-to-md/scripts/convert_any_to_md.py "C:\path\to\documents_folder" --recursive
68
+ ```
69
+
70
+ ## Format Support & Behavior
71
+
72
+ | Format | Handler | Image Extraction | Output Structure |
73
+ | :--- | :--- | :--- | :--- |
74
+ | `.xlsx` | MarkItDown + OpenPyXL | Extracts sheet embedded images | `<name>/<name>.md` + `img/` |
75
+ | `.pdf` | MarkItDown + PyMuPDF | Extracts page images into appendix | `<name>/<name>.md` + `img/` |
76
+ | `.docx` | MarkItDown + ZIP Media | Extracts word media images | `<name>/<name>.md` + `img/` |
77
+ | `.txt`, `.csv`, `.json`, `.yaml`, `.log` | Plaintext Formatter | N/A | `<name>/<name>.md` |
78
+
79
+ > [!NOTE]
80
+ > Legacy binary formats (`.xls`, `.doc`) are not supported directly. Ask the user to re-save them as `.xlsx` / `.docx` first.