skilldrop-cli 0.16.34 → 0.16.36
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.
- package/README.md +1 -0
- package/agents/README.md +4 -1
- package/agents/docs-reviewer.md +47 -0
- package/agents/finding-adjudicator.md +49 -0
- package/agents/test-reviewer.md +52 -0
- package/catalogue.json +17 -7
- package/model-routing.json +40 -0
- package/package.json +1 -1
- package/packs/ai-engineering/skills/agent-config-review/SKILL.md +116 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/eval_queries.json +12 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/evals.json +75 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/db-migrator.md +8 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/reviewer.md +9 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.json +48 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.local.json +5 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.mcp.json +22 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/1/package.json +12 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/2/.cursor/mcp.json +17 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/2/config.toml +21 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/my-user-settings.json +11 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/agents/release-notes.md +13 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/settings.json +39 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.mcp.json +18 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/package.json +14 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/.github/workflows/codex-triage.yml +29 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/AGENTS.md +16 -0
- package/packs/ai-engineering/skills/agent-config-review/evals/files/5/codex/config.toml +34 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.claude/settings.json +22 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.mcp.json +9 -0
- package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +81 -0
- package/packs/ai-engineering/skills/agent-config-review/manifest.json +60 -0
- package/packs/ai-engineering/skills/agent-config-review/reference.md +205 -0
- package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +989 -0
- package/packs/ai-engineering/skills/agent-incident-response/SKILL.md +175 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/eval_queries.json +14 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/evals.json +107 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/agent-session-excerpt.txt +11 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/ci-build-log.txt +24 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/incident-notes.md +19 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/mcp-config.json +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/session-transcript.txt +8 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/agent-session.log +70 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/infra/staging-db.tf +25 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/job.yaml +19 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/run.log +13 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/usage-export-2026-10-20.csv +13 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/.mcp.json +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/git-log.txt +12 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync-excerpt.txt +30 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync.log +15 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/ssh-config.txt +16 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/.github/workflows/ci.yml +28 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/SECURITY.md +8 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/issue-479.md +35 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/package.json +15 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/pr-482.diff +20 -0
- package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/run-log.txt +30 -0
- package/packs/ai-engineering/skills/agent-incident-response/examples/injected-issue-comment.md +57 -0
- package/packs/ai-engineering/skills/agent-incident-response/manifest.json +55 -0
- package/packs/ai-engineering/skills/agent-incident-response/reference.md +152 -0
- package/packs/ai-engineering/skills/agent-incident-response/scripts/find_secret_exposure.py +239 -0
- package/packs/ai-engineering/skills/agent-incident-response/scripts/usage_split.py +194 -0
- package/packs/converters/skills/file-to-markdown/SKILL.md +3 -2
- package/packs/converters/skills/file-to-markdown/evals/eval_queries.json +4 -0
- package/packs/converters/skills/file-to-markdown/manifest.json +2 -2
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +340 -7
- package/packs/converters/skills/md-to-confluence/SKILL.md +139 -0
- package/packs/converters/skills/md-to-confluence/evals/eval_queries.json +42 -0
- package/packs/converters/skills/md-to-confluence/evals/evals.json +89 -0
- package/packs/converters/skills/md-to-confluence/evals/files/1/img/latency-dashboard.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/1/payments-runbook.md +60 -0
- package/packs/converters/skills/md-to-confluence/evals/files/2/deploy-checklist.md +33 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/img/after/screen.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/img/before/screen.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/3/release-notes-4.2.md +49 -0
- package/packs/converters/skills/md-to-confluence/evals/files/4/docs/img/topology.png +0 -0
- package/packs/converters/skills/md-to-confluence/evals/files/4/docs/ingest-pipeline-design.md +65 -0
- package/packs/converters/skills/md-to-confluence/evals/files/5/support/troubleshooting-sso.md +57 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/img/queue-depth.png +0 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.fixed.md +21 -0
- package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.md +21 -0
- package/packs/converters/skills/md-to-confluence/examples/queue-runbook.md +57 -0
- package/packs/converters/skills/md-to-confluence/manifest.json +52 -0
- package/packs/converters/skills/md-to-confluence/reference.md +92 -0
- package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +821 -0
- package/packs/converters/skills/md-to-pptx/SKILL.md +131 -0
- package/packs/converters/skills/md-to-pptx/evals/eval_queries.json +12 -0
- package/packs/converters/skills/md-to-pptx/evals/evals.json +84 -0
- package/packs/converters/skills/md-to-pptx/evals/files/1/img/defect-burndown.png +0 -0
- package/packs/converters/skills/md-to-pptx/evals/files/1/status-report.md +75 -0
- package/packs/converters/skills/md-to-pptx/evals/files/3/brand.json +12 -0
- package/packs/converters/skills/md-to-pptx/evals/files/3/night-shift-induction.md +53 -0
- package/packs/converters/skills/md-to-pptx/evals/files/4/charts/ontime.png +0 -0
- package/packs/converters/skills/md-to-pptx/evals/files/4/qbr-q3.md +85 -0
- package/packs/converters/skills/md-to-pptx/evals/files/5/vendor-sync-notes.md +28 -0
- package/packs/converters/skills/md-to-pptx/examples/support-weekly-input.md +48 -0
- package/packs/converters/skills/md-to-pptx/examples/support-weekly.md +75 -0
- package/packs/converters/skills/md-to-pptx/examples/tickets-by-week.png +0 -0
- package/packs/converters/skills/md-to-pptx/manifest.json +49 -0
- package/packs/converters/skills/md-to-pptx/reference.md +139 -0
- package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1841 -0
- package/packs/dev-team/loops/modernize/LOOP.md +124 -0
- package/packs/dev-team/loops/modernize/evals/eval_queries.json +9 -0
- package/packs/dev-team/loops/modernize/evals/evals.json +27 -0
- package/packs/dev-team/loops/modernize/loop.json +60 -0
- package/packs/dev-team/skills/behaviour-equivalence/SKILL.md +155 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/eval_queries.json +11 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/evals.json +76 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/cases.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/new.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/notes.md +15 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old_rerun.jsonl +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v1.py +45 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v2.py +65 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/rules.json +10 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/3/diff-summary.txt +16 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/diff.txt +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py2.py +16 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py3.py +24 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/input.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/new_output.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/old_output.csv +13 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/TEAM_NOTES.md +19 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/normalisation.yaml +12 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/new_run.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run1.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run2.jsonl +8 -0
- package/packs/dev-team/skills/behaviour-equivalence/examples/delivery-date.md +78 -0
- package/packs/dev-team/skills/behaviour-equivalence/manifest.json +33 -0
- package/packs/dev-team/skills/behaviour-equivalence/reference.md +97 -0
- package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +524 -0
- package/packs/dev-team/skills/behaviour-equivalence/templates/accepted.json +10 -0
- package/packs/dev-team/skills/behaviour-equivalence/templates/rules.json +7 -0
- package/packs/frontend/pack.json +55 -0
- package/packs/frontend/skills/rendering-strategy/SKILL.md +149 -0
- package/packs/frontend/skills/rendering-strategy/evals/eval_queries.json +38 -0
- package/packs/frontend/skills/rendering-strategy/evals/evals.json +77 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/1/fernhill-app.md +40 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/3/routes.md +33 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/build-notes.txt +5 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/nuxt.config.ts +19 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/pages/recipes/[slug].vue +37 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/4/routes.md +15 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/routes.txt +18 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/hooks.server.ts +14 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/(marketing)/pricing/+page.server.ts +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/+layout.server.ts +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/svelte.config.js +10 -0
- package/packs/frontend/skills/rendering-strategy/evals/files/5/traffic.md +18 -0
- package/packs/frontend/skills/rendering-strategy/examples/saas-site.md +42 -0
- package/packs/frontend/skills/rendering-strategy/manifest.json +50 -0
- package/packs/frontend/skills/rendering-strategy/reference.md +116 -0
- package/packs/frontend/skills/rendering-strategy/scripts/render_load.py +181 -0
- package/packs/frontend/skills/web-performance-review/SKILL.md +106 -0
- package/packs/frontend/skills/web-performance-review/evals/eval_queries.json +38 -0
- package/packs/frontend/skills/web-performance-review/evals/evals.json +76 -0
- package/packs/frontend/skills/web-performance-review/evals/files/1/psi.json +1065 -0
- package/packs/frontend/skills/web-performance-review/evals/files/2/after.json +359 -0
- package/packs/frontend/skills/web-performance-review/evals/files/2/before.json +375 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/psi-pdp-mobile-2026-10-19.json +171 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-attribution-oct14-19.csv +6 -0
- package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-daily.csv +15 -0
- package/packs/frontend/skills/web-performance-review/evals/files/5/crux-home-phone.json +35 -0
- package/packs/frontend/skills/web-performance-review/evals/files/5/lighthouse-home-mobile.json +173 -0
- package/packs/frontend/skills/web-performance-review/examples/quayside-article-lhr.json +480 -0
- package/packs/frontend/skills/web-performance-review/examples/quayside-article.md +103 -0
- package/packs/frontend/skills/web-performance-review/manifest.json +48 -0
- package/packs/frontend/skills/web-performance-review/reference.md +105 -0
- package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +743 -0
- package/packs/product-manager/skills/lean-canvas/SKILL.md +162 -0
- package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +42 -0
- package/packs/product-manager/skills/lean-canvas/evals/evals.json +82 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +54 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +30 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +44 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +5 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +40 -0
- package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +18 -0
- package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +72 -0
- package/packs/product-manager/skills/lean-canvas/manifest.json +34 -0
- package/packs/product-manager/skills/lean-canvas/reference.md +102 -0
- package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +211 -0
- package/packs/solution-architect/skills/api-contract-draft/SKILL.md +2 -2
- package/packs/solution-architect/skills/api-contract-draft/manifest.json +28 -6
- package/packs/solution-architect/skills/event-contract-draft/SKILL.md +167 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/eval_queries.json +38 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/evals.json +76 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/1/order-events-brief.md +45 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/4/NOTES.md +23 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/4/schemas/shipment-update.json +40 -0
- package/packs/solution-architect/skills/event-contract-draft/evals/files/5/invoice.example.json +25 -0
- package/packs/solution-architect/skills/event-contract-draft/examples/order-service.asyncapi.json +246 -0
- package/packs/solution-architect/skills/event-contract-draft/examples/order-service.md +92 -0
- package/packs/solution-architect/skills/event-contract-draft/manifest.json +25 -0
- package/packs/solution-architect/skills/event-contract-draft/reference.md +113 -0
- package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +502 -0
- package/packs/solution-architect/skills/event-contract-draft/templates/asyncapi-skeleton.json +144 -0
- package/packs/sre-oncall/skills/slo-spec/SKILL.md +161 -0
- package/packs/sre-oncall/skills/slo-spec/evals/eval_queries.json +38 -0
- package/packs/sre-oncall/skills/slo-spec/evals/evals.json +75 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/1/order-service.md +51 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/3/search-slo-status.md +30 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/alerts/payments-api.rules.yaml +16 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/docs/dependencies.md +8 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/4/metrics/summary-90d.md +51 -0
- package/packs/sre-oncall/skills/slo-spec/evals/files/5/ops-notes.md +34 -0
- package/packs/sre-oncall/skills/slo-spec/examples/ticket-booking.md +88 -0
- package/packs/sre-oncall/skills/slo-spec/manifest.json +34 -0
- package/packs/sre-oncall/skills/slo-spec/reference.md +113 -0
- package/packs/sre-oncall/skills/slo-spec/scripts/slo_math.py +449 -0
- package/packs/sre-oncall/skills/slo-spec/templates/slo-spec.md +35 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""Convert a document into clean Markdown that another skill can read.
|
|
3
3
|
|
|
4
4
|
Stdlib only, Python 3.9+. Handles .docx, .pptx, .xlsx, .html/.htm, .csv/.tsv,
|
|
5
|
-
.json, .txt/.md, and .pdf (through `pdftotext` when it is installed). A PDF page with no
|
|
5
|
+
.json, .txt/.md, .eml and Outlook .msg email, and .pdf (through `pdftotext` when it is installed). A PDF page with no
|
|
6
6
|
text layer (a scan) is read by OCR when `tesseract` and `pdftoppm` are installed.
|
|
7
7
|
Everything the conversion could not carry (images, charts, comments, formulas,
|
|
8
8
|
tracked changes) is counted and reported on stderr, so nobody mistakes the
|
|
@@ -972,6 +972,256 @@ def html_to_md(path, rep):
|
|
|
972
972
|
return parser.result()
|
|
973
973
|
|
|
974
974
|
|
|
975
|
+
def _html_text_to_md(text, rep):
|
|
976
|
+
"""HTML already decoded to text (an email body) -> Markdown, through the same parser."""
|
|
977
|
+
skipped = len(re.findall(r"<(script|style)\b", text, re.I))
|
|
978
|
+
if skipped:
|
|
979
|
+
rep.drop("scripts/styles", skipped)
|
|
980
|
+
parser = HTMLToMD(rep)
|
|
981
|
+
parser.feed(text)
|
|
982
|
+
parser.close()
|
|
983
|
+
return parser.result()
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
# ---------------------------------------------------------------- email (.eml, .msg)
|
|
987
|
+
|
|
988
|
+
def _email_md(subject, rows, body_md, attachments, rep, note=None):
|
|
989
|
+
"""One email as Markdown: subject as the title, the headers as a table, the body, and the
|
|
990
|
+
attachments listed by name and size, since their content is not converted."""
|
|
991
|
+
out = ["# " + md_escape(subject or "(no subject)"), "", "| | |", "|---|---|"]
|
|
992
|
+
for k, v in rows:
|
|
993
|
+
if v:
|
|
994
|
+
out.append("| %s | %s |" % (k, cell_text(md_escape(v))))
|
|
995
|
+
out += ["", body_md.strip() or "_(no text body)_", ""]
|
|
996
|
+
if attachments:
|
|
997
|
+
out += ["## Attachments", ""]
|
|
998
|
+
for name, size in attachments:
|
|
999
|
+
out.append("- %s (%s)" % (md_escape(name or "unnamed"), _human_size(size)))
|
|
1000
|
+
out.append("")
|
|
1001
|
+
rep.drop("attachments (listed, not converted)", len(attachments))
|
|
1002
|
+
if note:
|
|
1003
|
+
rep.notes.append(note)
|
|
1004
|
+
rep.keep("emails", 1)
|
|
1005
|
+
return "\n".join(out).rstrip() + "\n"
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _email_text(text):
|
|
1009
|
+
"""A plain-text email body as Markdown. Bullets and `> ` quoted replies already read as
|
|
1010
|
+
Markdown lists and quotes, so only a leading `#` (which would become a heading) and a
|
|
1011
|
+
line of `=` or `-` under text (which would turn that text into one) are escaped."""
|
|
1012
|
+
out = []
|
|
1013
|
+
for line in text.replace("\r\n", "\n").split("\n"):
|
|
1014
|
+
if line.lstrip().startswith("#"):
|
|
1015
|
+
line = line.replace("#", "\\#", 1)
|
|
1016
|
+
elif re.fullmatch(r"\s*(=+|-{2,})\s*", line) and out and out[-1].strip():
|
|
1017
|
+
line = "\\" + line.strip()
|
|
1018
|
+
out.append(line)
|
|
1019
|
+
return "\n".join(out)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _human_size(n):
|
|
1023
|
+
if n is None:
|
|
1024
|
+
return "size unknown"
|
|
1025
|
+
for unit in ("bytes", "KB", "MB", "GB"):
|
|
1026
|
+
if n < 1024 or unit == "GB":
|
|
1027
|
+
return ("%d %s" % (n, unit)) if unit == "bytes" else ("%.1f %s" % (n, unit))
|
|
1028
|
+
n /= 1024.0
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def eml_to_md(path, rep):
|
|
1032
|
+
"""An RFC 5322 message (.eml): the plain-text part when there is one, else the HTML part."""
|
|
1033
|
+
import email
|
|
1034
|
+
from email import policy
|
|
1035
|
+
msg = email.message_from_bytes(path.read_bytes(), policy=policy.default)
|
|
1036
|
+
rows = [("From", str(msg.get("From", ""))), ("To", str(msg.get("To", ""))), ("Cc", str(msg.get("Cc", ""))),
|
|
1037
|
+
("Date", str(msg.get("Date", "")))]
|
|
1038
|
+
plain = msg.get_body(preferencelist=("plain",))
|
|
1039
|
+
html = msg.get_body(preferencelist=("html",))
|
|
1040
|
+
if plain is not None:
|
|
1041
|
+
body = _email_text(plain.get_content())
|
|
1042
|
+
if html is not None:
|
|
1043
|
+
rep.drop("HTML alternative (the plain-text part was used)", 1)
|
|
1044
|
+
elif html is not None:
|
|
1045
|
+
body = _html_text_to_md(html.get_content(), rep)
|
|
1046
|
+
else:
|
|
1047
|
+
body = ""
|
|
1048
|
+
attachments = []
|
|
1049
|
+
for part in msg.iter_attachments():
|
|
1050
|
+
payload = part.get_payload(decode=True)
|
|
1051
|
+
name = part.get_filename() or ("forwarded message" if part.get_content_type() == "message/rfc822" else None)
|
|
1052
|
+
attachments.append((name, len(payload) if payload is not None else None))
|
|
1053
|
+
return _email_md(str(msg.get("Subject", "")), rows, body, attachments, rep)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
class _CFB:
|
|
1057
|
+
"""A minimal, read-only reader for the Compound File Binary format Outlook saves .msg in.
|
|
1058
|
+
Bounded on every loop and offset, so a malformed file stops with an error, never a hang."""
|
|
1059
|
+
MAGIC = b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1"
|
|
1060
|
+
|
|
1061
|
+
def __init__(self, data):
|
|
1062
|
+
import struct
|
|
1063
|
+
self.struct = struct
|
|
1064
|
+
if data[:8] != self.MAGIC or len(data) < 512:
|
|
1065
|
+
raise ValueError("not an Outlook .msg file (no compound-file header)")
|
|
1066
|
+
self.d = data
|
|
1067
|
+
self.ss = 1 << struct.unpack_from("<H", data, 30)[0]
|
|
1068
|
+
self.mss = 1 << struct.unpack_from("<H", data, 32)[0]
|
|
1069
|
+
if self.ss not in (512, 4096) or self.mss != 64:
|
|
1070
|
+
raise ValueError("unsupported compound-file sector size")
|
|
1071
|
+
nfat, dir_start = struct.unpack_from("<I", data, 44)[0], struct.unpack_from("<I", data, 48)[0]
|
|
1072
|
+
self.cutoff = struct.unpack_from("<I", data, 56)[0]
|
|
1073
|
+
minifat_start, nminifat, difat_start, ndifat = struct.unpack_from("<IIII", data, 60)
|
|
1074
|
+
difat = list(struct.unpack_from("<109I", data, 76))
|
|
1075
|
+
per = self.ss // 4
|
|
1076
|
+
s, guard = difat_start, 0
|
|
1077
|
+
while ndifat and s < 0xFFFFFFFA and guard < 10000:
|
|
1078
|
+
vals = struct.unpack("<%dI" % per, self._sector(s))
|
|
1079
|
+
difat += vals[:-1]
|
|
1080
|
+
s, guard, ndifat = vals[-1], guard + 1, ndifat - 1
|
|
1081
|
+
self.fat = []
|
|
1082
|
+
for fs in difat[:nfat]:
|
|
1083
|
+
if fs < 0xFFFFFFFA:
|
|
1084
|
+
self.fat += struct.unpack("<%dI" % per, self._sector(fs))
|
|
1085
|
+
raw = self._chain(dir_start)
|
|
1086
|
+
self.entries = []
|
|
1087
|
+
for i in range(0, len(raw) - 127, 128):
|
|
1088
|
+
e = raw[i:i + 128]
|
|
1089
|
+
nlen = struct.unpack_from("<H", e, 64)[0]
|
|
1090
|
+
name = e[:max(0, min(nlen, 64) - 2)].decode("utf-16-le", "replace")
|
|
1091
|
+
kind = e[66]
|
|
1092
|
+
left, right, child = struct.unpack_from("<III", e, 68)
|
|
1093
|
+
start = struct.unpack_from("<I", e, 116)[0]
|
|
1094
|
+
size = struct.unpack_from("<I", e, 120)[0] # the high half is zero for version 3 files
|
|
1095
|
+
self.entries.append({"name": name, "kind": kind, "left": left, "right": right, "child": child,
|
|
1096
|
+
"start": start, "size": size})
|
|
1097
|
+
if not self.entries or self.entries[0]["kind"] != 5:
|
|
1098
|
+
raise ValueError("compound file has no root entry")
|
|
1099
|
+
root = self.entries[0]
|
|
1100
|
+
self.ministream = self._chain(root["start"])[:root["size"]] if root["start"] < 0xFFFFFFFA else b""
|
|
1101
|
+
mf = self._chain(minifat_start) if nminifat else b""
|
|
1102
|
+
self.minifat = list(struct.unpack("<%dI" % (len(mf) // 4), mf[:len(mf) // 4 * 4]))
|
|
1103
|
+
|
|
1104
|
+
def _sector(self, n):
|
|
1105
|
+
off = (n + 1) * self.ss
|
|
1106
|
+
if off + self.ss > len(self.d):
|
|
1107
|
+
raise ValueError("compound file is truncated or corrupt")
|
|
1108
|
+
return self.d[off:off + self.ss]
|
|
1109
|
+
|
|
1110
|
+
def _chain(self, start, mini=False):
|
|
1111
|
+
fat, out, n, seen = (self.minifat if mini else self.fat), [], start, 0
|
|
1112
|
+
while n < 0xFFFFFFFA:
|
|
1113
|
+
if n >= len(fat) or seen > 1000000:
|
|
1114
|
+
raise ValueError("compound file has a broken sector chain")
|
|
1115
|
+
if mini:
|
|
1116
|
+
off = n * self.mss
|
|
1117
|
+
out.append(self.ministream[off:off + self.mss])
|
|
1118
|
+
else:
|
|
1119
|
+
out.append(self._sector(n))
|
|
1120
|
+
n, seen = fat[n], seen + 1
|
|
1121
|
+
return b"".join(out)
|
|
1122
|
+
|
|
1123
|
+
def children(self, idx):
|
|
1124
|
+
"""The entries directly inside storage `idx`, by name."""
|
|
1125
|
+
out, stack, seen = {}, [self.entries[idx]["child"]], set()
|
|
1126
|
+
while stack:
|
|
1127
|
+
i = stack.pop()
|
|
1128
|
+
if i >= len(self.entries) or i in seen:
|
|
1129
|
+
continue
|
|
1130
|
+
seen.add(i)
|
|
1131
|
+
e = self.entries[i]
|
|
1132
|
+
out[e["name"]] = i
|
|
1133
|
+
stack += [e["left"], e["right"]]
|
|
1134
|
+
return out
|
|
1135
|
+
|
|
1136
|
+
def read(self, i):
|
|
1137
|
+
e = self.entries[i]
|
|
1138
|
+
if e["size"] < self.cutoff:
|
|
1139
|
+
return self._chain(e["start"], mini=True)[:e["size"]]
|
|
1140
|
+
return self._chain(e["start"])[:e["size"]]
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _msg_props(cfb, idx):
|
|
1144
|
+
"""The string and binary properties of one .msg storage, by four-hex-digit property id."""
|
|
1145
|
+
out = {}
|
|
1146
|
+
for name, i in cfb.children(idx).items():
|
|
1147
|
+
m = re.match(r"__substg1\.0_([0-9A-Fa-f]{4})([0-9A-Fa-f]{4})$", name)
|
|
1148
|
+
if not m or cfb.entries[i]["kind"] != 2:
|
|
1149
|
+
continue
|
|
1150
|
+
pid, ptype = m.group(1).upper(), m.group(2).upper()
|
|
1151
|
+
data = cfb.read(i)
|
|
1152
|
+
if ptype == "001F":
|
|
1153
|
+
out[pid] = data.decode("utf-16-le", "replace").rstrip("\x00")
|
|
1154
|
+
elif ptype == "001E":
|
|
1155
|
+
out[pid] = data.decode("cp1252", "replace").rstrip("\x00")
|
|
1156
|
+
elif ptype == "0102":
|
|
1157
|
+
out.setdefault(pid + ":bin", data)
|
|
1158
|
+
return out
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _msg_fixed(cfb, idx, header):
|
|
1162
|
+
"""Fixed-size properties (times, integers) from a storage's __properties_version1.0."""
|
|
1163
|
+
import struct
|
|
1164
|
+
from datetime import datetime as _dt
|
|
1165
|
+
stream = cfb.children(idx).get("__properties_version1.0")
|
|
1166
|
+
if stream is None:
|
|
1167
|
+
return {}
|
|
1168
|
+
data, out = cfb.read(stream), {}
|
|
1169
|
+
for off in range(header, len(data) - 15, 16):
|
|
1170
|
+
tag, = struct.unpack_from("<I", data, off)
|
|
1171
|
+
ptype, pid = tag & 0xFFFF, "%04X" % (tag >> 16)
|
|
1172
|
+
if ptype == 0x0040:
|
|
1173
|
+
ft, = struct.unpack_from("<Q", data, off + 8)
|
|
1174
|
+
if ft:
|
|
1175
|
+
out[pid] = _dt(1601, 1, 1) + timedelta(microseconds=ft // 10)
|
|
1176
|
+
elif ptype == 0x0003:
|
|
1177
|
+
out[pid] = struct.unpack_from("<i", data, off + 8)[0]
|
|
1178
|
+
return out
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def msg_to_md(path, rep):
|
|
1182
|
+
"""An Outlook message (.msg): subject, sender, recipients, sent time, the text body (or the
|
|
1183
|
+
HTML body when there is no text one), and attachments listed by name and size."""
|
|
1184
|
+
try:
|
|
1185
|
+
cfb = _CFB(path.read_bytes())
|
|
1186
|
+
except ValueError as e:
|
|
1187
|
+
sys.exit("error: %s: %s" % (path, e))
|
|
1188
|
+
props = _msg_props(cfb, 0)
|
|
1189
|
+
fixed = _msg_fixed(cfb, 0, 32)
|
|
1190
|
+
to, cc = [], []
|
|
1191
|
+
for name, i in sorted(cfb.children(0).items()):
|
|
1192
|
+
if name.startswith("__recip_version1.0_"):
|
|
1193
|
+
rp, rf = _msg_props(cfb, i), _msg_fixed(cfb, i, 8)
|
|
1194
|
+
who = rp.get("3001", "")
|
|
1195
|
+
addr = rp.get("39FE") or rp.get("3003", "")
|
|
1196
|
+
label = ("%s <%s>" % (who, addr)) if who and addr and addr not in who else (who or addr)
|
|
1197
|
+
(cc if rf.get("0C15") == 2 else to).append(label)
|
|
1198
|
+
sender = props.get("0C1A", "")
|
|
1199
|
+
saddr = props.get("5D01") or props.get("0C1F") or props.get("0065", "")
|
|
1200
|
+
sent = fixed.get("0039") or fixed.get("0E06")
|
|
1201
|
+
rows = [("From", ("%s <%s>" % (sender, saddr)) if sender and saddr else (sender or saddr)),
|
|
1202
|
+
("To", "; ".join(to) or props.get("0E04", "")), ("Cc", "; ".join(cc) or props.get("0E03", "")),
|
|
1203
|
+
("Date", sent.strftime("%Y-%m-%d %H:%M UTC") if sent else "")]
|
|
1204
|
+
note = None
|
|
1205
|
+
if props.get("1000"):
|
|
1206
|
+
body = _email_text(props["1000"])
|
|
1207
|
+
elif props.get("1013") or props.get("1013:bin"):
|
|
1208
|
+
html = props.get("1013") or props["1013:bin"].decode("utf-8", "replace")
|
|
1209
|
+
body = _html_text_to_md(html, rep)
|
|
1210
|
+
else:
|
|
1211
|
+
body = ""
|
|
1212
|
+
if props.get("1009:bin"):
|
|
1213
|
+
note = "the message body is stored only as compressed RTF, which is not converted"
|
|
1214
|
+
rep.drop("RTF body", 1)
|
|
1215
|
+
attachments = []
|
|
1216
|
+
for name, i in sorted(cfb.children(0).items()):
|
|
1217
|
+
if name.startswith("__attach_version1.0_"):
|
|
1218
|
+
ap, kids = _msg_props(cfb, i), cfb.children(i)
|
|
1219
|
+
data = ap.get("3701:bin")
|
|
1220
|
+
fname = ap.get("3707") or ap.get("3704") or ("embedded message" if "__substg1.0_3701000D" in kids else None)
|
|
1221
|
+
attachments.append((fname, len(data) if data is not None else None))
|
|
1222
|
+
return _email_md(props.get("0037", ""), rows, body, attachments, rep, note)
|
|
1223
|
+
|
|
1224
|
+
|
|
975
1225
|
# ---------------------------------------------------------------- csv / json / txt / pdf
|
|
976
1226
|
|
|
977
1227
|
def read_text(path, rep):
|
|
@@ -1104,9 +1354,78 @@ def _pdf_page_plain(page):
|
|
|
1104
1354
|
return out
|
|
1105
1355
|
|
|
1106
1356
|
|
|
1107
|
-
|
|
1357
|
+
_BBOX_PAGE = re.compile(r"<page\b.*?</page>", re.S)
|
|
1358
|
+
_BBOX_WORD = re.compile(r'<word xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">(.*?)</word>', re.S)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _word_rows(bbox_html):
|
|
1362
|
+
"""Each physical line of every page as [(xMin, xMax, height, text)], keyed by its words.
|
|
1363
|
+
`pdftotext -bbox-layout` puts each table cell in a block of its own, so words are
|
|
1364
|
+
regrouped by their vertical position into the rows the layout reading shows."""
|
|
1365
|
+
import html as _html
|
|
1366
|
+
rows = {}
|
|
1367
|
+
for page in _BBOX_PAGE.findall(bbox_html):
|
|
1368
|
+
words = [(float(a), float(b), float(c), float(d), _html.unescape(t)) for a, b, c, d, t in _BBOX_WORD.findall(page)]
|
|
1369
|
+
words.sort(key=lambda w: (round(w[3]), w[0]))
|
|
1370
|
+
line, base = [], None
|
|
1371
|
+
for x0, y0, x1, y1, t in words + [(0, 0, 0, -99, "")]:
|
|
1372
|
+
if base is not None and abs(y1 - base) > 2:
|
|
1373
|
+
line.sort()
|
|
1374
|
+
key = " ".join(w[3] for w in line)
|
|
1375
|
+
if key and key not in rows:
|
|
1376
|
+
rows[key] = line
|
|
1377
|
+
line = []
|
|
1378
|
+
base = y1
|
|
1379
|
+
if t:
|
|
1380
|
+
line.append((x0, x1, y1 - y0, t))
|
|
1381
|
+
return rows
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _finer_cells(region, boxes):
|
|
1385
|
+
"""The region's rows split again from where each word sits on the page, or None.
|
|
1386
|
+
|
|
1387
|
+
`pdftotext -layout` turns positions into characters, so two columns whose text sits closer
|
|
1388
|
+
than about two characters (a right-aligned amount beside a left-aligned date) come out one
|
|
1389
|
+
space apart and merge into one cell. A gap between words is a column when it is at least
|
|
1390
|
+
0.6 of the line's height (a word space is about a quarter of it) and, when the table has
|
|
1391
|
+
word spaces to compare with, at least twice the smallest of them, which keeps a monospace
|
|
1392
|
+
font's wide spaces from reading as columns. The new split is used only when every row then
|
|
1393
|
+
has the same number of cells, so a table that already came out right is never changed."""
|
|
1394
|
+
if not boxes:
|
|
1395
|
+
return None
|
|
1396
|
+
found = [boxes.get(" ".join(r.split())) for r in region]
|
|
1397
|
+
if not any(found):
|
|
1398
|
+
return None
|
|
1399
|
+
gaps = []
|
|
1400
|
+
for ws in found:
|
|
1401
|
+
if ws:
|
|
1402
|
+
h = sorted(w[2] for w in ws)[len(ws) // 2]
|
|
1403
|
+
gaps += [(b[0] - a[1], h) for a, b in zip(ws, ws[1:])]
|
|
1404
|
+
small = [g for g, h in gaps if g < 0.6 * h]
|
|
1405
|
+
floor = 2 * min(small) if small else 0
|
|
1406
|
+
rows = []
|
|
1407
|
+
for r, ws in zip(region, found):
|
|
1408
|
+
if not ws:
|
|
1409
|
+
rows.append(_cells(r))
|
|
1410
|
+
continue
|
|
1411
|
+
h = sorted(w[2] for w in ws)[len(ws) // 2]
|
|
1412
|
+
cells, cur = [], [ws[0][3]]
|
|
1413
|
+
for a, b in zip(ws, ws[1:]):
|
|
1414
|
+
g = b[0] - a[1]
|
|
1415
|
+
if g >= 0.6 * h and g >= floor:
|
|
1416
|
+
cells.append(" ".join(cur)); cur = []
|
|
1417
|
+
cur.append(b[3])
|
|
1418
|
+
cells.append(" ".join(cur))
|
|
1419
|
+
rows.append(cells)
|
|
1420
|
+
if len(set(len(c) for c in rows)) != 1 or rows == [_cells(r) for r in region]:
|
|
1421
|
+
return None
|
|
1422
|
+
return rows
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def _pdf_page_md(page, rep, fine=None):
|
|
1108
1426
|
"""One page of `pdftotext -layout` text -> Markdown. Layout mode keeps line breaks,
|
|
1109
|
-
indentation and column alignment, which is what tables and lists are recovered from.
|
|
1427
|
+
indentation and column alignment, which is what tables and lists are recovered from.
|
|
1428
|
+
`fine` maps a line's words to where each word sits on the page (see _finer_cells)."""
|
|
1110
1429
|
lines = page.splitlines()
|
|
1111
1430
|
out, i, n = [], 0, len(lines)
|
|
1112
1431
|
while i < n:
|
|
@@ -1133,7 +1452,16 @@ def _pdf_page_md(page, rep):
|
|
|
1133
1452
|
break
|
|
1134
1453
|
if rows >= 2:
|
|
1135
1454
|
widths = set(len(_cells(r)) for r in region)
|
|
1136
|
-
|
|
1455
|
+
finer = _finer_cells(region, fine) if rows == len(region) else None
|
|
1456
|
+
if finer and (len(widths) != 1 or len(finer[0]) > len(_cells(region[0]))):
|
|
1457
|
+
rep.keep("pdf table columns separated by position", 1)
|
|
1458
|
+
cells = [[cell_text(md_escape(c)) for c in r] for r in finer]
|
|
1459
|
+
width = len(cells[0])
|
|
1460
|
+
out.append("| " + " | ".join(cells[0]) + " |")
|
|
1461
|
+
out.append("|" + "---|" * width)
|
|
1462
|
+
out += ["| " + " | ".join(r) + " |" for r in cells[1:]]
|
|
1463
|
+
rep.keep("pdf tables", 1)
|
|
1464
|
+
elif len(widths) == 1 and rows == len(region):
|
|
1137
1465
|
cells = [[cell_text(md_escape(c)) for c in _cells(r)] for r in region]
|
|
1138
1466
|
width = len(cells[0])
|
|
1139
1467
|
out.append("| " + " | ".join(cells[0]) + " |")
|
|
@@ -1318,6 +1646,11 @@ def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
|
|
|
1318
1646
|
elif blank and len(blank) == len(layout):
|
|
1319
1647
|
sys.exit("error: %s has no text layer (probably scanned), and --no-ocr was given." % path)
|
|
1320
1648
|
layout = _join_split_tables(layout, rep)
|
|
1649
|
+
# Where each word sits, to tell table columns apart that sit closer than two characters
|
|
1650
|
+
# in the layout reading (see _finer_cells).
|
|
1651
|
+
proc = subprocess.run([exe, "-bbox-layout", "-enc", "UTF-8", str(path), "-"], stdout=subprocess.PIPE,
|
|
1652
|
+
stderr=subprocess.PIPE, check=False)
|
|
1653
|
+
fine = _word_rows(proc.stdout.decode("utf-8", "replace")) if proc.returncode == 0 else {}
|
|
1321
1654
|
out, plain_pages = [], []
|
|
1322
1655
|
for n, page in enumerate(layout, 1):
|
|
1323
1656
|
out += ["<!-- page %d -->" % n, ""]
|
|
@@ -1325,7 +1658,7 @@ def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
|
|
|
1325
1658
|
out += _pdf_page_plain(reading[n - 1])
|
|
1326
1659
|
plain_pages.append(n)
|
|
1327
1660
|
else:
|
|
1328
|
-
out += _pdf_page_md(page, rep)
|
|
1661
|
+
out += _pdf_page_md(page, rep, fine)
|
|
1329
1662
|
pages = layout
|
|
1330
1663
|
if plain_pages:
|
|
1331
1664
|
rep.notes.append("page(s) %s have side-by-side columns, so they were read in column order with "
|
|
@@ -1342,7 +1675,7 @@ def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
|
|
|
1342
1675
|
HANDLERS = {
|
|
1343
1676
|
".docx": docx_to_md, ".pptx": pptx_to_md, ".xlsx": xlsx_to_md, ".xlsm": xlsx_to_md,
|
|
1344
1677
|
".html": html_to_md, ".htm": html_to_md, ".csv": csv_to_md, ".tsv": csv_to_md,
|
|
1345
|
-
".json": json_to_md, ".pdf": pdf_to_md,
|
|
1678
|
+
".json": json_to_md, ".pdf": pdf_to_md, ".eml": eml_to_md, ".msg": msg_to_md,
|
|
1346
1679
|
}
|
|
1347
1680
|
LEGACY = {".doc": ".docx", ".ppt": ".pptx", ".xls": ".xlsx", ".rtf": ".docx", ".odt": ".docx",
|
|
1348
1681
|
".pages": ".docx", ".key": ".pptx", ".numbers": ".xlsx"}
|
|
@@ -1350,7 +1683,7 @@ LEGACY = {".doc": ".docx", ".ppt": ".pptx", ".xls": ".xlsx", ".rtf": ".docx", ".
|
|
|
1350
1683
|
|
|
1351
1684
|
def main(argv=None):
|
|
1352
1685
|
ap = argparse.ArgumentParser(description="Convert a document to Markdown for another skill to read.")
|
|
1353
|
-
ap.add_argument("input", help=".docx .pptx .xlsx .html .csv .tsv .json .txt .md .pdf")
|
|
1686
|
+
ap.add_argument("input", help=".docx .pptx .xlsx .html .csv .tsv .json .txt .md .eml .msg .pdf")
|
|
1354
1687
|
ap.add_argument("-o", "--output", required=True, help="Markdown file to write, or - for stdout")
|
|
1355
1688
|
ap.add_argument("--max-rows", type=int, default=0,
|
|
1356
1689
|
help="xlsx only: keep at most this many data rows per sheet (0 = all)")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: md-to-confluence
|
|
3
|
+
description: Turn a Markdown runbook, design note or guide into a Confluence page: storage-format XHTML with real code blocks, tables, tasks, info and warning panels, images as attachments and an optional contents macro, a warning for everything dropped, and an optional publish that first shows which page it will create or overwrite and at what version, refuses stale or wrong-space overwrites, and takes credentials only from the environment. Use when the user says "put this in Confluence", "publish this markdown to the wiki", "update the Confluence page with this", "convert to Confluence storage format", or names a Confluence space for a Markdown file.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# md-to-confluence
|
|
7
|
+
|
|
8
|
+
You turn a Markdown file into a Confluence page that reads like one written in the editor:
|
|
9
|
+
code in the code macro, callouts as panels, checkboxes as Confluence tasks. A stdlib script
|
|
10
|
+
does the conversion and, when asked, the publish. Your job is the part a person gets wrong
|
|
11
|
+
under time pressure: fix what won't convert, list the images that must be attached, and make
|
|
12
|
+
sure nobody overwrites the wrong page. Runbooks often come from `runbook-generator`.
|
|
13
|
+
|
|
14
|
+
## How to respond
|
|
15
|
+
|
|
16
|
+
**Four rules come before the steps and outrank them:**
|
|
17
|
+
|
|
18
|
+
- **Answer what was asked, first.** Open with the answer, the decision or the artifact, in plain words. Scores, matrices, frameworks and tags come after it, and anything that doesn't change the answer is cut.
|
|
19
|
+
- **Use only what you were given.** Don't add facts, names, numbers, incidents, history, steps or sections the input doesn't contain. What you need and don't have is left out of the artifact and listed once at the end under "To confirm".
|
|
20
|
+
- **Deliver from what you have.** When the request gives you something to work on, state your assumptions in a line and produce the result. When it gives you nothing to work on, ask for it in one or two plain sentences and say what you will do once you have it.
|
|
21
|
+
- **Write for someone who has never heard of this skill.** No skill names, no paths or scripts from this folder, no internal terms, and nothing about how the run was set up. A next step is one plain sentence at the end that describes the work.
|
|
22
|
+
- **For this skill:** Nothing reaches Confluence until the person has seen exactly which page will be created or overwritten (space, title, page id, current version, last editor) and the files to attach, and has said yes to that page. With nobody to ask, convert only and say what would be published.
|
|
23
|
+
|
|
24
|
+
1. **Take the target from the request; ask once for the rest.** Publishing needs the site
|
|
25
|
+
(`https://<site>.atlassian.net/wiki`, or the Data Center address), the space key, and
|
|
26
|
+
either a parent page or the id of the page to replace. Ask for what's missing in one
|
|
27
|
+
message, at most two questions. Defaults: title from the first `#` heading; parent is
|
|
28
|
+
the space home; contents macro on when the page has four or more `##` sections.
|
|
29
|
+
|
|
30
|
+
2. **Fix the Markdown in a copy before converting, and list each change.** These are the
|
|
31
|
+
losses the script can only warn about:
|
|
32
|
+
- **Raw HTML is dropped whole.** A `<details>` block often hides the rollback steps. Rewrite
|
|
33
|
+
it as Markdown (a `## Rollback` section with a numbered list).
|
|
34
|
+
✅ *rollback steps survive as a list* ❌ *"1 HTML block dropped" in the reply, steps gone*
|
|
35
|
+
- **Code between numbered steps breaks the numbering.** Confluence restarts each list at 1.
|
|
36
|
+
Indent the code block three spaces under its step so steps read 1, 2, 3.
|
|
37
|
+
- **Two images with the same file name** (`before/screen.png`, `after/screen.png`) become one
|
|
38
|
+
attachment, so both show the same picture. Rename one (`screen-before.png`).
|
|
39
|
+
- **Footnotes are dropped.** Move the text into the sentence or a "Notes" section.
|
|
40
|
+
- **Mermaid shows as source.** Render it to PNG with `mermaid-render` and embed the PNG.
|
|
41
|
+
- **A front-matter `title:` wins over the `#` heading** for a new page. An update keeps the
|
|
42
|
+
page's current title, so when the Markdown's title differs (`Deploy checklist v2 (draft)`),
|
|
43
|
+
say so and rename only if asked.
|
|
44
|
+
- **Callouts need the label as the first words of the quote:** `> **Note:**` → info panel,
|
|
45
|
+
`> **Warning:**` → warning panel (full table in [`reference.md`](reference.md)).
|
|
46
|
+
|
|
47
|
+
3. **Convert, then read every `WARN` line.**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Claude Code
|
|
51
|
+
python3 "${CLAUDE_SKILL_DIR}/scripts/md_to_confluence.py" runbook.md -o runbook.xhtml --toc
|
|
52
|
+
# Other IDEs (from the skill folder)
|
|
53
|
+
python3 scripts/md_to_confluence.py runbook.md -o runbook.xhtml --toc
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Fix the source and rerun for each warning you can fix. Report the rest in plain words:
|
|
57
|
+
✅ *"The in-page link to Rollback is plain text now; the contents box at the top covers it."*
|
|
58
|
+
❌ *"Converted with 3 warnings."* The script also lists every image to upload as an attachment.
|
|
59
|
+
|
|
60
|
+
4. **Credentials come from the environment, never the chat.** The script reads
|
|
61
|
+
`CONFLUENCE_EMAIL` + `CONFLUENCE_API_TOKEN` (Cloud) or `CONFLUENCE_PAT` (Data Center).
|
|
62
|
+
If they're unset, convert, run `--publish --dry-run`, and stop: tell the person to export
|
|
63
|
+
the variables in their own terminal and restart the session. If they paste a token into the chat, don't put it in a command, a file or an
|
|
64
|
+
`export` you run: tell them to revoke it, because it now sits in the transcript.
|
|
65
|
+
|
|
66
|
+
5. **Look at the target page before writing to it.** `--inspect` is read-only:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python3 "${CLAUDE_SKILL_DIR}/scripts/md_to_confluence.py" runbook.md -o runbook.xhtml --inspect --base-url "https://<site>.atlassian.net/wiki" --space SRE --page-id 48213
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Without `--page-id` it says whether a page with that title already exists in the space.
|
|
73
|
+
|
|
74
|
+
6. **Confirm with the facts, then publish.** One block, then wait for a yes:
|
|
75
|
+
✅ *"This overwrites **Deploy checklist** (page 48213, PLAT), now at version 7, last edited by
|
|
76
|
+
Priya Nair on 1 Oct. Upload after: `flow.png`. Dropped: one footnote, moved
|
|
77
|
+
into Notes. Publish?"* ❌ *"Ready to push to Confluence, go ahead?"*
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python3 "${CLAUDE_SKILL_DIR}/scripts/md_to_confluence.py" runbook.md -o runbook.xhtml --publish --base-url "https://<site>.atlassian.net/wiki" --space PLAT --page-id 48213 --expected-version 7
|
|
81
|
+
# other IDEs: python3 scripts/md_to_confluence.py … with the same flags
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Always pass `--expected-version` with the number `--inspect` showed: if anyone saved the page
|
|
85
|
+
since, the script refuses instead of erasing their edit. Leave out `--page-id` to create;
|
|
86
|
+
it refuses when the title is taken. An update keeps the page's title unless `--title` is
|
|
87
|
+
passed. `--dry-run` prints the request and sends nothing.
|
|
88
|
+
|
|
89
|
+
7. **Hand over in three or four lines, opening with the state.** ✅ *"Published: <link>, now
|
|
90
|
+
version 8."* ✅ *"Converted, not published yet: it becomes a new page **Payments API
|
|
91
|
+
runbook** under page 196612 in SRE."* ❌ *"Done!"* when nothing reached Confluence. Then the
|
|
92
|
+
files to upload in the page's Attachments with their names unchanged (images show broken
|
|
93
|
+
until then; the script never uploads them), what was dropped or changed, and what the
|
|
94
|
+
person does next.
|
|
95
|
+
|
|
96
|
+
**Non-interactive runs** (subagent, CI, headless): never publish. Convert, run `--publish
|
|
97
|
+
--dry-run` when a target was given, and reply with what would be published, to which space
|
|
98
|
+
and page, and the attachments, as the first thing to confirm. Defaults (title, contents
|
|
99
|
+
macro, parent) become `[assumption]` lines. With no Markdown, emit `BLOCKED: need the
|
|
100
|
+
Markdown file to convert` and write nothing.
|
|
101
|
+
|
|
102
|
+
## Useful references in this skill
|
|
103
|
+
|
|
104
|
+
- [`reference.md`](reference.md): what maps to what, the callout convention, code languages, the API calls and every refusal
|
|
105
|
+
- [`scripts/md_to_confluence.py`](scripts/md_to_confluence.py): converter and publisher (stdlib only, Python 3.9+)
|
|
106
|
+
- [`examples/queue-runbook.md`](examples/queue-runbook.md): a runbook converted, with the fixes made and the confirmation shown
|
|
107
|
+
|
|
108
|
+
## Quality bar
|
|
109
|
+
|
|
110
|
+
- **The right page, said out loud.** Every publish is preceded by the page id, title, space, current version and last editor from a live check, and a yes.
|
|
111
|
+
- **No silent overwrite.** Updates carry `--expected-version`; a newer version stops the run.
|
|
112
|
+
- **Nothing lost silently.** Each `WARN` is fixed in the source or named in the reply; rollback steps in HTML are rewritten, not dropped.
|
|
113
|
+
- **Images are listed by attachment name**, with any missing file or name clash called out before publishing.
|
|
114
|
+
- **Code survives byte for byte**, including a `]]>` inside it; the script checks the page is well-formed.
|
|
115
|
+
- **Tokens never appear** in a command, a file or the reply.
|
|
116
|
+
- **Facts untouched.** Restructuring Markdown never changes a command, threshold, name or date.
|
|
117
|
+
|
|
118
|
+
## When to use this skill
|
|
119
|
+
|
|
120
|
+
- ✅ A Markdown runbook, design doc or how-to that belongs on a team's Confluence space
|
|
121
|
+
- ✅ Replacing an existing Confluence page with a newer Markdown version, without clobbering edits
|
|
122
|
+
- ✅ Storage-format XHTML to paste into the source editor or another tool's API call
|
|
123
|
+
|
|
124
|
+
## When NOT to use this skill
|
|
125
|
+
|
|
126
|
+
- ❌ A Word file to edit with tracked changes; use `md-to-docx`
|
|
127
|
+
- ❌ A standalone web page or an HTML attachment for email; use `md-to-html`
|
|
128
|
+
- ❌ Jira epics and issues from a PRD; use `tracker-brief-sync`
|
|
129
|
+
- ❌ Writing the runbook itself; use `runbook-generator`, then convert
|
|
130
|
+
|
|
131
|
+
## Anti-patterns to avoid
|
|
132
|
+
|
|
133
|
+
- ❌ **Publishing on "put it in Confluence".** That names a destination, not a page to overwrite. Inspect, show, ask.
|
|
134
|
+
- ❌ **Updating without `--expected-version`.** A teammate's edit from this morning disappears with no trace in the reply.
|
|
135
|
+
- ❌ **Hand-writing storage XHTML.** One unescaped `&` or a `]]>` in a code sample, and Confluence rejects the page or cuts the code.
|
|
136
|
+
- ❌ **Treating "published" as done while images are unattached.** The page renders with broken image boxes until the files are uploaded.
|
|
137
|
+
- ❌ **Putting a pasted token on a command line.** It lands in shell history and logs. Ask for environment variables and a revoke.
|
|
138
|
+
- ❌ **Showing the machinery.** No skill names, script paths or flags in the reply; describe the page and the steps.
|
|
139
|
+
- ❌ **A bare `BLOCKED` line.** Say in plain words what is missing and what you will produce once you have it.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"query": "put this runbook in Confluence under the SRE space",
|
|
4
|
+
"should_trigger": true
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"query": "Convert README.md to Confluence storage format so I can paste it into the source editor",
|
|
8
|
+
"should_trigger": true
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"query": "Publish design-notes.md as a wiki page under Architecture on our atlassian site",
|
|
12
|
+
"should_trigger": true
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"query": "update Confluence page 48213 with the new deploy-checklist.md",
|
|
16
|
+
"should_trigger": true
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"query": "our team docs live in Confluence, can you get this markdown onto a page with the code blocks looking right",
|
|
20
|
+
"should_trigger": true
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"query": "Convert this markdown to a Word document legal can track changes in",
|
|
24
|
+
"should_trigger": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"query": "Turn the markdown report into a single HTML file I can email",
|
|
28
|
+
"should_trigger": false
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"query": "Create Jira epics and issues from this PRD",
|
|
32
|
+
"should_trigger": false
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"query": "Write a runbook for the payments service before it goes to on-call",
|
|
36
|
+
"should_trigger": false
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"query": "Render the Mermaid flowchart in arch.mmd to a PNG",
|
|
40
|
+
"should_trigger": false
|
|
41
|
+
}
|
|
42
|
+
]
|