@skitterbyte/skitterspec-jira 0.0.0-stage → 2.0.0
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/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Jira Cloud REST v3 adapter — the one place that speaks Atlassian's
|
|
5
|
+
* protocol, and the boundary ADF never crosses.
|
|
6
|
+
*
|
|
7
|
+
* Every op takes and returns the engine's shapes: descriptions go in as
|
|
8
|
+
* markdown and come back as markdown-ish text (`adf.js` converts at this
|
|
9
|
+
* boundary), an issue's workflow state comes back as `{ name }`, an assignee
|
|
10
|
+
* as `{ id, name }` (the id is the Atlassian accountId). Nothing downstream
|
|
11
|
+
* needs to know ADF exists.
|
|
12
|
+
*
|
|
13
|
+
* STATUS MOVES BY TRANSITION, NEVER BY FIELD WRITE. Jira has no "set status":
|
|
14
|
+
* a workflow exposes the transitions legal from the current status, and the
|
|
15
|
+
* adapter applies the one landing on the target (`transitionTo`). No matching
|
|
16
|
+
* transition is a loud, named refusal listing the transitions that DO exist —
|
|
17
|
+
* a positive signal, never a guess.
|
|
18
|
+
* BLIND SPOT, deliberate: a status reachable only through intermediate
|
|
19
|
+
* transitions is reported as unreachable — v1 does not path-find a workflow.
|
|
20
|
+
*
|
|
21
|
+
* Auth is basic (email + API token). 401/403 are phrased for the human holding
|
|
22
|
+
* the token; 429 waits and retries via the kit's shared discipline
|
|
23
|
+
* (`provider-kit/src/http.js`); a 429 that never recovers is a retryable
|
|
24
|
+
* refusal, distinguishable from every other failure.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { withRateLimitRetry, MAX_RETRIES } = require('../provider-kit/src/http.js')
|
|
28
|
+
const { mdToAdf, adfToText } = require('./adf.js')
|
|
29
|
+
const { authHeader } = require('./credentials.js')
|
|
30
|
+
|
|
31
|
+
// A refusal Jira itself expressed — its `errorMessages` / field `errors` —
|
|
32
|
+
// kept apart from transport failures so callers can report cause, not symptom.
|
|
33
|
+
class JiraRefusal extends Error {
|
|
34
|
+
constructor(message, { status = null, retryable = false, errors = null } = {}) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'JiraRefusal'
|
|
37
|
+
this.status = status
|
|
38
|
+
this.retryable = retryable
|
|
39
|
+
this.errors = errors
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function refusalFrom(status, body) {
|
|
44
|
+
const messages = []
|
|
45
|
+
if (body && Array.isArray(body.errorMessages)) messages.push(...body.errorMessages)
|
|
46
|
+
if (body && body.errors && typeof body.errors === 'object') {
|
|
47
|
+
for (const [field, msg] of Object.entries(body.errors)) messages.push(`${field}: ${msg}`)
|
|
48
|
+
}
|
|
49
|
+
const joined = messages.length ? messages.join('; ') : `HTTP ${status}`
|
|
50
|
+
return new JiraRefusal(`Jira refused the request: ${joined}`, { status, errors: body && body.errors })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap credential + site into the typed ops the engine uses. Injected `fetch`
|
|
55
|
+
* and `sleep` make every op testable offline.
|
|
56
|
+
*/
|
|
57
|
+
function makeApiAdapter({ siteUrl, email, token, fetch: fetchImpl, sleep, maxRetries = MAX_RETRIES }) {
|
|
58
|
+
const doFetch = fetchImpl || globalThis.fetch
|
|
59
|
+
if (typeof doFetch !== 'function') {
|
|
60
|
+
throw new Error('no fetch available — Node 18+ is required for the API transport')
|
|
61
|
+
}
|
|
62
|
+
const site = String(siteUrl || '').replace(/\/+$/, '')
|
|
63
|
+
if (!site) throw new Error('no Jira site URL configured — set jira.siteUrl in specs/.core/jira.config.json')
|
|
64
|
+
const base = `${site}/rest/api/3`
|
|
65
|
+
const auth = authHeader({ email, token })
|
|
66
|
+
|
|
67
|
+
async function request(method, pathname, { query, body, allow404 = false } = {}) {
|
|
68
|
+
const url = new URL(base + pathname)
|
|
69
|
+
for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v))
|
|
70
|
+
const { response: res, exhausted } = await withRateLimitRetry(
|
|
71
|
+
async () => {
|
|
72
|
+
try {
|
|
73
|
+
return await doFetch(url.toString(), {
|
|
74
|
+
method,
|
|
75
|
+
headers: {
|
|
76
|
+
Authorization: auth,
|
|
77
|
+
Accept: 'application/json',
|
|
78
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
79
|
+
},
|
|
80
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
81
|
+
})
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
throw new Error(`Jira unreachable: ${cause && cause.message ? cause.message : cause}`)
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{ sleep, maxRetries },
|
|
87
|
+
)
|
|
88
|
+
if (res.status === 401 || res.status === 403) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Jira rejected the credentials (HTTP ${res.status}) — check the email and API token`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (exhausted) {
|
|
94
|
+
throw new JiraRefusal(
|
|
95
|
+
`Jira rate-limited this request and did not recover after ${maxRetries} retries`,
|
|
96
|
+
{ status: 429, retryable: true },
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 404 && allow404) return null
|
|
100
|
+
if (res.status === 204) return {}
|
|
101
|
+
let parsed = null
|
|
102
|
+
try {
|
|
103
|
+
parsed = await res.json()
|
|
104
|
+
} catch {
|
|
105
|
+
parsed = null
|
|
106
|
+
}
|
|
107
|
+
if (!res.ok) throw refusalFrom(res.status, parsed)
|
|
108
|
+
return parsed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ISSUE_FIELDS = 'summary,description,status,assignee,parent,subtasks'
|
|
112
|
+
|
|
113
|
+
const urlOf = (key) => `${site}/browse/${key}`
|
|
114
|
+
|
|
115
|
+
function normalizeIssue(raw) {
|
|
116
|
+
if (!raw || !raw.key) return null
|
|
117
|
+
const f = raw.fields || {}
|
|
118
|
+
return {
|
|
119
|
+
id: String(raw.id),
|
|
120
|
+
identifier: raw.key,
|
|
121
|
+
url: urlOf(raw.key),
|
|
122
|
+
title: f.summary != null ? f.summary : null,
|
|
123
|
+
// Text at the boundary: ADF never escapes the adapter.
|
|
124
|
+
description: f.description != null ? adfToText(f.description) : null,
|
|
125
|
+
state: f.status ? { name: f.status.name } : null,
|
|
126
|
+
assignee: f.assignee ? { id: f.assignee.accountId, name: f.assignee.displayName || null } : null,
|
|
127
|
+
parent: f.parent ? { id: String(f.parent.id), identifier: f.parent.key } : null,
|
|
128
|
+
subtasks: Array.isArray(f.subtasks)
|
|
129
|
+
? f.subtasks.map((s) => ({
|
|
130
|
+
id: String(s.id),
|
|
131
|
+
identifier: s.key,
|
|
132
|
+
title: s.fields && s.fields.summary != null ? s.fields.summary : null,
|
|
133
|
+
state: s.fields && s.fields.status ? { name: s.fields.status.name } : null,
|
|
134
|
+
}))
|
|
135
|
+
: [],
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build the writable `fields` payload from engine-shaped input. Only keys
|
|
140
|
+
// that are PRESENT are sent — `undefined` means "say nothing", `null` means
|
|
141
|
+
// "clear it" (assignee only; a null description clears to empty).
|
|
142
|
+
function issueFields(input, { forCreate = false, projectKey, issueTypeName } = {}) {
|
|
143
|
+
const fields = {}
|
|
144
|
+
if (forCreate) {
|
|
145
|
+
fields.project = { key: projectKey }
|
|
146
|
+
fields.issuetype = { name: issueTypeName }
|
|
147
|
+
}
|
|
148
|
+
if (input.title !== undefined) fields.summary = input.title
|
|
149
|
+
if (input.description !== undefined) {
|
|
150
|
+
fields.description = input.description == null ? null : mdToAdf(input.description)
|
|
151
|
+
}
|
|
152
|
+
if (input.assigneeId !== undefined) {
|
|
153
|
+
fields.assignee = input.assigneeId == null ? null : { accountId: input.assigneeId }
|
|
154
|
+
}
|
|
155
|
+
return fields
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
async readIssue(keyOrId) {
|
|
160
|
+
const raw = await request('GET', `/issue/${encodeURIComponent(keyOrId)}`, {
|
|
161
|
+
query: { fields: ISSUE_FIELDS },
|
|
162
|
+
allow404: true,
|
|
163
|
+
})
|
|
164
|
+
return normalizeIssue(raw)
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async createIssue({ title, description, assigneeId, projectKey, issueTypeName }) {
|
|
168
|
+
const created = await request('POST', '/issue', {
|
|
169
|
+
body: { fields: issueFields({ title, description, assigneeId }, { forCreate: true, projectKey, issueTypeName }) },
|
|
170
|
+
})
|
|
171
|
+
if (!created || !created.key) throw new Error('Jira returned no issue for the create')
|
|
172
|
+
return { id: String(created.id), identifier: created.key, url: urlOf(created.key) }
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async createSubIssue(parentId, { title, description, projectKey, subtaskTypeName }) {
|
|
176
|
+
const fields = issueFields({ title, description }, { forCreate: true, projectKey, issueTypeName: subtaskTypeName })
|
|
177
|
+
fields.parent = { id: String(parentId) }
|
|
178
|
+
const created = await request('POST', '/issue', { body: { fields } })
|
|
179
|
+
if (!created || !created.key) throw new Error('Jira returned no issue for the subtask create')
|
|
180
|
+
return { id: String(created.id), identifier: created.key, url: urlOf(created.key) }
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async updateIssue(keyOrId, updates) {
|
|
184
|
+
const fields = issueFields(updates)
|
|
185
|
+
if (!Object.keys(fields).length) return {}
|
|
186
|
+
return request('PUT', `/issue/${encodeURIComponent(keyOrId)}`, { body: { fields } })
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async listTransitions(keyOrId) {
|
|
190
|
+
const raw = await request('GET', `/issue/${encodeURIComponent(keyOrId)}/transitions`)
|
|
191
|
+
return ((raw && raw.transitions) || []).map((t) => ({
|
|
192
|
+
id: String(t.id),
|
|
193
|
+
name: t.name,
|
|
194
|
+
to: t.to ? { name: t.to.name } : null,
|
|
195
|
+
}))
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Move an issue to the status named `targetStatus`, by whichever available
|
|
200
|
+
* transition lands there. `{ moved: false, already: true }` when the issue
|
|
201
|
+
* is already sitting on it — the idempotent case, not a failure.
|
|
202
|
+
*/
|
|
203
|
+
async transitionTo(keyOrId, targetStatus) {
|
|
204
|
+
const want = String(targetStatus || '').toLowerCase().trim()
|
|
205
|
+
const transitions = await this.listTransitions(keyOrId)
|
|
206
|
+
const hit = transitions.find((t) => t.to && String(t.to.name).toLowerCase().trim() === want)
|
|
207
|
+
if (!hit) {
|
|
208
|
+
const current = await this.readIssue(keyOrId)
|
|
209
|
+
const at = current && current.state ? current.state.name : null
|
|
210
|
+
if (at && String(at).toLowerCase().trim() === want) return { moved: false, already: true }
|
|
211
|
+
const names = transitions.map((t) => `"${t.to ? t.to.name : t.name}"`).join(', ') || '(none)'
|
|
212
|
+
throw new JiraRefusal(
|
|
213
|
+
`no transition from ${keyOrId}'s current status${at ? ` "${at}"` : ''} reaches "${targetStatus}" — ` +
|
|
214
|
+
`available: ${names}. The workflow may need an intermediate step; this engine does not path-find.`,
|
|
215
|
+
{ status: null },
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
await request('POST', `/issue/${encodeURIComponent(keyOrId)}/transitions`, {
|
|
219
|
+
body: { transition: { id: hit.id } },
|
|
220
|
+
})
|
|
221
|
+
return { moved: true, via: hit.name }
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Issues whose summary contains `query`, scoped to a project when given.
|
|
226
|
+
* JQL strings escape embedded quotes; result shape matches `readIssue`'s
|
|
227
|
+
* essentials (the twin-mint guard needs identifier + title).
|
|
228
|
+
*/
|
|
229
|
+
async searchIssues({ query, projectKey, limit = 50 }) {
|
|
230
|
+
const quoted = String(query || '').replace(/(["\\])/g, '\\$1')
|
|
231
|
+
const jql = [projectKey ? `project = "${projectKey}"` : null, quoted ? `summary ~ "${quoted}"` : null]
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.join(' AND ')
|
|
234
|
+
const raw = await request('GET', '/search/jql', {
|
|
235
|
+
query: { jql, fields: 'summary,status,parent', maxResults: limit },
|
|
236
|
+
})
|
|
237
|
+
return ((raw && raw.issues) || []).map((i) => ({
|
|
238
|
+
id: String(i.id),
|
|
239
|
+
identifier: i.key,
|
|
240
|
+
title: i.fields && i.fields.summary != null ? i.fields.summary : null,
|
|
241
|
+
state: i.fields && i.fields.status ? { name: i.fields.status.name } : null,
|
|
242
|
+
parent: i.fields && i.fields.parent ? { id: String(i.fields.parent.id), identifier: i.fields.parent.key } : null,
|
|
243
|
+
}))
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The status names this project's workflows can express, deduped across
|
|
248
|
+
* issue types — what `states` validation checks the config against.
|
|
249
|
+
*/
|
|
250
|
+
async listIssueStates(projectKey) {
|
|
251
|
+
const raw = await request('GET', `/project/${encodeURIComponent(projectKey)}/statuses`)
|
|
252
|
+
const names = new Map()
|
|
253
|
+
for (const type of raw || []) {
|
|
254
|
+
for (const status of type.statuses || []) {
|
|
255
|
+
if (status && status.name && !names.has(status.name)) {
|
|
256
|
+
names.set(status.name, {
|
|
257
|
+
id: String(status.id),
|
|
258
|
+
name: status.name,
|
|
259
|
+
// The status CATEGORY (To Do / In Progress / Done) is what boards
|
|
260
|
+
// and reports key off — a different axis from the name, and worth
|
|
261
|
+
// surfacing wherever the names are listed.
|
|
262
|
+
category: (status.statusCategory && status.statusCategory.name) || null,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return [...names.values()]
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async readViewer() {
|
|
271
|
+
const raw = await request('GET', '/myself')
|
|
272
|
+
if (!raw || !raw.accountId) return null
|
|
273
|
+
return { id: raw.accountId, name: raw.displayName || null, email: raw.emailAddress || null }
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
async searchUsers({ query, limit = 20 }) {
|
|
277
|
+
const raw = await request('GET', '/user/search', { query: { query, maxResults: limit } })
|
|
278
|
+
return (raw || []).map((u) => ({ id: u.accountId, name: u.displayName || null, email: u.emailAddress || null }))
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async listComments(keyOrId) {
|
|
282
|
+
const raw = await request('GET', `/issue/${encodeURIComponent(keyOrId)}/comment`)
|
|
283
|
+
return ((raw && raw.comments) || []).map((c) => ({ id: String(c.id), body: adfToText(c.body) }))
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async createComment(keyOrId, mdBody) {
|
|
287
|
+
const raw = await request('POST', `/issue/${encodeURIComponent(keyOrId)}/comment`, {
|
|
288
|
+
body: { body: mdToAdf(mdBody) },
|
|
289
|
+
})
|
|
290
|
+
return raw && raw.id ? { id: String(raw.id) } : null
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = { makeApiAdapter, JiraRefusal, adfToText, mdToAdf }
|