A live Backlog.md board in a Claude Code side pane (unofficial).

<img src=".claude-plugin/icon.png" alt="" width="96" align="right">
English · Español
A live Backlog.md board in a Claude Code side pane.
Unofficial. backlog-pane is a community mod for Claude Code. It is not part of, endorsed by, or maintained by the Backlog.md project or Anthropic. It only drives the
backlogCLI you already have installed.
/backlog opens a pane beside the conversation with your tasks in one column per status. The pane stays in sync with backlog task list --json --watch, so it updates when Claude, you or your teammates change a task. Drag a card to move it, or open a task to edit its title, priority, acceptance criteria, description and plan. Work on this task and Edit with Claude put a ready-made prompt in the prompt box. The prompt is never sent for you.
! in the error colour, so the meaning does not depend on colour alone.h and l) move it one step, on every surface. The status picker jumps anywhere.backlog config get priorities, else High/Medium/Low), and acceptance criteria. You can tick, add, edit, and delete criteria; delete asks first. Work on BL-7 only. Research the codebase and write an implementation plan in the task. Wait for my approval before coding.
Update BL-7: with the caret after it, ready for you to say what should change.Both drafts replace an empty prompt box. If the box already holds text, the draft goes below it. Nothing is submitted.
language option.backlog command (task edit, task create); the mod never edits the task files. Backlog.md has no command that changes the list of statuses, so creating or deleting one rewrites only the statuses: line of Backlog.md's config (see Statuses).Give the pane the keyboard with ctrl+x then tab, or click it. Tab moves between controls and Esc returns to the prompt.
| Where | Key or action | What it does |
|---|---|---|
| Board (terminal, desktop) | Drag a card to another column | Moves the task there. |
| Board (terminal, desktop) | Click a card, then a free spot in another column | Moves the selected card there. |
| Board | Click the selected card again | Opens the task. |
| Board | Click a free spot | Lets go of the selected card. A card you moved with the mouse is let go of by itself, so a later click can't move it again. |
| Board, after a click on it | ← / → | Moves the selected card to the next open column left or right, skipping closed ones. |
| Board, after a click on it | ↑ / ↓ | Selects the card above or below. The first key selects the first card of the first open column. |
| Board, after a click on it | Enter | Opens the selected task. |
| Board | Esc | Gives the keyboard back. |
| Task view | h | Moves the task to the previous open status (◀). |
| Task view | l | Moves the task to the next open status (▶). |
| Multi-line field | Enter | Saves. |
| Multi-line field | Shift+Enter, or \ then Enter | Starts a new line. |
h and l work while the pane has the keyboard, not while you type in the prompt box.
Statuses belong to each project: Backlog.md keeps them in its config (backlog/config.yml, or backlog.config.yml / .backlog/config.yml). The pane reads them with backlog config get statuses and draws the columns in that order. The last one is the final ("done") status, the same rule Backlog.md uses.
Statuses… (beside + New task) opens a list of them:
backlog config get defaultStatus) is kept, and a project always keeps at least two.Backlog.md has no command for this, so the pane:
statuses: line, every other line left as it was;backlog config get statuses, and if Backlog.md does not read exactly the new list, puts the file back as it was and says so.Each row also has:
Colours and closed statuses are kept per project, in Claude Code's own store for the mod (keyed by the project's config file), because Backlog.md's config has no place for them. They are yours alone: they are not in the repository, so teammates don't share them, and a project moved to another folder starts afresh. The closedStatuses option (below) is only the starting value for a project where no Closed box was ever ticked.
Each project can be linked to one outside tracker: Redmine's API, Jira's (see Integration (Jira)), or a CSV that Redmine exported (see Integration (Excel / CSV)). The tasks are copied into Backlog.md with the backlog CLI, so the board, Claude and Backlog.md's own tools all see them as ordinary tasks.
Set it up:
/plugin, or /config when the mod loads from a folder). It is a sensitive option, so Claude Code keeps it in secure storage, not in settings.json.https://redmine.example.com) and the project's identifier (as in its URL, for example my-app).Modes:
What syncs: every issue of the project, open and closed, not its subprojects'; or, with Only those assigned to me, just the ones assigned to the API key's owner (you, or a group you are in). For each one, the title (subject), status, priority and description. Each new task gets the issue's URL (…/issues/42) in its references, which is how the pane finds the pair again; a task you already pointed at an issue that way is adopted instead of copied twice, with Redmine's values winning.
Status and priority mappings. After the first sync, Integration… lists each Redmine status and priority with the Backlog.md one it lands in. The pane guesses them (same name first; then closed ones in the final status, *In Progress*-like ones in yours, the rest in the first status; *Urgent*/*High* → High, Normal → Medium, Low → Low) and you change any of them. A priority set to — (left alone) is not synced. In both-ways mode, a Backlog.md status that no Redmine one maps to is not sent, and the pane says so.
Conflicts. The pane keeps what both sides agreed at the last sync. When the same field changed on both sides, to different values, neither side is touched for that field: the board shows 1 conflict, and Integration… shows both values with Use Redmine's and Use Backlog.md's.
View: all / only mine. Beside Sync now on the board, a button switches the view at once, with no call to Redmine: only mine hides the tasks whose issue, at the last sync, was assigned to someone else, to nobody, or was not listed; tasks with no issue always show. It is separate from the Issues option, which decides what is synced: you can sync every issue and still look at yours only. The view is kept per project.
When: Sync now in Integration… or on the board's sync line, and on its own every 5, 15, 30 or 60 minutes (5 by default), or Only with the button. The sync runs only while the pane is open; the first one starts when it opens.
Nothing is deleted on either side. An issue that was deleted, moved out of the project, or (with only mine) assigned to someone else leaves its task as it was, no longer synced; it syncs again if it comes back. With only mine in both-ways mode, a new Backlog.md task is created in Redmine assigned to you, so it stays in your list. Disconnect (tracker None) stops the sync and keeps the tasks.
Tasks from before. In both-ways mode, the tasks that were in the project when you set Redmine up are not sent to it, so linking a project does not flood Redmine. Send them too sends them.
Settings, links and mappings are kept per project in Claude Code's store for the mod, like the colours, so they are yours alone. The key is per user: one key for every project you sync, sent only in the X-Redmine-API-Key header to the URL each project sets.
The mod never shows the key to Claude: it is not in the prompt, the conversation, the pane (which only says whether it is set), the tasks or the mod's store. But Claude Code keeps it on your machine, and Claude has tools that read files:
~/.claude/.credentials.json, a plain file guarded only by your user's permissions. Claude would need your approval to read it (it is outside the project), unless a permission mode approves everything for you.To close that door:
~/.claude/settings.json. Deny rules win over every permission mode: {
"permissions": {
"deny": ["Read(~/.claude/.credentials.json)", "Bash(*credentials.json*)"]
}
}
The Read rule is the one that matters; the Bash one only catches the plain cases.
The same as Redmine's: pick Jira (API) in Integration…, and the modes, the Issues option, the board's View: all / only mine, the mappings, the conflicts, the interval, the tasks from before and nothing is deleted all work as described above. It works with Jira Cloud (*.atlassian.net) and with Jira Data Center / Server.
Set it up:
The token is a sensitive option, kept in secure storage like Redmine's key (see Keeping the API key safe).
https://your-company.atlassian.net) and the project's key (the letters of its issue keys: APP for APP-12).What is different from Redmine:
https://your-company.atlassian.net/browse/APP-12.Only one token is kept, so all the projects you sync with Jira use the same account.
For when you can't get a Redmine API key but can export the issues. Redmine exports them as CSV, which Excel opens; the pane reads that file. It is read only: nothing is ever written to the file or to Redmine.
Set it up:
C:\Users\you\Downloads\issues.csv.~).…/issues/12) instead of #12, and if you later get an API key, Redmine (API) picks the same tasks up instead of copying them again.Each time you want fresh data, export again over the same file and press Sync now. The file is read only then: not when the pane opens, and not on a timer.
What it reads: the columns #, Subject, Status, Priority and Description, by their headers in English, Spanish and Redmine's other main languages. Only # and Subject are needed; a field with no column is left alone, and the pane says so. The separator (,, ; or tab) and the encoding (UTF-8, or Windows-1252 as Redmine writes Spanish by default) are found by themselves.
Statuses and priorities are mapped by name, the same way as with the API (a status with a date in the Closed column, or a name like Cerrada or Rechazada, goes to the final status), and you can change each one in Integration….
Your changes in Backlog.md stay there: a field you changed in Backlog.md is not overwritten by the next file. When a field changed both in the file and in Backlog.md, the pane asks, with Use the file's and Use Backlog.md's. Keeping Backlog.md's holds until the file changes that field again. Nothing is deleted: an issue that is no longer in the file leaves its task as it was.
Claude Code 2.1.301 and later: Edit beside Description or Implementation plan opens an 8-row field. Enter saves with backlog task edit <id> -d … or --plan ….
Older versions: Input has no rows there, and a drawing that asks for one can be refused or drawn as a one-line field. So the pane never asks there, and shows Open in editor instead. It writes the text to a file in your temp folder (TMPDIR, TEMP or TMP) and opens your editor on it. When the editor exits, the pane reads the file back and saves it. The editor is the first of:
backlog config get defaultEditorVISUALEDITORThe editor must keep running until you close the file, or the pane reads the file back before you have typed anything. Terminal editors (vim, nano) already do that. A GUI editor needs its wait flag:
backlog config set defaultEditor "code --wait"
The pane waits up to ten minutes. After that the edit is not applied.
<!-- Replace these with real captures: docs/screenshots/terminal-board.png, docs/screenshots/terminal-task.png, docs/screenshots/desktop-board.png -->
Screenshots are coming. Until then, this is a text preview of the terminal pane:
[ + New task ] [ Statuses… ]
Cancelled 1 To Do 1 In Progress 1 Done 2
╭─────────────╮ ╭─────────────╮ ╭─────────────╮ ╭─────────────╮
│ Old export │ │ ! Rate │ │ ! Write the │ │ Add login │
│ idea │ │ limiting… │ │ JSON str… │ │ page │
│ TASK-5 │ │ TASK-3 │ │ TASK-1 · 1… │ │ TASK-2 │
╰─────────────╯ ╰─────────────╯ ╰─────────────╯ ╰─────────────╯
╭─────────────╮
│ Dark mode │
│ TASK-4 │
╰─────────────╯
Drag a card to another column, or click it and press ←/→ to move
it, ↑/↓ to pick another, Enter to open it.
That is the pane docked in a 150-column terminal (Claude Code 2.1.310). On screen each status is a chip in its colour and each card's border takes its status's colour; the selected card has a thick border, and the column a card is dragged over shows a dashed slot where it will land.
plugin.json has no field for a minimum version, so it is stated here only.schemaVersion 1). Tested with 1.55.3. npm i -g backlog.md
bun add -g backlog.md and brew install backlog-md also work.
backlog init. The pane uses the session's working folder.At the prompt of a Claude Code terminal session:
/plugin install backlog-pane --marketplace ezmg/backlog-pane
Answer y to add the marketplace, then choose a scope (user scope is the default).
From a shell:
claude plugin install backlog-pane --marketplace ezmg/backlog-pane
Claude Desktop (Code tab). /plugin does not run there. Click + next to the prompt box, then Plugins, Add marketplace with ezmg/backlog-pane, then Add plugin. A mod you install from a terminal at the user scope also loads in the desktop app's local sessions.
/backlog.To close the pane, click its close mark or press ctrl+x then x. Closing it stops the --watch process.
| Option | Default | What it does |
|---|---|---|
backlogPath | (empty) | Full path to the backlog executable. When empty, the mod tries backlog on PATH. On Windows it then tries the native binaries npm and bun install globally. |
doneLimit | 5 | How many of the most recent tasks in the final status (and in each closed status) the board shows, and the list shows when that group is unfolded. |
closedStatuses | (empty) | Optional. Statuses that start as closed in a project where you have never ticked a Closed box in Statuses…, separated by commas (for example Cancelled). Once you tick or untick one, the project keeps its own list. |
jiraEmail | (empty) | For Jira Cloud: the email of the Atlassian account the token belongs to. Empty for Data Center / Server. |
jiraApiToken | (empty) | Your Jira API token (Cloud) or personal access token (Data Center / Server). Sensitive: kept in secure storage, and sent only to the Jira URL each project sets. |
redmineApiKey | (empty) | Your Redmine API key, for the projects you link to Redmine in Integration…. Sensitive: kept in secure storage, and sent only to the Redmine URL each project sets. |
| language | auto | en (English), es (Spanish), or auto. With auto the mod uses the first of these that is set: Claude Code's language setting (free text such as Español, Spanish
hooks/register.tsx 2255 lines1// backlog-pane: a live Backlog.md board in a side pane.
2//
3// `/backlog` opens the pane and starts one `backlog task list --json --watch`
4// child, whose full-list replacements are read into $.state; the pane draws
5// from there. Every write goes through a `backlog` command, never the files.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { BoardProps, Phase, Prefs, SyncConflict, SyncData, SyncLink, SyncSettings, SyncView, TaskDetail, TaskSummary } from '../types'
11import {
12 JsonDocumentReader,
13 args,
14 candidates,
15 classify,
16 configCandidates,
17 createdId,
18 editorFile,
19 errorLine,
20 groupTasks,
21 isAtLeast,
22 isHighPriority,
23 isPaletteColor,
24 isRecord,
25 neighbours,
26 PALETTE,
27 parseClosed,
28 parsePriorities,
29 parseStatuses,
30 pickEditor,
31 readPrefs,
32 readTaskList,
33 readTaskView,
34 rewriteCriteria,
35 rewriteStatuses,
36 sameStatuses,
37 statusProblem,
38 summarize,
39} from './cli'
40import { detectLanguage, strings, wordsFor } from './i18n'
41import * as jira from './jira'
42import {
43 PAGE,
44 defaultPriorityMap,
45 defaultStatusMap,
46 headers as redmineHeaders,
47 issueBody,
48 issueUrl,
49 normalizeUrl,
50 readCreated,
51 readCurrentUser,
52 issueIdOf,
53 readError,
54 readIssues,
55 readPriorities,
56 readStatuses,
57 redmineStatusFor,
58 toRemoteItem,
59 urls as redmineUrls,
60} from './redmine'
61import type { RedminePriority, RedmineStatus } from './redmine'
62import { EMPTY_SYNC, needsRead, othersTasks, plan, readSyncStore } from './sync'
63import type { Action, Change, LocalItem } from './sync'
64import { decodeText, fromBase64, prioritiesOf, readExport, refFor, statusesOf, toRemoteRow } from './csv'
65import type { Language, Strings } from './i18n'
66
67type Engine = EngineInterface
68
69const PANE = 'backlog'
70const COMMAND = 'backlog'
71const MAX_ATTEMPTS = 5
72// The release whose Input took `rows`. An older engine does not draw it (or
73// refuses the tree that names it), so the external editor stands in there.
74const MULTILINE_SINCE = '2.1.301'
75// How long the external editor may stay open: $.process.run's longest.
76const EDITOR_TIMEOUT_MS = 600_000
77
78type LongField = 'description' | 'plan'
79
80const phase = atom({ plugin: 'backlog-pane', key: 'phase' } as const, { kind: 'idle' } as Phase)
81const tasks = atom({ plugin: 'backlog-pane', key: 'tasks' } as const, [] as TaskSummary[])
82const statuses = atom({ plugin: 'backlog-pane', key: 'statuses' } as const, [] as string[])
83const priorities = atom({ plugin: 'backlog-pane', key: 'priorities' } as const, [] as string[])
84const selectedId = atom({ plugin: 'backlog-pane', key: 'selectedId' } as const, null as string | null)
85const detail = atom({ plugin: 'backlog-pane', key: 'detail' } as const, null as TaskDetail | null)
86// The folded groups of the list (the final status, the closed ones) that are unfolded.
87const openGroups = atom({ plugin: 'backlog-pane', key: 'openGroups' } as const, [] as string[])
88const notice = atom({ plugin: 'backlog-pane', key: 'notice' } as const, null as string | null)
89// What the task view is editing: `description`, `plan`, a criterion
90// (`criterion:<index>`), or the criterion awaiting a delete (`remove:<index>`).
91const editing = atom({ plugin: 'backlog-pane', key: 'editing' } as const, null as string | null)
92const isCreating = atom({ plugin: 'backlog-pane', key: 'isCreating' } as const, false)
93// Set when the board's Client failed on a surface: the list stands in.
94const isBoardFailed = atom({ plugin: 'backlog-pane', key: 'isBoardFailed' } as const, false)
95// The statuses view, and where a new status goes in it (null: before the final one).
96const isManagingStatuses = atom({ plugin: 'backlog-pane', key: 'isManagingStatuses' } as const, false)
97const newStatusAt = atom({ plugin: 'backlog-pane', key: 'newStatusAt' } as const, null as number | null)
98// This project's colours and closed statuses, and the $.store key they are kept under.
99const prefs = atom({ plugin: 'backlog-pane', key: 'prefs' } as const, { colors: {} } as Prefs)
100const prefsKey = atom({ plugin: 'backlog-pane', key: 'prefsKey' } as const, null as string | null)
101// This project's sync with an outside tracker, and whether its view is open.
102const sync = atom(
103 { plugin: 'backlog-pane', key: 'sync' } as const,
104 { settings: null, data: EMPTY_SYNC, isSyncing: false } as SyncView,
105)
106const isManagingSync = atom({ plugin: 'backlog-pane', key: 'isManagingSync' } as const, false)
107
108type Run = { ok: true; stdout: string } | { ok: false; stderr: string }
109
110// Read from the plugin's options each time the module loads.
111let configured = ''
112let doneLimit = 5
113// The closedStatuses option: the closed statuses of a project whose own are not set yet.
114let closedOption: string[] = []
115// The redmineApiKey option (secure storage), sent in the X-Redmine-API-Key header only.
116let redmineKey = ''
117let jiraEmail = ''
118let jiraToken = ''
119// The automatic sync while the pane is open, and whether a sync runs now.
120let syncTimer: { cancel: () => void } | null = null
121let isSyncRunning = false
122// Whether this watch has started the sync (once its first list is in).
123let isSyncArmed = false
124// The session's working folder, from session.start: where the config is looked for from.
125let cwd = ''
126let languageOption: unknown = 'auto'
127// The language shown, detected once per load.
128let language: Language | null = null
129// Whether this engine's Input takes `rows`, asked once per load.
130let multiline: boolean | null = null
131// The executable that answered `--version`; found again after a reload.
132let bin: string | null = null
133// The watch loop in progress, if any.
134let active: { isStopped: boolean; stop: () => void } | null = null
135
136/**
137 * The strings in the language the `language` option names, or for `auto`
138 * the one Claude Code's `language` setting, the locale variables or the
139 * runtime's locale say, in that order.
140 */
141async function text($: Engine): Promise<Strings> {
142 if (!language) {
143 let setting: unknown
144 try {
145 const value = await $.settings.read({ key: 'language' })
146 // Earlier releases (2.1.296 among them) answer a read of one key with
147 // the whole settings object.
148 setting = isRecord(value) ? value.language : value
149 } catch {
150 // No settings to read here; the locale decides.
151 }
152 let intl: string | undefined
153 try {
154 intl = new Intl.DateTimeFormat().resolvedOptions().locale
155 } catch {
156 // No Intl in this runtime.
157 }
158 language = detectLanguage({
159 option: languageOption,
160 setting,
161 lcAll: await $.env.get('LC_ALL'),
162 lcMessages: await $.env.get('LC_MESSAGES'),
163 lang: await $.env.get('LANG'),
164 intl,
165 })
166 }
167 return strings(language)
168}
169
170async function hasMultiline($: Engine): Promise<boolean> {
171 if (multiline === null) {
172 try {
173 const engine = await $.session.version()
174 multiline = isAtLeast(engine.base ?? engine.version, MULTILINE_SINCE)
175 } catch {
176 multiline = false
177 }
178 }
179 return multiline
180}
181
182async function findBin($: Engine): Promise<string | null> {
183 if (bin) return bin
184 const list = candidates({
185 configured,
186 os: await $.env.get('OS'),
187 appData: await $.env.get('APPDATA'),
188 userProfile: await $.env.get('USERPROFILE'),
189 })
190 for (const candidate of list) {
191 try {
192 const probe = await $.process.run([candidate, ...args.version()], { timeoutMs: 15_000 })
193 if (probe.exitCode === 0) return (bin = candidate)
194 } catch {
195 // Not startable under this name; try the next.
196 }
197 }
198 return null
199}
200
201async function backlog($: Engine, argv: string[]): Promise<Run> {
202 const exe = await findBin($)
203 if (!exe) return { ok: false, stderr: (await text($)).cliNotFound }
204 try {
205 const done = await $.process.run([exe, ...argv], { timeoutMs: 60_000 })
206 return done.exitCode === 0
207 ? { ok: true, stdout: done.stdout }
208 : { ok: false, stderr: done.stderr || done.stdout }
209 } catch (error) {
210 return { ok: false, stderr: String(error) }
211 }
212}
213
214async function loadStatuses($: Engine) {
215 const run = await backlog($, args.statuses())
216 if (run.ok) await update($, statuses, () => parseStatuses(run.stdout))
217 const listed = await backlog($, args.priorities())
218 await update($, priorities, () => parsePriorities(listed.ok ? listed.stdout : ''))
219 await loadPrefs($)
220}
221
222/** This project's prefs, kept in $.store under its config's path (else its folder). */
223async function loadPrefs($: Engine) {
224 const key = `project:${(await findConfig($)) ?? cwd}`
225 let stored: unknown
226 try {
227 stored = await $.store.get(key)
228 } catch {
229 // Nothing kept yet.
230 }
231 await update($, prefsKey, () => key)
232 await update($, prefs, () => readPrefs(stored))
233 await loadSync($)
234}
235
236/** The $.store key of the project's sync, beside its prefs'. */
237const syncKeyOf = (key: string) => key.replace(/^project:/, 'sync:')
238
239async function loadSync($: Engine) {
240 const key = await read($, prefsKey)
241 if (!key) return
242 let stored: unknown
243 try {
244 stored = await $.store.get(syncKeyOf(key))
245 } catch {
246 // Nothing kept yet.
247 }
248 const kept = readSyncStore(stored)
249 await update($, sync, now => ({ ...kept, isSyncing: now.isSyncing }))
250}
251
252async function saveSync($: Engine, change: (now: SyncView) => { settings: SyncSettings | null; data: SyncData }) {
253 const next = await update($, sync, now => ({ ...change(now), isSyncing: now.isSyncing }))
254 const key = await read($, prefsKey)
255 if (!key) return
256 try {
257 await $.store.set(syncKeyOf(key), { settings: next.settings, data: next.data })
258 } catch (error) {
259 await update($, notice, () => String(error))
260 }
261}
262
263type Http = { ok: boolean; status: number; text: string }
264
265/** One request to Redmine with the key; a request that cannot go out is status 0. */
266async function redmineCall($: Engine, method: string, url: string, body?: unknown): Promise<Http> {
267 try {
268 const answer = await $.http.fetch(url, {
269 method,
270 headers: redmineHeaders(redmineKey, body !== undefined),
271 body: body === undefined ? undefined : JSON.stringify(body),
272 })
273 return { ok: answer.ok, status: answer.status, text: answer.text }
274 } catch (error) {
275 return { ok: false, status: 0, text: String(error) }
276 }
277}
278
279function problemOf(t: Strings, answer: Http, project: string): string {
280 if (answer.status === 0) return t.unreachable(errorLine(answer.text, '?'))
281 if (answer.status === 401) return t.unauthorized
282 if (answer.status === 403) return t.forbidden
283 if (answer.status === 404) return t.noProject(project)
284 return t.unexpected(readError(answer.status, answer.text))
285}
286
287/** Runs one sync now, unless one runs already; what it did is the notice. */
288async function runSync($: Engine) {
289 const view = await read($, sync)
290 const settings = view.settings
291 if (!settings || isSyncRunning) return
292 const t = wordsFor(await text($), settings.provider)
293 const base = normalizeUrl(settings.url)
294 const isCsv = settings.provider === 'csv'
295 const isJira = settings.provider === 'jira'
296 const problem = isCsv
297 ? !settings.file.trim()
298 ? t.badFile
299 : null
300 : !base
301 ? t.badUrl
302 : !settings.project.trim()
303 ? t.badProject
304 : !(isJira ? jiraToken : redmineKey)
305 ? t.apiKeyMissing
306 : isJira && jira.isCloud(base) && !jiraEmail
307 ? t.emailMissing
308 : null
309 if (problem || (!isCsv && !base)) {
310 await update($, notice, () => problem ?? t.badUrl)
311 return
312 }
313 isSyncRunning = true
314 await update($, sync, now => ({ ...now, isSyncing: true }))
315 try {
316 const failed = isCsv
317 ? await syncCsv($, settings, base, t)
318 : isJira
319 ? await syncJira($, settings, base ?? '', t)
320 : await syncOnce($, settings, base ?? '', t)
321 if (failed) {
322 await saveSync($, now => ({ settings: now.settings, data: { ...now.data, lastError: failed } }))
323 await update($, notice, () => failed)
324 }
325 } finally {
326 isSyncRunning = false
327 await update($, sync, now => ({ ...now, isSyncing: false }))
328 }
329}
330
331/**
332 * The Backlog.md side of a sync: every task, with the description of the
333 * linked ones (or those naming an issue) that changed since the last sync.
334 * A string: the list failed, and why.
335 */
336async function readLocal($: Engine, data: SyncData, issueRefs: ReadonlySet<string>): Promise<LocalItem[] | string> {
337 const listed = await backlog($, args.list())
338 const tasksNow = listed.ok ? readTaskList(safeParse(listed.stdout)) : undefined
339 if (!tasksNow) return errorLine(listed.ok ? '' : listed.stderr, '')
340 const linked = new Set(Object.values(data.links).map(link => link.taskId))
341 const local: LocalItem[] = []
342 for (const task of tasksNow) {
343 const fields: LocalItem['fields'] = { title: task.title, status: task.status, priority: task.priority?.toLowerCase() ?? null }
344 const isLinked = linked.has(task.id) || task.references.some(ref => issueRefs.has(ref))
345 if (isLinked && needsRead(task.updatedAt, data.lastSync)) {
346 const run = await backlog($, args.view(task.id))
347 const detail = run.ok ? readTaskView(safeParse(run.stdout)) : undefined
348 if (detail) fields.description = detail.description ?? ''
349 }
350 local.push({ id: task.id, references: task.references, fields })
351 }
352 return local
353}
354
355/** Runs a create or edit in Backlog.md; on a failure the link stays as it was, to try again. */
356async function applyLocal(
357 $: Engine,
358 action: Extract<Action, { kind: 'create-local' | 'update-local' }>,
359 links: Record<string, SyncLink>,
360 data: SyncData,
361 warnings: string[],
362 t: Strings,
363): Promise<boolean> {
364 if (action.kind === 'create-local') {
365 const run = await backlog($, args.createFrom(action.fields, action.url))
366 const id = run.ok ? createdId(run.stdout) : undefined
367 if (id) {
368 links[action.key] = { taskId: id, base: action.fields }
369 return true
370 }
371 delete links[action.key]
372 warnings.push(t.localFailed(`#${action.key}`, errorLine(run.ok ? '' : run.stderr, '?')))
373 return false
374 }
375 const run = await backlog($, args.editFields(action.taskId, action.change))
376 if (run.ok) return true
377 // Tried again next time: the old base still differs.
378 const old = data.links[action.key]
379 if (old) links[action.key] = old
380 warnings.push(t.localFailed(action.taskId, errorLine(run.stderr, '?')))
381 return false
382}
383
384/** One request to Jira with the token; a request that cannot go out is status 0. */
385async function jiraCall($: Engine, base: string, method: string, url: string, body?: unknown): Promise<Http> {
386 try {
387 const answer = await $.http.fetch(url, {
388 method,
389 headers: jira.headers(base, jiraEmail, jiraToken, body !== undefined),
390 body: body === undefined ? undefined : JSON.stringify(body),
391 })
392 return { ok: answer.ok, status: answer.status, text: answer.text }
393 } catch (error) {
394 return { ok: false, status: 0, text: String(error) }
395 }
396}
397
398function jiraProblem(t: Strings, answer: Http, project: string): string {
399 if (answer.status === 0) return t.unreachable(errorLine(answer.text, '?'))
400 if (answer.status === 401) return t.unauthorized
401 if (answer.status === 403) return t.forbidden
402 if (answer.status === 404) return t.noProject(project)
403 return t.unexpected(jira.readError(answer.status, answer.text))
404}
405
406/**
407 * Sends a change to a Jira issue: its fields by a PUT, its status by the
408 * workflow's transition to it, if one is open from where the issue is.
409 * Answers whether anything was sent, and what could not be.
410 */
411async function pushJira(
412 $: Engine,
413 base: string,
414 key: string,
415 taskId: string,
416 change: Change,
417 maps: { status: Record<string, string>; priority: Record<string, string> },
418 lists: { statuses: RedmineStatus[]; priorities: RedminePriority[] },
419 current: { statusId?: number | string; priorityId?: number | string | null } | undefined,
420 t: Strings,
421): Promise<{ isSent: boolean; failed: string[]; warnings: string[] }> {
422 const failed: string[] = []
423 const warnings: string[] = []
424 let isSent = false
425 const body = jira.fieldsBody(change, maps.priority, lists.priorities, current?.priorityId === undefined ? undefined : String(current.priorityId))
426 for (const field of body.skipped) {
427 failed.push(field)
428 warnings.push(t.priorityNotSent(taskId, change.priority ?? ''))
429 }
430 if (Object.keys(body.fields).length > 0) {
431 const answer = await jiraCall($, base, 'PUT', jira.urls.issue(base, key), { fields: body.fields })
432 if (answer.ok) isSent = true
433 else {
434 failed.push(...Object.keys(change).filter(field => field !== 'status'))
435 warnings.push(t.refused(key, answer.status === 0 ? errorLine(answer.text, '?') : jira.readError(answer.status, answer.text)))
436 }
437 }
438 if (change.status !== undefined) {
439 const target = redmineStatusFor(change.status, maps.status, lists.statuses, current?.statusId)
440 if (target === undefined) {
441 failed.push('status')
442 warnings.push(t.statusNotSent(taskId, change.status))
443 } else if (String(target) !== String(current?.statusId)) {
444 const open = await jiraCall($, base, 'GET', jira.urls.transitions(base, key))
445 const transition = open.ok ? jira.readTransitions(open.text).find(one => one.to === String(target)) : undefined
446 if (!transition) {
447 failed.push('status')
448 warnings.push(t.noTransition(taskId, change.status))
449 } else {
450 const moved = await jiraCall($, base, 'POST', jira.urls.transitions(base, key), { transition: { id: transition.id } })
451 if (moved.ok) isSent = true
452 else {
453 failed.push('status')
454 warnings.push(t.refused(key, moved.status === 0 ? errorLine(moved.text, '?') : jira.readError(moved.status, moved.text)))
455 }
456 }
457 }
458 }
459 return { isSent, failed, warnings }
460}
461
462/** One sync with Jira; answers what went wrong, if it could not run. */
463async function syncJira($: Engine, settings: SyncSettings, base: string, t: Strings): Promise<string | undefined> {
464 const project = jira.projectKey(settings.project)
465 const found = await jiraCall($, base, 'GET', jira.urls.project(base, project))
466 if (!found.ok) return jiraProblem(t, found, project)
467 const types = jira.readIssueTypes(found.text) ?? []
468 const statusAnswer = await jiraCall($, base, 'GET', jira.urls.statuses(base, project))
469 const remoteStatuses = statusAnswer.ok ? jira.readStatuses(statusAnswer.text) : undefined
470 if (!remoteStatuses) return statusAnswer.ok ? t.unexpected('statuses') : jiraProblem(t, statusAnswer, project)
471 const priorityAnswer = await jiraCall($, base, 'GET', jira.urls.priorities(base))
472 const remotePriorities = (priorityAnswer.ok ? jira.readPriorities(priorityAnswer.text) : undefined) ?? []
473 const issues: jira.JiraIssue[] = []
474 const query = jira.jql(project, settings.onlyMine)
475 for (let page: jira.Page | undefined = {}; page; ) {
476 const answer = await jiraCall($, base, 'GET', jira.urls.search(base, query, page))
477 const read = answer.ok ? jira.readSearch(answer.text) : undefined
478 if (!read) return answer.ok ? t.unexpected('search') : jiraProblem(t, answer, project)
479 issues.push(...read.issues)
480 page = read.next
481 }
482
483 const order = await read($, statuses)
484 const levels = await read($, priorities)
485 const statusMap = { ...defaultStatusMap(remoteStatuses, order), ...settings.statusMap }
486 const priorityMap = { ...defaultPriorityMap(remotePriorities, levels), ...settings.priorityMap }
487
488 const data = (await read($, sync)).data
489 const remote = issues.map(issue => jira.toRemoteIssue(base, issue, statusMap, priorityMap, order[0] ?? ''))
490 const local = await readLocal($, data, new Set(remote.map(item => item.url)))
491 if (typeof local === 'string') return local || t.listFailed
492 const planned = plan({
493 remote,
494 local,
495 links: data.links,
496 conflicts: data.conflicts,
497 mode: settings.mode,
498 excluded: settings.excluded,
499 createsRemote: true,
500 ownsRef: ref => jira.issueKeyOf(base, ref) !== undefined,
501 })
502
503 const myself = await jiraCall($, base, 'GET', jira.urls.myself(base))
504 const me = myself.ok ? jira.readMyself(myself.text) : undefined
505 const meta = Object.fromEntries(
506 issues.map(issue => [issue.key, { statusId: issue.statusId, priorityId: issue.priorityId, assigneeId: issue.assigneeId }]),
507 )
508 const maps = { status: statusMap, priority: priorityMap }
509 const lists = { statuses: remoteStatuses, priorities: remotePriorities }
510 const links = { ...planned.links }
511 const warnings: string[] = []
512 let fromRemote = 0
513 let toRemote = 0
514 for (const action of planned.actions) {
515 if (action.kind === 'create-local' || action.kind === 'update-local') {
516 if (await applyLocal($, action, links, data, warnings, t)) fromRemote++
517 } else if (action.kind === 'update-remote') {
518 const old = data.links[action.key]?.base
519 const link = links[action.key]
520 const pushed = await pushJira($, base, action.key, action.taskId, action.change, maps, lists, meta[action.key], t)
521 warnings.push(...pushed.warnings)
522 // What did not go keeps the old base, so it is tried again next time.
523 if (old && link) for (const field of pushed.failed) link.base = { ...link.base, [field]: old[field as keyof typeof old] }
524 if (pushed.isSent) toRemote++
525 } else {
526 if (settings.onlyMine && me === undefined) {
527 warnings.push(t.noMe(action.taskId))
528 continue
529 }
530 const type = jira.issueTypeFor(types, settings.issueType)
531 const run = await backlog($, args.view(action.taskId))
532 const detail = run.ok ? readTaskView(safeParse(run.stdout)) : undefined
533 const fields = { ...action.fields, description: detail?.description ?? '' }
534 const body = jira.fieldsBody({ title: fields.title, description: fields.description, priority: fields.priority }, priorityMap, remotePriorities)
535 const answer = await jiraCall($, base, 'POST', jira.urls.create(base), {
536 fields: {
537 project: { key: project },
538 ...(type ? { issuetype: { id: type.id } } : {}),
539 ...body.fields,
540 ...(settings.onlyMine && me !== undefined ? { assignee: jira.assigneeField(base, me) } : {}),
541 },
542 })
543 const key = answer.ok ? jira.readCreated(answer.text) : undefined
544 if (key === undefined) {
545 warnings.push(t.refused(action.taskId, answer.status === 0 ? errorLine(answer.text, '?') : jira.readError(answer.status, answer.text)))
546 continue
547 }
548 await backlog($, args.addRef(action.taskId, jira.browseUrl(base, key)))
549 // A new issue starts in its workflow's first status: move it on to the task's.
550 const created = await jiraCall($, base, 'GET', jira.urls.issueStatus(base, key))
551 const statusId = created.ok ? jira.readIssueStatus(created.text) : undefined
552 const moved = await pushJira($, base, key, action.taskId, { status: fields.status }, maps, lists, { statusId }, t)
553 warnings.push(...moved.warnings)
554 links[key] = { taskId: action.taskId, base: moved.failed.includes('status') && statusId ? { ...fields, status: statusMap[statusId] ?? fields.status } : fields }
555 meta[key] = { statusId: statusId ?? '', priorityId: null, assigneeId: settings.onlyMine ? (me ?? null) : null }
556 toRemote++
557 }
558 }
559
560 const now = new Date(await $.clock.now()).toISOString()
561 await saveSync($, current => ({
562 settings: current.settings ? { ...current.settings, statusMap, priorityMap } : current.settings,
563 data: {
564 links,
565 conflicts: planned.conflicts,
566 meta,
567 me: me === undefined ? data.me : [me],
568 issueTypes: types,
569 statuses: remoteStatuses,
570 priorities: remotePriorities,
571 lastSync: now,
572 warnings,
573 },
574 }))
575 await update($, notice, () => t.syncDone({ fromRemote, toRemote, conflicts: planned.conflicts.length }))
576 return undefined
577}
578
579/** The CSV's path as $.fs takes it: `~` is the home folder. */
580async function filePath($: Engine, file: string): Promise<string> {
581 const path = file.trim().replace(/^"(.*)"$/, '$1')
582 if (!/^~[\\/]/.test(path)) return path
583 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
584 return home ? `${home}${path.slice(1)}` : path
585}
586
587/**
588 * One read of a CSV Redmine exported: read only, a field changed on both
589 * sides is a conflict. Answers what went wrong, if it could not run.
590 */
591async function syncCsv($: Engine, settings: SyncSettings, base: string | undefined, t: Strings): Promise<string | undefined> {
592 const path = await filePath($, settings.file)
593 let bytes: Uint8Array
594 try {
595 const { base64 } = (await $.fs.read(path, { as: 'bytes' })) as { base64: string }
596 bytes = fromBase64(base64)
597 } catch (error) {
598 return t.fileUnreadable(path, errorLine(String(error), '?'))
599 }
600 const file = readExport(decodeText(bytes))
601 if (!('rows' in file)) return t.csvColumns(file.headers.filter(Boolean).slice(0, 12).join(', '))
602
603 const remoteStatuses = statusesOf(file.rows)
604 const remotePriorities = prioritiesOf(file.rows)
605 const order = await read($, statuses)
606 const levels = await read($, priorities)
607 const statusMap = { ...defaultStatusMap(remoteStatuses, order), ...settings.statusMap }
608 const priorityMap = { ...defaultPriorityMap(remotePriorities, levels), ...settings.priorityMap }
609
610 const data = (await read($, sync)).data
611 const remote = file.rows.map(row => toRemoteRow(base, row, file.skip, statusMap, priorityMap, order[0] ?? ''))
612 const local = await readLocal($, data, new Set(remote.map(item => item.url)))
613 if (typeof local === 'string') return local || t.listFailed
614 const planned = plan({
615 remote,
616 local,
617 links: data.links,
618 conflicts: data.conflicts,
619 mode: 'read',
620 excluded: [],
621 createsRemote: false,
622 askOnClash: true,
623 })
624
625 const links = { ...planned.links }
626 const warnings = file.skip.length > 0 ? [t.csvMissing(file.skip.map(field => t.fieldNames[field] ?? field).join(', '))] : []
627 let fromRemote = 0
628 for (const action of planned.actions) {
629 if (action.kind !== 'create-local' && action.kind !== 'update-local') continue
630 if (await applyLocal($, action, links, data, warnings, t)) fromRemote++
631 }
632
633 const now = new Date(await $.clock.now()).toISOString()
634 await saveSync($, current => ({
635 settings: current.settings ? { ...current.settings, statusMap, priorityMap } : current.settings,
636 data: {
637 links,
638 conflicts: planned.conflicts,
639 meta: {},
640 statuses: remoteStatuses,
641 priorities: remotePriorities,
642 lastSync: now,
643 warnings,
644 },
645 }))
646 await update($, notice, () => t.csvDone(file.rows.length, fromRemote, planned.conflicts.length))
647 return undefined
648}
649
650/** One sync with Redmine; answers what went wrong, if it could not run. */
651async function syncOnce($: Engine, settings: SyncSettings, base: string, t: Strings): Promise<string | undefined> {
652 const project = settings.project.trim()
653 const found = await redmineCall($, 'GET', redmineUrls.project(base, project))
654 if (!found.ok) return problemOf(t, found, project)
655 const statusAnswer = await redmineCall($, 'GET', redmineUrls.statuses(base))
656 const remoteStatuses = statusAnswer.ok ? readStatuses(statusAnswer.text) : undefined
657 if (!remoteStatuses) return statusAnswer.ok ? t.unexpected('issue_statuses') : problemOf(t, statusAnswer, project)
658 const priorityAnswer = await redmineCall($, 'GET', redmineUrls.priorities(base))
659 const remotePriorities = (priorityAnswer.ok ? readPriorities(priorityAnswer.text) : undefined) ?? []
660 const issues = []
661 for (let offset = 0; ; offset += PAGE) {
662 const answer = await redmineCall($, 'GET', redmineUrls.issues(base, project, offset, settings.onlyMine))
663 const page = answer.ok ? readIssues(answer.text) : undefined
664 if (!page) return answer.ok ? t.unexpected('issues') : problemOf(t, answer, project)
665 issues.push(...page.issues)
666 if (page.issues.length < PAGE || issues.length >= page.total) break
667 }
668
669 // The maps: the person's choices over the defaults for what is new.
670 const order = await read($, statuses)
671 const levels = await read($, priorities)
672 const statusMap = { ...defaultStatusMap(remoteStatuses, order), ...settings.statusMap }
673 const priorityMap = { ...defaultPriorityMap(remotePriorities, levels), ...settings.priorityMap }
674
675 const data = (await read($, sync)).data
676 const local = await readLocal($, data, new Set(issues.map(issue => issueUrl(base, issue.id))))
677 if (typeof local === 'string') return local || t.listFailed
678
679 const fallback = order[0] ?? ''
680 const remote = issues.map(issue => toRemoteItem(base, issue, statusMap, priorityMap, fallback))
681 const planned = plan({
682 remote,
683 local,
684 links: data.links,
685 conflicts: data.conflicts,
686 mode: settings.mode,
687 excluded: settings.excluded,
688 createsRemote: true,
689 ownsRef: ref => issueIdOf(base, ref) !== undefined,
690 })
691
692 // Who the key's owner is, for the "only mine" view; and, syncing only
693 // theirs, a task sent to Redmine is assigned to them, or it would fall
694 // out of the list on the next sync.
695 const userAnswer = await redmineCall($, 'GET', redmineUrls.currentUser(base))
696 const mine = userAnswer.ok ? readCurrentUser(userAnswer.text) : undefined
697 const me = mine?.[0]
698
699 const meta = Object.fromEntries(
700 issues.map(issue => [String(issue.id), { statusId: issue.statusId, priorityId: issue.priorityId, assigneeId: issue.assigneeId }]),
701 )
702 const maps = { status: statusMap, priority: priorityMap }
703 const lists = { statuses: remoteStatuses, priorities: remotePriorities }
704 const links = { ...planned.links }
705 const warnings: string[] = []
706 let fromRemote = 0
707 let toRemote = 0
708 for (const action of planned.actions) {
709 if (action.kind === 'create-local' || action.kind === 'update-local') {
710 if (await applyLocal($, action, links, data, warnings, t)) fromRemote++
711 } else if (action.kind === 'update-remote') {
712 const body = issueBody(action.change, maps, lists, meta[action.key])
713 const old = data.links[action.key]?.base
714 const link = links[action.key]
715 for (const field of body.skipped) {
716 const value = field === 'status' ? action.change.status : action.change.priority
717 warnings.push(field === 'status' ? t.statusNotSent(action.taskId, value ?? '') : t.priorityNotSent(action.taskId, value ?? ''))
718 if (old && link) link.base = { ...link.base, [field]: old[field as 'status' | 'priority'] }
719 }
720 if (Object.keys(body.issue).length === 0) continue
721 const answer = await redmineCall($, 'PUT', redmineUrls.issue(base, action.key), { issue: body.issue })
722 if (answer.ok) toRemote++
723 else {
724 if (old && link) link.base = old
725 warnings.push(t.refused(action.key, answer.status === 0 ? errorLine(answer.text, '?') : readError(answer.status, answer.text)))
726 }
727 } else {
728 if (settings.onlyMine && me === undefined) {
729 warnings.push(t.noMe(action.taskId))
730 continue
731 }
732 const run = await backlog($, args.view(action.taskId))
733 const detail = run.ok ? readTaskView(safeParse(run.stdout)) : undefined
734 const fields = { ...action.fields, description: detail?.description ?? '' }
735 const body = issueBody(fields, maps, lists)
736 const answer = await redmineCall($, 'POST', redmineUrls.create(base), {
737 issue: { project_id: project, ...body.issue, ...(settings.onlyMine && me !== undefined ? { assigned_to_id: me } : {}) },
738 })
739 const id = answer.ok ? readCreated(answer.text) : undefined
740 if (id === undefined) {
741 warnings.push(t.refused(action.taskId, answer.status === 0 ? errorLine(answer.text, '?') : readError(answer.status, answer.text)))
742 continue
743 }
744 await backlog($, args.addRef(action.taskId, issueUrl(base, id)))
745 links[String(id)] = { taskId: action.taskId, base: fields }
746 toRemote++
747 }
748 }
749
750 const now = new Date(await $.clock.now()).toISOString()
751 await saveSync($, current => ({
752 settings: current.settings ? { ...current.settings, statusMap, priorityMap } : current.settings,
753 data: {
754 links,
755 conflicts: planned.conflicts,
756 meta,
757 statuses: remoteStatuses,
758 priorities: remotePriorities,
759 me: mine ?? data.me,
760 lastSync: now,
761 warnings,
762 },
763 }))
764 await update($, notice, () => t.syncDone({ fromRemote, toRemote, conflicts: planned.conflicts.length }))
765 return undefined
766}
767
768/** Starts a sync in the background; it ends with the module like the watch. */
769function startSync($: Engine) {
770 runSync($).catch(() => {
771 // Aborted by an unload or a reload.
772 })
773}
774
775/** The automatic sync, every `intervalMin` minutes while the pane is open. */
776async function startSyncTimer($: Engine) {
777 stopSyncTimer()
778 const settings = (await read($, sync)).settings
779 if (!settings || settings.intervalMin <= 0) return
780 syncTimer = $.clock.every(settings.intervalMin * 60_000, () => startSync($))
781}
782
783function stopSyncTimer() {
784 syncTimer?.cancel()
785 syncTimer = null
786}
787
788/** Settles one conflict: the side picked gives its value to the other. */
789async function resolveConflict($: Engine, conflict: SyncConflict, side: 'remote' | 'local') {
790 const view = await read($, sync)
791 const settings = view.settings
792 const t = wordsFor(await text($), settings?.provider)
793 const base = settings ? normalizeUrl(settings.url) : undefined
794 const isCsv = settings?.provider === 'csv'
795 if (!settings || (!base && !isCsv)) return
796 const value = side === 'remote' ? conflict.remote : conflict.local
797 if (side === 'remote') {
798 const run = await backlog($, args.editFields(conflict.taskId, { [conflict.field]: value ?? '' }))
799 if (!run.ok) {
800 await update($, notice, () => t.localFailed(conflict.taskId, errorLine(run.stderr, '?')))
801 return
802 }
803 } else if (settings.provider === 'jira' && base) {
804 const pushed = await pushJira(
805 $,
806 base,
807 conflict.key,
808 conflict.taskId,
809 { [conflict.field]: value ?? '' },
810 { status: settings.statusMap, priority: settings.priorityMap },
811 { statuses: view.data.statuses, priorities: view.data.priorities },
812 view.data.meta[conflict.key],
813 t,
814 )
815 if (pushed.failed.length > 0) {
816 await update($, notice, () => pushed.warnings[0] ?? null)
817 return
818 }
819 } else if (!isCsv && base) {
820 const body = issueBody(
821 { [conflict.field]: value ?? '' },
822 { status: settings.statusMap, priority: settings.priorityMap },
823 { statuses: view.data.statuses, priorities: view.data.priorities },
824 view.data.meta[conflict.key],
825 )
826 if (body.skipped.length > 0) {
827 await update($, notice, () =>
828 conflict.field === 'status' ? t.statusNotSent(conflict.taskId, value ?? '') : t.priorityNotSent(conflict.taskId, value ?? ''),
829 )
830 return
831 }
832 const answer = await redmineCall($, 'PUT', redmineUrls.issue(base, conflict.key), { issue: body.issue })
833 if (!answer.ok) {
834 await update($, notice, () => t.refused(conflict.key, answer.status === 0 ? errorLine(answer.text, '?') : readError(answer.status, answer.text)))
835 return
836 }
837 }
838 // The file is read only: either way its value is the one agreed, so keeping
839 // Backlog.md's counts as a local change, kept until the file changes again.
840 const agreed = isCsv ? conflict.remote : value
841 await saveSync($, now => {
842 const link = now.data.links[conflict.key]
843 const links = link ? { ...now.data.links, [conflict.key]: { ...link, base: { ...link.base, [conflict.field]: agreed ?? '' } } } : now.data.links
844 const conflicts = now.data.conflicts.filter(one => !(one.key === conflict.key && one.field === conflict.field))
845 return { settings: now.settings, data: { ...now.data, links, conflicts } }
846 })
847 await update($, notice, () => null)
848}
849
850/**
851 * Picks the tracker. Redmine and Jira start read only, every 5 minutes, the
852 * tasks there now kept out; a CSV is read only, read with the button.
853 * Switching keeps no mappings (Redmine's go by id, a CSV's by name), and
854 * keeps the links only between Redmine and its CSV, which both name issues
855 * by number; the tasks there now are kept out of the new tracker.
856 */
857async function setTracker($: Engine, value: string) {
858 if (value !== 'redmine' && value !== 'jira' && value !== 'csv') {
859 stopSyncTimer()
860 const t = await text($)
861 await saveSync($, now => ({ settings: null, data: now.data }))
862 await update($, notice, () => t.disconnected)
863 return
864 }
865 const existing = (await read($, tasks)).map(task => task.id)
866 await saveSync($, now => {
867 if (now.settings?.provider === value) return { settings: now.settings, data: now.data }
868 const isCsv = value === 'csv'
869 return {
870 settings: {
871 provider: value,
872 url: now.settings?.url ?? '',
873 project: now.settings?.project ?? '',
874 file: now.settings?.file ?? '',
875 mode: 'read',
876 onlyMine: false,
877 view: 'all',
878 intervalMin: isCsv ? 0 : 5,
879 statusMap: {},
880 priorityMap: {},
881 excluded: existing,
882 },
883 data: {
884 ...now.data,
885 links: (now.settings?.provider === 'jira') === (value === 'jira') ? now.data.links : {},
886 conflicts: [],
887 meta: {},
888 me: undefined,
889 statuses: [],
890 priorities: [],
891 warnings: [],
892 lastError: undefined,
893 },
894 }
895 })
896 // A CSV has no timer; Redmine's starts with the pane's watch, or now if it runs.
897 if (active) await startSyncTimer($)
898}
899
900async function setSyncSettings($: Engine, change: Partial<SyncSettings>) {
901 const t = await text($)
902 const isCsv = (await read($, sync)).settings?.provider === 'csv'
903 if (change.url !== undefined && !normalizeUrl(change.url) && !(isCsv && change.url.trim() === '')) {
904 await update($, notice, () => t.badUrl)
905 return
906 }
907 await saveSync($, now => ({
908 settings: now.settings ? { ...now.settings, ...change, url: change.url !== undefined ? (normalizeUrl(change.url) ?? '') : now.settings.url } : null,
909 data: now.data,
910 }))
911 await update($, notice, () => null)
912 if (change.intervalMin !== undefined && active) await startSyncTimer($)
913}
914
915async function openSync($: Engine) {
916 await update($, isManagingSync, () => true)
917 await update($, isManagingStatuses, () => false)
918 await update($, isCreating, () => false)
919 await update($, notice, () => null)
920 await loadSync($)
921}
922
923/** The project's closed statuses: its own, else the closedStatuses option's. */
924const closedOf = (kept: Prefs) => kept.closed ?? closedOption
925
926async function savePrefs($: Engine, change: (kept: Prefs) => Prefs) {
927 const next = await update($, prefs, change)
928 const key = await read($, prefsKey)
929 if (!key) return
930 try {
931 await $.store.set(key, next)
932 } catch (error) {
933 await update($, notice, () => String(error))
934 }
935}
936
937/** Gives a status a colour of the palette, or `auto` for the one it would have. */
938async function setStatusColor($: Engine, status: string, color: string) {
939 await savePrefs($, kept => {
940 const colors = { ...kept.colors }
941 if (isPaletteColor(color)) colors[status] = color
942 else delete colors[status]
943 return { ...kept, colors }
944 })
945}
946
947async function toggleClosed($: Engine, status: string) {
948 await savePrefs($, kept => {
949 const now = closedOf(kept)
950 const isClosed = now.some(one => one.toLowerCase() === status.toLowerCase())
951 const next = isClosed ? now.filter(one => one.toLowerCase() !== status.toLowerCase()) : [...now, status]
952 return { ...kept, closed: next }
953 })
954}
955
956async function loadDetail($: Engine, id: string) {
957 const run = await backlog($, args.view(id))
958 if ((await read($, selectedId)) !== id) return
959 if (!run.ok) {
960 const t = await text($)
961 await update($, notice, () => errorLine(run.stderr, t.couldNotRead(id)))
962 return
963 }
964 const task = readTaskView(safeParse(run.stdout))
965 await update($, detail, () => task ?? null)
966}
967
968async function receive($: Engine, list: TaskSummary[]) {
969 await update($, tasks, () => list)
970 await update($, phase, () => ({ kind: 'ready' }) as Phase)
971 // The first list of this watch: sync once now, then on the timer.
972 const kept = (await read($, sync)).settings
973 if (!isSyncArmed && kept && kept.provider !== 'csv') {
974 isSyncArmed = true
975 startSync($)
976 await startSyncTimer($)
977 }
978 const id = await read($, selectedId)
979 if (!id) return
980 const shown = await read($, detail)
981 const now = list.find(task => task.id === id)
982 if (now && (!shown || shown.updatedAt !== now.updatedAt || shown.status !== now.status)) {
983 await loadDetail($, id)
984 }
985}
986
987async function watch($: Engine) {
988 if (active) return
989 const run = { isStopped: false, stop: () => {} }
990 active = run
991 try {
992 if ((await read($, phase)).kind !== 'ready') await update($, phase, () => ({ kind: 'loading' }) as Phase)
993 let failures = 0
994 while (!run.isStopped) {
995 const exe = await findBin($)
996 if (!exe) {
997 await update($, phase, () => ({ kind: 'not-installed' }) as Phase)
998 return
999 }
1000 await loadStatuses($)
1001 let stderr = ''
1002 let isListed = false
1003 const reader = new JsonDocumentReader()
1004 try {
1005 const stream = $.process.spawn({ argv: [exe, ...args.watch()] })
1006 run.stop = () => void stream.return(undefined as never)
1007 for await (const chunk of stream) {
1008 if (run.isStopped) break
1009 if (chunk.stream === 'stderr') {
1010 stderr = (stderr + chunk.text).slice(-4000)
1011 continue
1012 }
1013 for (const document of reader.push(chunk.text)) {
1014 const list = readTaskList(document)
1015 if (!list) continue
1016 isListed = true
1017 await receive($, list)
1018 }
1019 }
1020 } catch (error) {
1021 stderr ||= String(error)
1022 }
1023 if (run.isStopped) return
1024 if (classify(stderr) === 'not-initialized') {
1025 await update($, phase, () => ({ kind: 'not-initialized', message: errorLine(stderr, '') }) as Phase)
1026 return
1027 }
1028 // A watch that delivered lists and then ended starts the count over.
1029 failures = isListed ? 1 : failures + 1
1030 if (failures >= MAX_ATTEMPTS) {
1031 const message = errorLine(stderr, (await text($)).watchStopping)
1032 await update($, phase, () => ({ kind: 'failed', message }) as Phase)
1033 return
1034 }
1035 await $.clock.sleep(1000 * 2 ** (failures - 1))
1036 }
1037 } finally {
1038 if (active === run) active = null
1039 }
1040}
1041
1042/** Starts the watch in the background; it ends with the pane or the module. */
1043function startWatch($: Engine) {
1044 watch($).catch(() => {
1045 // Aborted by an unload or a reload: the next load starts it again.
1046 })
1047}
1048
1049function stopWatch() {
1050 stopSyncTimer()
1051 isSyncArmed = false
1052 if (!active) return
1053 active.isStopped = true
1054 active.stop()
1055 active = null
1056}
1057
1058async function retry($: Engine) {
1059 stopWatch()
1060 bin = null
1061 await update($, notice, () => null)
1062 await update($, phase, () => ({ kind: 'loading' }) as Phase)
1063 startWatch($)
1064}
1065
1066async function select($: Engine, id: string | null) {
1067 await update($, selectedId, () => id)
1068 await update($, detail, () => null)
1069 await update($, notice, () => null)
1070 await update($, editing, () => null)
1071 if (id) await loadDetail($, id)
1072}
1073
1074/**
1075 * Runs `task edit` commands for `id` one after another, stopping at the
1076 * first that fails; then says how it went and reads the task again.
1077 */
1078async function edit($: Engine, id: string, commands: string[][]): Promise<boolean> {
1079 const t = await text($)
1080 for (const argv of commands) {
1081 const run = await backlog($, argv)
1082 if (!run.ok) {
1083 await update($, notice, () => errorLine(run.stderr, t.couldNotUpdate(id)))
1084 return false
1085 }
1086 }
1087 await update($, notice, () => t.updated(id))
1088 await update($, editing, () => null)
1089 if ((await read($, selectedId)) === id) await loadDetail($, id)
1090 return true
1091}
1092
1093async function rename($: Engine, id: string, title: string) {
1094 const name = title.trim()
1095 const shown = (await read($, detail))?.title
1096 if (!name || name === shown) return
1097 if (await edit($, id, [args.setTitle(id, name)])) {
1098 await update($, tasks, list => list.map(task => (task.id === id ? { ...task, title: name } : task)))
1099 }
1100}
1101
1102async function setPriority($: Engine, id: string, priority: string) {
1103 const shown = (await read($, detail))?.priority
1104 if (!priority || priority.toLowerCase() === shown?.toLowerCase()) return
1105 if (await edit($, id, [args.setPriority(id, priority)])) {
1106 await update($, tasks, list => list.map(task => (task.id === id ? { ...task, priority } : task)))
1107 }
1108}
1109
1110/** Saves a description or plan typed in the inline field. */
1111async function saveLong($: Engine, id: string, field: LongField, value: string) {
1112 const task = await read($, detail)
1113 const before = (field === 'description' ? task?.description : task?.implementationPlan) ?? ''
1114 if (value.trimEnd() === before.trimEnd()) {
1115 const t = await text($)
1116 await update($, notice, () => t.noChange)
1117 await update($, editing, () => null)
1118 return
1119 }
1120 await edit($, id, [field === 'description' ? args.setDescription(id, value) : args.setPlan(id, value)])
1121}
1122
1123async function findEditor($: Engine): Promise<string[] | undefined> {
1124 const run = await backlog($, args.defaultEditor())
1125 return pickEditor({
1126 configured: run.ok ? run.stdout : undefined,
1127 visual: await $.env.get('VISUAL'),
1128 editor: await $.env.get('EDITOR'),
1129 os: await $.env.get('OS'),
1130 })
1131}
1132
1133/**
1134 * Hands a description or plan to the person's own editor: writes it to a
1135 * temp file, runs the editor on it and waits for it to exit (so the editor
1136 * must block, as `code --wait` does), then reads it back and saves it.
1137 */
1138async function openInEditor($: Engine, id: string, field: LongField) {
1139 const t = await text($)
1140 const editor = await findEditor($)
1141 if (!editor) {
1142 await update($, notice, () => t.noEditor)
1143 return
1144 }
1145 const task = await read($, detail)
1146 const before = (field === 'description' ? task?.description : task?.implementationPlan) ?? ''
1147 const path = editorFile(
1148 {
1149 tmpdir: await $.env.get('TMPDIR'),
1150 temp: await $.env.get('TEMP'),
1151 tmp: await $.env.get('TMP'),
1152 userProfile: await $.env.get('USERPROFILE'),
1153 os: await $.env.get('OS'),
1154 },
1155 id,
1156 field,
1157 )
1158 let after: string
1159 try {
1160 await $.fs.write(path, before)
1161 await update($, notice, () => t.editorWaiting)
1162 const done = await $.process.run([...editor, path], { timeoutMs: EDITOR_TIMEOUT_MS })
1163 if (done.exitCode !== 0) {
1164 await update($, notice, () => t.editorFailed(errorLine(done.stderr, `exit code ${done.exitCode}`)))
1165 return
1166 }
1167 after = (await $.fs.read(path)).replace(/\r\n/g, '\n')
1168 } catch (error) {
1169 await update($, notice, () => t.editorFailed(String(error)))
1170 return
1171 }
1172 if (after.trimEnd() === before.trimEnd()) {
1173 await update($, notice, () => t.noChange)
1174 return
1175 }
1176 const value = after.replace(/\n+$/, '')
1177 await edit($, id, [field === 'description' ? args.setDescription(id, value) : args.setPlan(id, value)])
1178}
1179
1180async function toggleCriterion($: Engine, id: string, index: number, isChecked: boolean) {
1181 await edit($, id, [isChecked ? args.uncheckCriterion(id, index) : args.checkCriterion(id, index)])
1182}
1183
1184async function addCriterion($: Engine, id: string, value: string) {
1185 const criterion = value.trim()
1186 if (criterion) await edit($, id, [args.addCriterion(id, criterion)])
1187}
1188
1189async function removeCriterion($: Engine, id: string, index: number) {
1190 await edit($, id, [args.removeCriterion(id, index)])
1191}
1192
1193/**
1194 * Edits one criterion by writing the whole list again, in order: the CLI
1195 * keeps the ticks of the texts that did not change, so a ticked criterion
1196 * whose text changed is ticked again by a second command.
1197 */
1198async function rewriteCriterion($: Engine, id: string, index: number, value: string) {
1199 const criteria = (await read($, detail))?.acceptanceCriteria ?? []
1200 const texts = rewriteCriteria(criteria, index, value)hooks/cli.ts 539 lines1// Pure helpers: the backlog command lines, the reader for its JSON stream,
2// and the grouping the pane draws. Nothing here touches `$`, so the tests
3// can cover it directly.
4
5import type { ThemeKey } from 'claude-code'
6
7import type { Checklist, Prefs, TaskDetail, TaskSummary } from '../types'
8import type { Strings } from './i18n'
9
10export const DEFAULT_STATUSES = ['To Do', 'In Progress', 'Done']
11export const DEFAULT_PRIORITIES = ['high', 'medium', 'low']
12
13export const args = {
14 version: () => ['--version'],
15 statuses: () => ['config', 'get', 'statuses'],
16 list: () => ['task', 'list', '--json'],
17 watch: () => ['task', 'list', '--json', '--watch'],
18 view: (id: string) => ['task', 'view', id, '--json'],
19 setStatus: (id: string, status: string) => ['task', 'edit', id, '--status', status],
20 create: (title: string) => ['task', 'create', title],
21 priorities: () => ['config', 'get', 'priorities'],
22 defaultEditor: () => ['config', 'get', 'defaultEditor'],
23 defaultStatus: () => ['config', 'get', 'defaultStatus'],
24 /** A task made from a tracker's issue, linked to it by its URL in the references. */
25 createFrom: (fields: { title: string; status: string; priority: string | null; description: string }, ref: string) => [
26 'task',
27 'create',
28 fields.title,
29 '-s',
30 fields.status,
31 ...(fields.priority ? ['--priority', fields.priority] : []),
32 ...(fields.description.trim() ? ['-d', fields.description] : []),
33 '--ref',
34 ref,
35 ],
36 /** Several fields of a task in one edit; a priority cannot be cleared, so a null one is left out. */
37 editFields: (id: string, change: { title?: string; status?: string; priority?: string | null; description?: string }) => [
38 'task',
39 'edit',
40 id,
41 ...(change.title !== undefined ? ['-t', change.title] : []),
42 ...(change.status !== undefined ? ['-s', change.status] : []),
43 ...(change.priority ? ['--priority', change.priority] : []),
44 ...(change.description !== undefined ? ['-d', change.description] : []),
45 ],
46 addRef: (id: string, ref: string) => ['task', 'edit', id, '--add-ref', ref],
47 setDescription: (id: string, text: string) => ['task', 'edit', id, '-d', text],
48 setPlan: (id: string, text: string) => ['task', 'edit', id, '--plan', text],
49 setTitle: (id: string, title: string) => ['task', 'edit', id, '-t', title],
50 setPriority: (id: string, priority: string) => ['task', 'edit', id, '--priority', priority],
51 checkCriterion: (id: string, index: number) => ['task', 'edit', id, '--check-ac', String(index)],
52 uncheckCriterion: (id: string, index: number) => ['task', 'edit', id, '--uncheck-ac', String(index)],
53 addCriterion: (id: string, text: string) => ['task', 'edit', id, '--ac', text],
54 removeCriterion: (id: string, index: number) => ['task', 'edit', id, '--remove-ac', String(index)],
55 /** Replaces every criterion, in order; a text left unchanged keeps its tick. */
56 setCriteria: (id: string, texts: string[]) => [
57 'task',
58 'edit',
59 id,
60 ...texts.flatMap(text => ['--acceptance-criteria', text]),
61 ],
62}
63
64/**
65 * The executables to try, in order: the configured path alone when one is
66 * set; else `backlog` on PATH, then (on Windows, where a process started
67 * without a shell cannot run npm's `backlog.cmd` shim) the native binaries
68 * npm and bun install globally.
69 */
70export function candidates(env: {
71 configured?: string
72 os?: string
73 appData?: string
74 userProfile?: string
75}): string[] {
76 const configured = env.configured?.trim()
77 if (configured) return [configured]
78 const found = ['backlog']
79 if (env.os !== 'Windows_NT') return found
80 if (env.appData) {
81 const modules = `${env.appData}\\npm\\node_modules`
82 for (const arch of ['x64', 'arm64']) {
83 found.push(`${modules}\\backlog.md\\node_modules\\backlog.md-windows-${arch}\\backlog.exe`)
84 found.push(`${modules}\\backlog.md-windows-${arch}\\backlog.exe`)
85 }
86 }
87 if (env.userProfile) found.push(`${env.userProfile}\\.bun\\bin\\backlog.exe`)
88 found.push('backlog.exe')
89 return found
90}
91
92/** Whether a version (`2.1.301`, `2.1.301-dev.…`) is at least `minimum`. */
93export function isAtLeast(version: string | undefined, minimum: string): boolean {
94 const parse = (text: string | undefined) => /^(\d+)\.(\d+)\.(\d+)/.exec(text?.trim() ?? '')?.slice(1).map(Number)
95 const have = parse(version)
96 const need = parse(minimum)
97 if (!have || !need) return false
98 for (let i = 0; i < 3; i++) {
99 const a = have[i] ?? 0
100 const b = need[i] ?? 0
101 if (a !== b) return a > b
102 }
103 return true
104}
105
106/**
107 * Splits an editor command as a shell would split its words, quotes kept
108 * together: `code --wait`, `"C:\Program Files\Editor\editor.exe" -w`.
109 */
110export function splitCommand(command: string): string[] {
111 const words: string[] = []
112 let word = ''
113 let quote: string | null = null
114 let isWord = false
115 for (const c of command.trim()) {
116 if (quote) {
117 if (c === quote) quote = null
118 else word += c
119 } else if (c === '"' || c === "'") {
120 quote = c
121 isWord = true
122 } else if (/\s/.test(c)) {
123 if (isWord) words.push(word)
124 word = ''
125 isWord = false
126 } else {
127 word += c
128 isWord = true
129 }
130 }
131 if (isWord) words.push(word)
132 return words
133}
134
135/**
136 * The editor to run, as an argument vector: Backlog.md's `defaultEditor`,
137 * else VISUAL, else EDITOR, else Notepad on Windows; undefined when none.
138 */
139export function pickEditor(env: { configured?: string; visual?: string; editor?: string; os?: string }): string[] | undefined {
140 for (const value of [env.configured, env.visual, env.editor]) {
141 const text = value?.trim() ?? ''
142 if (text === '' || text === 'undefined' || text === 'null') continue
143 const argv = splitCommand(text)
144 if (argv.length > 0) return argv
145 }
146 return env.os === 'Windows_NT' ? ['notepad'] : undefined
147}
148
149/** Where the text goes while the editor has it: the temp folder. */
150export function editorFile(env: { tmpdir?: string; temp?: string; tmp?: string; userProfile?: string; os?: string }, id: string, field: string): string {
151 const isWindows = env.os === 'Windows_NT'
152 const fallback = isWindows ? (env.userProfile ? `${env.userProfile}\\AppData\\Local\\Temp` : 'C:\\Windows\\Temp') : '/tmp'
153 const folder = (env.tmpdir || env.temp || env.tmp || fallback).replace(/[\\/]+$/, '')
154 const name = `backlog-pane-${id.replace(/[^\w.-]/g, '_')}-${field}.md`
155 return `${folder}${isWindows ? '\\' : '/'}${name}`
156}
157
158/** Why a backlog command failed, from its stderr. */
159export function classify(stderr: string): 'not-initialized' | 'failed' {
160 return /no backlog(\.md)? project found|backlog init/i.test(stderr) ? 'not-initialized' : 'failed'
161}
162
163/** The first meaningful line of a command's error output. */
164export function errorLine(stderr: string, fallback: string): string {
165 const line = stderr
166 .split(/\r?\n/)
167 .map(one => one.trim())
168 .find(one => one !== '' && !/^warning:/i.test(one))
169 return line ?? fallback
170}
171
172/**
173 * A list `backlog config get` prints: `To Do, In Progress, Done`, or with
174 * brackets and quotes around it; `fallback` when it prints none.
175 */
176export function parseList(stdout: string, fallback: string[]): string[] {
177 const list = stdout
178 .trim()
179 .replace(/^\[|\]$/g, '')
180 .split(',')
181 .map(one => one.trim().replace(/^["']|["']$/g, '').trim())
182 .filter(one => one !== '' && one !== 'undefined' && one !== 'null')
183 return list.length > 0 ? list : fallback
184}
185
186/** `backlog config get statuses` prints `To Do, In Progress, Done`. */
187export const parseStatuses = (stdout: string) => parseList(stdout, DEFAULT_STATUSES)
188
189/** `backlog config get priorities`; High, Medium, Low when it prints none. */
190export const parsePriorities = (stdout: string) => parseList(stdout, DEFAULT_PRIORITIES)
191
192/**
193 * The criteria's texts with the one at `index` (1-based, as the CLI counts)
194 * replaced, for `args.setCriteria`; undefined when nothing would change.
195 */
196export function rewriteCriteria(criteria: Checklist[], index: number, text: string): string[] | undefined {
197 const next = text.trim()
198 const at = criteria.findIndex(item => item.index === index)
199 if (at === -1 || next === '' || criteria[at]?.text === next) return undefined
200 return criteria.map((item, i) => (i === at ? next : item.text))
201}
202
203/** The id `backlog task create` printed (`Created task TASK-3`). */
204export function createdId(stdout: string): string | undefined {
205 return /Created task (\S+)/.exec(stdout)?.[1]
206}
207
208/**
209 * Splits a stream of JSON documents written back to back (pretty-printed,
210 * so a document spans many lines and a chunk may end anywhere) into whole
211 * documents. Braces inside strings and escaped quotes are respected.
212 */
213export class JsonDocumentReader {
214 private buffer = ''
215 private depth = 0
216 private inString = false
217 private escaped = false
218 private start = -1
219
220 push(text: string): unknown[] {
221 const documents: unknown[] = []
222 let i = this.buffer.length
223 this.buffer += text
224 for (; i < this.buffer.length; i++) {
225 const c = this.buffer[i]
226 if (this.inString) {
227 if (this.escaped) this.escaped = false
228 else if (c === '\\') this.escaped = true
229 else if (c === '"') this.inString = false
230 continue
231 }
232 if (c === '"') this.inString = true
233 else if (c === '{' || c === '[') {
234 if (this.depth === 0) this.start = i
235 this.depth++
236 } else if ((c === '}' || c === ']') && this.depth > 0) {
237 this.depth--
238 if (this.depth === 0) {
239 const raw = this.buffer.slice(this.start, i + 1)
240 try {
241 documents.push(JSON.parse(raw))
242 } catch {
243 // A malformed document is dropped; the next one replaces it anyway.
244 }
245 this.buffer = this.buffer.slice(i + 1)
246 i = -1
247 this.start = -1
248 }
249 }
250 }
251 if (this.depth === 0) this.buffer = ''
252 return documents
253 }
254}
255
256const text = (value: unknown): string | null => (typeof value === 'string' ? value : null)
257const count = (value: unknown): number => (typeof value === 'number' ? value : 0)
258
259function toSummary(raw: Record<string, unknown>): TaskSummary {
260 return {
261 id: String(raw.id),
262 title: text(raw.title) ?? '',
263 status: text(raw.status) ?? '',
264 priority: text(raw.priority),
265 labels: Array.isArray(raw.labels) ? raw.labels.map(String) : [],
266 references: Array.isArray(raw.references) ? raw.references.map(String) : [],
267 acceptanceCriteriaCompleted: count(raw.acceptanceCriteriaCompleted),
268 acceptanceCriteriaCount: count(raw.acceptanceCriteriaCount),
269 createdAt: text(raw.createdAt),
270 updatedAt: text(raw.updatedAt),
271 }
272}
273
274export const isRecord = (value: unknown): value is Record<string, unknown> =>
275 typeof value === 'object' && value !== null && !Array.isArray(value)
276
277/** The tasks of a `task-list` document, or undefined for any other. */
278export function readTaskList(document: unknown): TaskSummary[] | undefined {
279 if (!isRecord(document) || document.kind !== 'task-list' || !Array.isArray(document.tasks)) {
280 return undefined
281 }
282 return document.tasks.filter(isRecord).filter(task => task.id !== undefined).map(toSummary)
283}
284
285/** The task of a `task-view` document, or undefined for any other. */
286export function readTaskView(document: unknown): TaskDetail | undefined {
287 if (!isRecord(document) || document.kind !== 'task-view' || !isRecord(document.task)) {
288 return undefined
289 }
290 const task = document.task
291 const criteria: Checklist[] = Array.isArray(task.acceptanceCriteria)
292 ? task.acceptanceCriteria.filter(isRecord).map(item => ({
293 index: count(item.index),
294 text: text(item.text) ?? '',
295 checked: item.checked === true,
296 }))
297 : []
298 return {
299 ...toSummary(task),
300 description: text(task.description),
301 acceptanceCriteria: criteria,
302 implementationPlan: text(task.implementationPlan),
303 }
304}
305
306export type Group = {
307 status: string
308 tasks: TaskSummary[]
309 total: number
310 isFinal: boolean
311 /** Listed in the `closedStatuses` option: shown dim and folded, as the final one. */
312 isClosed: boolean
313 color: ThemeKey
314}
315
316const recency = (task: TaskSummary) => task.updatedAt ?? task.createdAt ?? ''
317
318/** The `closedStatuses` option: status names separated by commas. */
319export function parseClosed(option: unknown): string[] {
320 if (typeof option !== 'string') return []
321 return option
322 .split(',')
323 .map(one => one.trim())
324 .filter(one => one !== '')
325}
326
327/** Whether `status` is one of `closed`, whatever the case. */
328export const isClosedStatus = (status: string, closed: readonly string[]) =>
329 closed.some(one => one.toLowerCase() === status.trim().toLowerCase())
330
331/**
332 * Groups tasks by status in the configured order, statuses the config does
333 * not list after it. The final status (the config's last) and the closed
334 * ones keep only their `doneLimit` most recently changed tasks; `total`
335 * still counts them all.
336 */
337export function groupTasks(
338 tasks: TaskSummary[],
339 statuses: string[],
340 doneLimit: number,
341 closed: readonly string[] = [],
342 chosen: Readonly<Record<string, string>> = {},
343): Group[] {
344 const order = statuses.length > 0 ? statuses : DEFAULT_STATUSES
345 const final = order[order.length - 1]
346 const names = [...order]
347 for (const task of tasks) if (!names.includes(task.status)) names.push(task.status)
348 const colors = statusColors(names, final ?? '', closed, chosen)
349 return names.map(status => {
350 let list = tasks.filter(task => task.status === status)
351 const total = list.length
352 const isFinal = status === final
353 const isClosed = !isFinal && isClosedStatus(status, closed)
354 if (isFinal || isClosed) {
355 list = [...list].sort((a, b) => recency(b).localeCompare(recency(a))).slice(0, Math.max(0, doneLimit))
356 }
357 return { status, tasks: list, total, isFinal, isClosed, color: colors.get(status) ?? 'text' }
358 })
359}
360
361// planMode first: permission draws much like suggestion (the first status's).
362const ROTATION: ThemeKey[] = ['planMode', 'merged', 'ide', 'permission']
363
364/** The theme colours a status can be given in the statuses view, in the order offered. */
365export const PALETTE: ThemeKey[] = [
366 'suggestion',
367 'ide',
368 'planMode',
369 'success',
370 'warning',
371 'claude',
372 'error',
373 'merged',
374 'permission',
375 'inactive',
376]
377
378export const isPaletteColor = (value: unknown): value is ThemeKey =>
379 typeof value === 'string' && (PALETTE as string[]).includes(value)
380
381/**
382 * What the pane keeps per project (in `$.store`, as Backlog.md's config has
383 * no place for it), as stored, keeping only what is well formed: a colour
384 * of the palette chosen for a status, and which statuses are closed.
385 */
386export function readPrefs(value: unknown): Prefs {
387 const raw = isRecord(value) ? value : {}
388 const colors: Record<string, string> = {}
389 if (isRecord(raw.colors)) {
390 for (const [status, color] of Object.entries(raw.colors)) if (isPaletteColor(color)) colors[status] = color
391 }
392 const closed = Array.isArray(raw.closed) ? raw.closed.filter(one => typeof one === 'string') : undefined
393 return closed ? { colors, closed } : { colors }
394}
395const IN_PROGRESS = /in[ -]?progress|en (curso|progreso)|doing|wip/i
396
397/**
398 * Each status's colour, by theme key so it follows the person's light or
399 * dark theme: the one chosen for it in `chosen` if any; else the final one
400 * `success`, closed ones `inactive`, one named
401 * like "In Progress" `warning`, the first of the rest `suggestion`, and the
402 * others in turn from ROTATION. `names` is every status shown, in order;
403 * `final` the configured last.
404 */
405export function statusColors(
406 names: string[],
407 final: string,
408 closed: readonly string[] = [],
409 chosen: Readonly<Record<string, string>> = {},
410): Map<string, ThemeKey> {
411 const colors = new Map<string, ThemeKey>()
412 let turn = 0
413 let isFirstOpen = true
414 for (const name of names) {
415 const picked = chosen[name]
416 if (isPaletteColor(picked)) colors.set(name, picked)
417 else if (name === final) colors.set(name, 'success')
418 else if (isClosedStatus(name, closed)) colors.set(name, 'inactive')
419 else if (IN_PROGRESS.test(name)) colors.set(name, 'warning')
420 else if (isFirstOpen) colors.set(name, 'suggestion')
421 else colors.set(name, ROTATION[turn++ % ROTATION.length] ?? 'permission')
422 if (name !== final && !isClosedStatus(name, closed)) isFirstOpen = false
423 }
424 return colors
425}
426
427/**
428 * The statuses ◀ and ▶ lead to from `status`: the nearest ones before and
429 * after it in `order` that are not closed. A closed status is never one,
430 * but a task in a closed status still moves to the open ones beside it.
431 */
432export function neighbours(
433 order: string[],
434 status: string,
435 closed: readonly string[] = [],
436): { previous?: string; next?: string } {
437 const i = order.indexOf(status)
438 if (i === -1) return {}
439 const final = order[order.length - 1]
440 const isOpen = (name: string) => name === final || !isClosedStatus(name, closed)
441 const previous = [...order.slice(0, i)].reverse().find(isOpen)
442 const next = order.slice(i + 1).find(isOpen)
443 return { previous, next }
444}
445
446/**
447 * Where Backlog.md keeps its config, as it looks for it: in each folder
448 * from `cwd` up, `backlog.config.yml`, then `backlog/` or `.backlog/`
449 * holding `config.yml` (or `.yaml`). Nearest first, `levels` folders up at
450 * most; absolute when `cwd` is (POSIX or Windows), else relative.
451 */
452export function configCandidates(cwd = '', levels = 12): string[] {
453 const names = [
454 'backlog.config.yml',
455 'backlog.config.yaml',
456 'backlog/config.yml',
457 'backlog/config.yaml',
458 '.backlog/config.yml',
459 '.backlog/config.yaml',
460 ]
461 const sep = /^[A-Za-z]:\\|\\/.test(cwd) && !cwd.includes('/') ? '\\' : '/'
462 const root = cwd.startsWith('/') ? '/' : ''
463 const parts = cwd.split(/[\\/]+/).filter(part => part !== '')
464 const folders: string[] = []
465 if (parts.length === 0 && root === '') {
466 for (let up = 0; up <= levels; up++) folders.push('../'.repeat(up))
467 } else {
468 for (let n = parts.length; n >= 0 && folders.length <= levels; n--) {
469 const folder = root + parts.slice(0, n).join(sep)
470 // A Windows drive alone (`C:`) is its root, `C:\\`.
471 if (folder === '' && root === '') break
472 folders.push(folder === root ? root : `${folder}${sep}`)
473 }
474 }
475 return folders.flatMap(folder => names.map(name => `${folder}${sep === '/' ? name : name.replace(/\//g, sep)}`))
476}
477
478/**
479 * The config's text with its `statuses` replaced by `statuses`, as one
480 * flow list (`statuses: ["To Do", "Done"]`, the form `backlog init`
481 * writes); a block list (`statuses:` then `- To Do` lines) is replaced the
482 * same way. Every other line is kept as it was, line endings included.
483 * Undefined when the text has no top-level `statuses` key, or has several.
484 */
485export function rewriteStatuses(config: string, statuses: string[]): string | undefined {
486 const eol = config.includes('\r\n') ? '\r\n' : '\n'
487 const lines = config.split(/\r?\n/)
488 const at = lines.flatMap((line, i) => (/^statuses\s*:/.test(line) ? [i] : []))
489 if (at.length !== 1) return undefined
490 const start = at[0] ?? 0
491 const rest = (lines[start] ?? '').replace(/^statuses\s*:/, '').trim()
492 let end = start + 1
493 if (rest === '' || rest.startsWith('#')) {
494 // A block list: the indented `- item` lines (and blank lines) under it.
495 while (end < lines.length && /^(\s+-\s|\s*$)/.test(lines[end] ?? '')) end++
496 while (end > start + 1 && (lines[end - 1] ?? '').trim() === '') end--
497 } else if (!rest.startsWith('[')) {
498 return undefined
499 }
500 const line = `statuses: [${statuses.map(one => JSON.stringify(one)).join(', ')}]`
501 return [...lines.slice(0, start), line, ...lines.slice(end)].join(eol)
502}
503
504export type StatusProblem = 'empty' | 'comma' | 'duplicate' | 'too-long'
505
506/**
507 * Why `name` cannot be a new status, if it cannot: empty, holding a comma
508 * or a quote (`backlog config get statuses` joins them with commas), the
509 * same as one there (whatever the case), or longer than 40 characters.
510 */
511export function statusProblem(name: string, statuses: readonly string[]): StatusProblem | undefined {
512 const trimmed = name.trim()
513 if (trimmed === '') return 'empty'
514 if (/[,"\n\r]/.test(trimmed)) return 'comma'
515 if (trimmed.length > 40) return 'too-long'
516 if (statuses.some(one => one.toLowerCase() === trimmed.toLowerCase())) return 'duplicate'
517 return undefined
518}
519
520/** The same lists, compared name by name. */
521export const sameStatuses = (a: readonly string[], b: readonly string[]) =>
522 a.length === b.length && a.every((one, i) => one === b[i])
523
524/** Whether a task's priority is high, which the pane marks with "!". */
525export const isHighPriority = (priority: string | null | undefined) => priority?.trim().toLowerCase() === 'high'
526
527/** The board as plain text, for surfaces that draw no pane. */
528export function summarize(tasks: TaskSummary[], statuses: string[], t: Strings): string {
529 if (tasks.length === 0) return t.summaryEmpty
530 const lines = [t.summaryCount(tasks.length)]
531 for (const group of groupTasks(tasks, statuses, 5)) {
532 if (group.total === 0) continue
533 lines.push('', `${group.status} (${group.total})`)
534 for (const task of group.tasks) lines.push(` ${task.id} ${task.title}`)
535 if (group.total > group.tasks.length) lines.push(` ${t.summaryMore(group.total - group.tasks.length)}`)
536 }
537 return lines.join('\n')
538}
539hooks/i18n.ts 569 lines1// Every string the mod shows, in English and Spanish, and how the language
2// is chosen. Nothing here touches `$`: register.tsx reads the sources and
3// hands them to `detectLanguage`.
4
5export type Language = 'en' | 'es'
6
7/** What the `language` option holds: a language, or `auto` to detect one. */
8export type LanguageOption = 'auto' | Language
9
10const plural = (n: number, one: string, many: string) => (n === 1 ? one : many)
11
12
13/** What Jira changes in the integration's words, over Redmine's. */
14const enJira = {
15 redmineUrl: 'Jira URL',
16 urlPlaceholder: 'https://your-company.atlassian.net',
17 redmineProject: 'Project key',
18 projectPlaceholder: 'as in its issue keys, e.g. APP',
19 badProject: 'Type the project’s key, as in its issue keys (APP-12 → APP).',
20 modeRead: 'Read only (Jira → Backlog.md)',
21 apiKeySet: 'API token: set.',
22 apiKeyMissing:
23 'API token: missing. Set “Jira API token” in the plugin’s settings (/plugin, or /config), and for Jira Cloud also “Jira email”: Claude Code keeps the token in secure storage.',
24 statusMapping: 'Jira status → Backlog.md status',
25 priorityMapping: 'Jira priority → Backlog.md priority',
26 excludedNote: (n: number) =>
27 `${n} ${plural(n, 'task', 'tasks')} that existed before the sync ${plural(n, 'is', 'are')} not sent to Jira.`,
28 inRedmine: 'Jira',
29 useRemote: 'Use Jira’s',
30 syncDone: (n: { fromRemote: number; toRemote: number; conflicts: number }) =>
31 `Jira: ${n.fromRemote} from Jira, ${n.toRemote} to Jira, ${n.conflicts} ${plural(n.conflicts, 'conflict', 'conflicts')}.`,
32 unauthorized: 'Jira refused the email or the API token (401). Check them in the plugin’s settings.',
33 forbidden: 'Jira says this token may not do that (403).',
34 noProject: (project: string) => `Jira has no project “${project}” (404), or this token cannot see it.`,
35 unreachable: (detail: string) => `Could not reach Jira: ${detail}`,
36 unexpected: (detail: string) => `Jira answered something unexpected: ${detail}`,
37 statusNotSent: (task: string, status: string) => `${task}: no Jira status maps to “${status}”, so its status was not sent.`,
38 priorityNotSent: (task: string, priority: string) =>
39 `${task}: no Jira priority maps to “${priority}”, so its priority was not sent.`,
40 noMe: (task: string) => `${task} was not sent: Jira did not say who owns the API token, to assign it to.`,
41 refused: (issue: string, why: string) => `Jira refused the change to ${issue}: ${why}`,
42 syncNote:
43 'Issues are copied into Backlog.md as tasks (with the issue’s URL in their references) and kept in step field by field: title, status, priority and description. Read only never writes to Jira. Nothing is ever deleted on either side.',
44}
45
46const en = {
47 paneTitle: 'Backlog',
48 commandDescription: 'Open the Backlog.md board in a side pane',
49 opened: 'Backlog pane opened.',
50 waitingForRoom: (reason: string) => `Backlog pane is waiting for room (${reason}).`,
51 cliMissingText: 'Backlog.md CLI not found. Install it with `npm i -g backlog.md`.',
52 noProjectText: 'No Backlog.md project here. Run `backlog init` first.',
53 listFailed: 'backlog task list failed.',
54 cliNotFound: 'The backlog CLI was not found.',
55 watchStopping: 'backlog task list --watch kept stopping.',
56
57 summaryEmpty: 'Backlog: no tasks yet. Create one with `backlog task create "Title"`.',
58 summaryCount: (n: number) => `Backlog: ${n} ${plural(n, 'task', 'tasks')}`,
59 summaryMore: (n: number) => `… ${n} more`,
60
61 notInstalledTitle: 'Backlog.md CLI not found',
62 notInstalledHelp:
63 'Install it with `npm i -g backlog.md` (or `bun add -g backlog.md`, `brew install backlog-md`), then press Retry. You can also set the full path in the plugin option **backlogPath**.',
64 notInitializedTitle: 'No Backlog.md project in this folder',
65 notInitializedHelp: 'Run `backlog init` in the project root, then press Retry.',
66 failedTitle: 'The live task list stopped',
67 retry: 'Retry',
68 loadingTasks: 'Loading tasks…',
69 loadingDetails: 'Loading details…',
70 noTasks: 'No tasks yet.',
71 mostRecent: (shown: number, total: number) => `The ${shown} most recent of ${total}.`,
72 boardHint: 'Drag a card to another column, or click it and press ←/→ to move it, ↑/↓ to pick another, Enter to open it.',
73 highPriority: 'high priority',
74
75 newTask: '+ New task',
76 newTaskPlaceholder: 'New task title',
77 create: 'Create',
78 cancel: 'Cancel',
79 created: (id: string) => `Created ${id}.`,
80 theTask: 'the task',
81 couldNotCreate: 'Could not create the task.',
82
83 back: '← Back',
84 title: 'Title',
85 save: 'Save',
86 status: 'Status',
87 priority: 'Priority',
88 noPriority: 'None',
89 priorityName: { high: 'High', medium: 'Medium', low: 'Low' } as Record<string, string>,
90 moved: (id: string, status: string) => `Moved ${id} to ${status}.`,
91 couldNotMove: (id: string) => `Could not move ${id}.`,
92 couldNotRead: (id: string) => `Could not read ${id}.`,
93 updated: (id: string) => `Updated ${id}.`,
94 couldNotUpdate: (id: string) => `Could not update ${id}.`,
95 noChange: 'Nothing changed.',
96
97 work: 'Work on this task',
98 editWithClaude: 'Edit with Claude',
99 workPrompt: (id: string) =>
100 `Work on ${id} only. Research the codebase and write an implementation plan in the task. Wait for my approval before coding.`,
101 editPrompt: (id: string) => `Update ${id}: `,
102 draftReady: (id: string) => `Draft for ${id} is in the prompt box. Review it, then press Enter to send.`,
103 editReady: (id: string) => `"Update ${id}" is in the prompt box. Say what to change, then press Enter.`,
104 draftRejected: (text: string) => `The prompt box did not take the draft. Copy it instead: ${text}`,
105
106 description: 'Description',
107 plan: 'Implementation plan',
108 criteria: 'Acceptance criteria',
109 none: 'None.',
110 edit: 'Edit',
111 remove: 'Delete',
112 confirmRemove: (index: number) => `Delete criterion ${index}?`,
113 keep: 'Keep',
114 newCriterion: 'New criterion',
115 add: 'Add',
116 multilineHint: 'Enter saves. Shift+Enter (or \\ then Enter) starts a new line.',
117 openInEditor: 'Open in editor',
118 editorHint:
119 'Opens the text in your editor (`backlog config get defaultEditor`, else VISUAL or EDITOR). The editor must stay open until you close the file, for example `code --wait`.',
120 editorWaiting: 'Waiting for the editor. Save and close the file to apply the change.',
121 noEditor: 'No editor is set. Run `backlog config set defaultEditor "code --wait"`, or set VISUAL or EDITOR.',
122 editorFailed: (detail: string) => `The editor did not finish: ${detail}`,
123
124 manageStatuses: 'Statuses…',
125 statusesTitle: 'Statuses',
126 taskCount: (n: number) => (n === 0 ? 'no tasks' : `${n} ${plural(n, 'task', 'tasks')}`),
127 finalTag: 'final',
128 closedTag: 'closed',
129 defaultTag: 'new tasks start here',
130 newStatus: 'New status name',
131 position: 'Position',
132 atStart: 'At the start',
133 after: (status: string) => `After ${status}`,
134 statusAdded: (status: string) => `Added the status ${status}.`,
135 statusRemoved: (status: string) => `Deleted the status ${status}.`,
136 confirmRemoveStatus: (status: string) => `Delete the status ${status}?`,
137 statusProblem: {
138 empty: 'Type a name for the status.',
139 comma: 'A status name cannot hold a comma, a quote or a line break.',
140 duplicate: 'There is already a status with that name.',
141 'too-long': 'A status name can be 40 characters at most.',
142 } as Record<string, string>,
143 cannotRemoveDefault: (status: string) =>
144 `${status} is the status new tasks start in. Change it first with \`backlog config set defaultStatus <status>\`.`,
145 cannotRemoveLast: 'A project needs at least two statuses.',
146 configNotFound: 'Could not find the Backlog.md config (backlog/config.yml).',
147 configUnreadable: 'The config has no statuses line the pane can rewrite. Edit it by hand.',
148 configMismatch: 'Backlog.md did not read the new statuses, so the config was put back as it was.',
149 statusesNote:
150 'Backlog.md has no command to change statuses, so the pane rewrites only the statuses line of its config and then checks that Backlog.md reads it. Task counts leave out drafts and archived tasks.',
151 closedHint: 'A closed status is folded and dim, and ◀/▶ and the board’s arrow keys skip it. Colours and closed statuses are kept for this project only.',
152 colour: 'Colour',
153 closedCheck: 'Closed',
154 autoColour: 'Automatic',
155 colourNames: {
156 suggestion: 'Blue',
157 ide: 'Steel blue',
158 planMode: 'Teal',
159 success: 'Green',
160 warning: 'Amber',
161 claude: 'Orange',
162 error: 'Red',
163 merged: 'Purple',
164 permission: 'Lavender',
165 inactive: 'Grey',
166 } as Record<string, string>,
167
168 integration: 'Integration…',
169 integrationTitle: 'Integration',
170 tracker: 'Tracker',
171 noTracker: 'None',
172 trackerRedmine: 'Redmine (API)',
173 trackerCsv: 'Excel / CSV (Redmine export)',
174 csvFile: 'CSV file',
175 csvPlaceholder: 'C:\\Users\\you\\Downloads\\issues.csv',
176 csvUrl: 'Redmine URL (optional, to link the tasks)',
177 csvHow:
178 'In Redmine: Issues, filter them (for example Assignee: “me”), then CSV at the bottom, tick Description, and Export. Save it over the same file each time, then press Sync now.',
179 csvNote:
180 'The file’s issues are copied into Backlog.md as tasks and kept in step field by field: title, status, priority and description. Nothing is ever written to the file or to Redmine, and nothing is deleted. A field changed both in the file and in Backlog.md is a conflict you settle.',
181 badFile: 'Type the path of the CSV file Redmine exported.',
182 fileUnreadable: (path: string, why: string) => `Could not read ${path}: ${why}`,
183 csvColumns: (headers: string) =>
184 `This is not an issue export from Redmine: it has no “#” and “Subject” columns${headers ? ` (it has: ${headers})` : ''}.`,
185 csvMissing: (fields: string) => `The file has no column for: ${fields}. Those are left alone.`,
186 csvDone: (rows: number, changed: number, conflicts: number) =>
187 `CSV: ${rows} ${plural(rows, 'issue', 'issues')} read, ${changed} ${plural(changed, 'task', 'tasks')} created or updated, ${conflicts} ${plural(conflicts, 'conflict', 'conflicts')}.`,
188 inFile: 'The file',
189 useFile: 'Use the file’s',
190 redmineUrl: 'Redmine URL',
191 urlPlaceholder: 'https://redmine.example.com',
192 redmineProject: 'Project identifier',
193 projectPlaceholder: 'as in the project’s URL, e.g. my-app',
194 mode: 'Mode',
195 modeRead: 'Read only (Redmine → Backlog.md)',
196 modeBoth: 'Both ways',
197 scope: 'Issues',
198 scopeAll: 'All of the project',
199 scopeMine: 'Only those assigned to me',
200 mineShort: 'only mine',
201 viewAll: 'View: all',
202 viewMine: (n: number) => `View: only mine${n > 0 ? ` (${n} hidden)` : ''}`,
203 allHidden: 'No task of yours here. Press “View: only mine” to see them all.',
204 every: 'Sync',
205 manualOnly: 'Only with the button',
206 everyMinutes: (n: number) => `Every ${n} min`,
207 apiKeySet: 'API key: set.',
208 apiKeyMissing:
209 'API key: missing. Set “Redmine API key” in the plugin’s settings (/plugin, or /config): Claude Code keeps it in secure storage.',
210 syncNow: 'Sync now',
211 syncing: 'Syncing…',
212 disconnect: 'Disconnect',
213 disconnected: 'Disconnected. The tasks stay in Backlog.md; nothing was deleted.',
214 lastSync: (time: string) => `Last sync: ${time}`,
215 notSynced: 'Not synced yet.',
216 statusMapping: 'Redmine status → Backlog.md status',
217 priorityMapping: 'Redmine priority → Backlog.md priority',
218 leaveAlone: '— (left alone)',
219 mappingLater: 'The status and priority mappings show after the first sync.',
220 excludedNote: (n: number) =>
221 `${n} ${plural(n, 'task', 'tasks')} that existed before the sync ${plural(n, 'is', 'are')} not sent to Redmine.`,
222 includeOld: 'Send them too',
223 conflictsTitle: 'Conflicts',
224 conflictLine: (task: string, field: string) => `${task}: the ${field} changed on both sides.`,
225 inRedmine: 'Redmine',
226 inBacklog: 'Backlog.md',
227 useRemote: 'Use Redmine’s',
228 useLocal: 'Use Backlog.md’s',
229 fieldNames: { title: 'title', status: 'status', priority: 'priority', description: 'description' } as Record<
230 string,
231 string
232 >,
233 empty: '(empty)',
234 syncDone: (n: { fromRemote: number; toRemote: number; conflicts: number }) =>
235 `Redmine: ${n.fromRemote} from Redmine, ${n.toRemote} to Redmine, ${n.conflicts} ${plural(n.conflicts, 'conflict', 'conflicts')}.`,
236 syncLine: (tracker: string, mode: string, time: string | null, conflicts: number) =>
237 `${tracker} · ${mode}${time ? ` · ${time}` : ''}${conflicts > 0 ? ` · ${conflicts} ${plural(conflicts, 'conflict', 'conflicts')}` : ''}`,
238 readOnlyShort: 'read only',
239 bothShort: 'both ways',
240 badUrl: 'Type the server’s URL, starting with https:// (or http://).',
241 badProject: 'Type the project’s identifier, as in its URL.',
242 unauthorized: 'Redmine refused the API key (401). Check it in the plugin’s settings.',
243 forbidden: 'Redmine says this key may not do that (403).',
244 noProject: (project: string) => `Redmine has no project “${project}” (404), or this key cannot see it.`,
245 unreachable: (detail: string) => `Could not reach Redmine: ${detail}`,
246 unexpected: (detail: string) => `Redmine answered something unexpected: ${detail}`,
247 statusNotSent: (task: string, status: string) => `${task}: no Redmine status maps to “${status}”, so its status was not sent.`,
248 priorityNotSent: (task: string, priority: string) =>
249 `${task}: no Redmine priority maps to “${priority}”, so its priority was not sent.`,
250 noMe: (task: string) => `${task} was not sent: Redmine did not say who owns the API key, to assign it to.`,
251 refused: (issue: string, why: string) => `Redmine refused the change to #${issue}: ${why}`,
252 localFailed: (task: string, why: string) => `${task} could not be updated: ${why}`,
253 syncNote:
254 'Issues are copied into Backlog.md as tasks (with the issue’s URL in their references) and kept in step field by field: title, status, priority and description. Read only never writes to Redmine. Nothing is ever deleted on either side.',
255 trackerJira: 'Jira (API)',
256 issueType: 'Issue type for new tasks',
257 issueTypeAuto: 'Task, or the first',
258 noTransition: (task: string, status: string) =>
259 `${task}: its Jira workflow has no transition to “${status}” from where the issue is, so its status was not sent.`,
260 emailMissing: 'Jira Cloud needs your account’s email too: set “Jira email” in the plugin’s settings.',
261 jira: enJira,
262}
263
264export type Strings = typeof en
265
266/** The strings for a tracker: Jira's words over Redmine's where they differ. */
267export const wordsFor = (t: Strings, provider: string | undefined): Strings => (provider === 'jira' ? { ...t, ...t.jira } : t)
268
269const es: Strings = {
270 paneTitle: 'Backlog',
271 commandDescription: 'Abre el tablero de Backlog.md en un panel lateral',
272 opened: 'Panel de Backlog abierto.',
273 waitingForRoom: reason => `El panel de Backlog espera a tener sitio (${reason}).`,
274 cliMissingText: 'No se encontró la CLI de Backlog.md. Instálala con `npm i -g backlog.md`.',
275 noProjectText: 'Aquí no hay ningún proyecto de Backlog.md. Ejecuta antes `backlog init`.',
276 listFailed: 'Falló backlog task list.',
277 cliNotFound: 'No se encontró la CLI de backlog.',
278 watchStopping: 'backlog task list --watch se detenía una y otra vez.',
279
280 summaryEmpty: 'Backlog: todavía no hay tareas. Crea una con `backlog task create "Título"`.',
281 summaryCount: n => `Backlog: ${n} ${plural(n, 'tarea', 'tareas')}`,
282 summaryMore: n => `… ${n} más`,
283
284 notInstalledTitle: 'No se encontró la CLI de Backlog.md',
285 notInstalledHelp:
286 'Instálala con `npm i -g backlog.md` (o `bun add -g backlog.md`, `brew install backlog-md`) y pulsa Reintentar. También puedes indicar la ruta completa en la opción **backlogPath** del plugin.',
287 notInitializedTitle: 'No hay ningún proyecto de Backlog.md en esta carpeta',
288 notInitializedHelp: 'Ejecuta `backlog init` en la raíz del proyecto y pulsa Reintentar.',
289 failedTitle: 'La lista de tareas en vivo se detuvo',
290 retry: 'Reintentar',
291 loadingTasks: 'Cargando tareas…',
292 loadingDetails: 'Cargando detalles…',
293 noTasks: 'Todavía no hay tareas.',
294 mostRecent: (shown, total) => `Las ${shown} más recientes de ${total}.`,
295 boardHint:
296 'Arrastra una tarjeta a otra columna, o haz clic en ella y pulsa ←/→ para moverla, ↑/↓ para elegir otra, Enter para abrirla.',
297 highPriority: 'prioridad alta',
298
299 newTask: '+ Nueva tarea',
300 newTaskPlaceholder: 'Título de la nueva tarea',
301 create: 'Crear',
302 cancel: 'Cancelar',
303 created: id => `Creada ${id}.`,
304 theTask: 'la tarea',
305 couldNotCreate: 'No se pudo crear la tarea.',
306
307 back: '← Volver',
308 title: 'Título',
309 save: 'Guardar',
310 status: 'Estado',
311 priority: 'Prioridad',
312 noPriority: 'Ninguna',
313 priorityName: { high: 'Alta', medium: 'Media', low: 'Baja' },
314 moved: (id, status) => `${id} movida a ${status}.`,
315 couldNotMove: id => `No se pudo mover ${id}.`,
316 couldNotRead: id => `No se pudo leer ${id}.`,
317 updated: id => `${id} actualizada.`,
318 couldNotUpdate: id => `No se pudo actualizar ${id}.`,
319 noChange: 'No ha cambiado nada.',
320
321 work: 'Trabajar en esta tarea',
322 editWithClaude: 'Editar con Claude',
323 workPrompt: id =>
324 `Trabaja solo en ${id}. Investiga el código y escribe un plan de implementación en la tarea. Espera mi aprobación antes de programar.`,
325 editPrompt: id => `Actualiza ${id}: `,
326 draftReady: id => `El borrador para ${id} está en el cuadro del prompt. Revísalo y pulsa Enter para enviarlo.`,
327 editReady: id => `"Actualiza ${id}" está en el cuadro del prompt. Di qué cambiar y pulsa Enter.`,
328 draftRejected: text => `El cuadro del prompt no aceptó el borrador. Cópialo: ${text}`,
329
330 description: 'Descripción',
331 plan: 'Plan de implementación',
332 criteria: 'Criterios de aceptación',
333 none: 'Ninguno.',
334 edit: 'Editar',
335 remove: 'Eliminar',
336 confirmRemove: index => `¿Eliminar el criterio ${index}?`,
337 keep: 'Conservar',
338 newCriterion: 'Nuevo criterio',
339 add: 'Añadir',
340 multilineHint: 'Enter guarda. Mayús+Enter (o \\ y luego Enter) empieza una línea nueva.',
341 openInEditor: 'Abrir en el editor',
342 editorHint:
343 'Abre el texto en tu editor (`backlog config get defaultEditor`, si no VISUAL o EDITOR). El editor debe quedarse abierto hasta que cierres el archivo, por ejemplo `code --wait`.',
344 editorWaiting: 'Esperando al editor. Guarda y cierra el archivo para aplicar el cambio.',
345 noEditor: 'No hay ningún editor configurado. Ejecuta `backlog config set defaultEditor "code --wait"`, o define VISUAL o EDITOR.',
346 editorFailed: detail => `El editor no terminó: ${detail}`,
347
348 manageStatuses: 'Estados…',
349 statusesTitle: 'Estados',
350 taskCount: n => (n === 0 ? 'sin tareas' : `${n} ${plural(n, 'tarea', 'tareas')}`),
351 finalTag: 'final',
352 closedTag: 'cerrado',
353 defaultTag: 'las tareas nuevas empiezan aquí',
354 newStatus: 'Nombre del nuevo estado',
355 position: 'Posición',
356 atStart: 'Al principio',
357 after: status => `Después de ${status}`,
358 statusAdded: status => `Estado ${status} añadido.`,
359 statusRemoved: status => `Estado ${status} eliminado.`,
360 confirmRemoveStatus: status => `¿Eliminar el estado ${status}?`,
361 statusProblem: {
362 empty: 'Escribe un nombre para el estado.',
363 comma: 'El nombre de un estado no puede tener comas, comillas ni saltos de línea.',
364 duplicate: 'Ya hay un estado con ese nombre.',
365 'too-long': 'El nombre de un estado puede tener 40 caracteres como máximo.',
366 },
367 cannotRemoveDefault: status =>
368 `${status} es el estado en el que empiezan las tareas nuevas. Cámbialo antes con \`backlog config set defaultStatus <estado>\`.`,
369 cannotRemoveLast: 'Un proyecto necesita al menos dos estados.',
370 configNotFound: 'No se encontró la configuración de Backlog.md (backlog/config.yml).',
371 configUnreadable: 'La configuración no tiene una línea de estados que el panel pueda reescribir. Edítala a mano.',
372 configMismatch: 'Backlog.md no leyó los estados nuevos, así que la configuración se dejó como estaba.',
373 statusesNote:
374 'Backlog.md no tiene un comando para cambiar los estados, así que el panel reescribe solo la línea de estados de su configuración y después comprueba que Backlog.md la lee. Los recuentos no incluyen borradores ni tareas archivadas.',
375 closedHint: 'Un estado cerrado sale plegado y atenuado, y ◀/▶ y las flechas del tablero se lo saltan. Los colores y los estados cerrados se guardan solo para este proyecto.',
376 colour: 'Color',
377 closedCheck: 'Cerrado',
378 autoColour: 'Automático',
379 colourNames: {
380 suggestion: 'Azul',
381 ide: 'Azul acero',
382 planMode: 'Verde azulado',
383 success: 'Verde',
384 warning: 'Ámbar',
385 claude: 'Naranja',
386 error: 'Rojo',
387 merged: 'Morado',
388 permission: 'Lavanda',
389 inactive: 'Gris',
390 },
391
392 integration: 'Integración…',
393 integrationTitle: 'Integración',
394 tracker: 'Gestor',
395 noTracker: 'Ninguno',
396 trackerRedmine: 'Redmine (API)',
397 trackerCsv: 'Excel / CSV (exportación de Redmine)',
398 csvFile: 'Archivo CSV',
399 csvPlaceholder: 'C:\\Users\\tú\\Downloads\\issues.csv',
400 csvUrl: 'URL de Redmine (opcional, para enlazar las tareas)',
401 csvHow:
402 'En Redmine: Peticiones, fíltralas (por ejemplo Asignado a: «yo»), luego CSV abajo, marca Descripción y Exportar. Guárdalo siempre sobre el mismo archivo y pulsa Sincronizar ahora.',
403 csvNote:
404 'Las peticiones del archivo se copian en Backlog.md como tareas y se mantienen al día campo a campo: título, estado, prioridad y descripción. Nunca se escribe nada en el archivo ni en Redmine, y nunca se borra nada. Un campo cambiado en el archivo y en Backlog.md es un conflicto que resuelves tú.',
405 badFile: 'Escribe la ruta del archivo CSV que exportó Redmine.',
406 fileUnreadable: (path, why) => `No se pudo leer ${path}: ${why}`,
407 csvColumns: headers =>
408 `Esto no es una exportación de peticiones de Redmine: no tiene las columnas «#» y «Asunto»${headers ? ` (tiene: ${headers})` : ''}.`,
409 csvMissing: fields => `El archivo no tiene columna para: ${fields}. Eso no se toca.`,
410 csvDone: (rows, changed, conflicts) =>
411 `CSV: ${rows} ${plural(rows, 'petición leída', 'peticiones leídas')}, ${changed} ${plural(changed, 'tarea creada o actualizada', 'tareas creadas o actualizadas')}, ${conflicts} ${plural(conflicts, 'conflicto', 'conflictos')}.`,
412 inFile: 'El archivo',
413 useFile: 'Usar el del archivo',
414 redmineUrl: 'URL de Redmine',
415 urlPlaceholder: 'https://redmine.ejemplo.com',
416 redmineProject: 'Identificador del proyecto',
417 projectPlaceholder: 'como en la URL del proyecto, p. ej. mi-app',
418 mode: 'Modo',
419 modeRead: 'Solo lectura (Redmine → Backlog.md)',
420 modeBoth: 'Bidireccional',
421 scope: 'Peticiones',
422 scopeAll: 'Todas las del proyecto',
423 scopeMine: 'Solo las asignadas a mí',
424 mineShort: 'solo mías',
425 viewAll: 'Ver: todas',
426 viewMine: n => `Ver: solo mías${n > 0 ? ` (${n} ocultas)` : ''}`,
427 allHidden: 'Aquí no hay tareas tuyas. Pulsa «Ver: solo mías» para verlas todas.',
428 every: 'Sincronizar',
429 manualOnly: 'Solo con el botón',
430 everyMinutes: n => `Cada ${n} min`,
431 apiKeySet: 'Clave de API: configurada.',
432 apiKeyMissing:
433 'Clave de API: falta. Ponla en «Redmine API key» en los ajustes del plugin (/plugin, o /config): Claude Code la guarda en el almacén seguro.',
434 syncNow: 'Sincronizar ahora',
435 syncing: 'Sincronizando…',
436 disconnect: 'Desconectar',
437 disconnected: 'Desconectado. Las tareas siguen en Backlog.md; no se ha borrado nada.',
438 lastSync: time => `Última sincronización: ${time}`,
439 notSynced: 'Aún no se ha sincronizado.',
440 statusMapping: 'Estado de Redmine → estado de Backlog.md',
441 priorityMapping: 'Prioridad de Redmine → prioridad de Backlog.md',
442 leaveAlone: '— (sin tocar)',
443 mappingLater: 'La correspondencia de estados y prioridades aparece tras la primera sincronización.',
444 excludedNote: n =>
445 `${n} ${plural(n, 'tarea que ya existía', 'tareas que ya existían')} antes de la sincronización no se ${plural(n, 'envía', 'envían')} a Redmine.`,
446 includeOld: 'Enviarlas también',
447 conflictsTitle: 'Conflictos',
448 conflictLine: (task, field) => `${task}: ${field === 'prioridad' || field === 'descripción' ? 'la' : 'el'} ${field} cambió en los dos lados.`,
449 inRedmine: 'Redmine',
450 inBacklog: 'Backlog.md',
451 useRemote: 'Usar el de Redmine',
452 useLocal: 'Usar el de Backlog.md',
453 fieldNames: { title: 'título', status: 'estado', priority: 'prioridad', description: 'descripción' },
454 empty: '(vacío)',
455 syncDone: n =>
456 `Redmine: ${n.fromRemote} desde Redmine, ${n.toRemote} hacia Redmine, ${n.conflicts} ${plural(n.conflicts, 'conflicto', 'conflictos')}.`,
457 syncLine: (tracker, mode, time, conflicts) =>
458 `${tracker} · ${mode}${time ? ` · ${time}` : ''}${conflicts > 0 ? ` · ${conflicts} ${plural(conflicts, 'conflicto', 'conflictos')}` : ''}`,
459 readOnlyShort: 'solo lectura',
460 bothShort: 'bidireccional',
461 badUrl: 'Escribe la URL del servidor, empezando por https:// (o http://).',
462 badProject: 'Escribe el identificador del proyecto, como aparece en su URL.',
463 unauthorized: 'Redmine rechazó la clave de API (401). Revísala en los ajustes del plugin.',
464 forbidden: 'Redmine dice que esta clave no puede hacer eso (403).',
465 noProject: project => `Redmine no tiene el proyecto «${project}» (404), o esta clave no puede verlo.`,
466 unreachable: detail => `No se pudo conectar con Redmine: ${detail}`,
467 unexpected: detail => `Redmine respondió algo inesperado: ${detail}`,
468 statusNotSent: (task, status) => `${task}: ningún estado de Redmine corresponde a «${status}», así que su estado no se envió.`,
469 priorityNotSent: (task, priority) =>
470 `${task}: ninguna prioridad de Redmine corresponde a «${priority}», así que su prioridad no se envió.`,
471 noMe: task => `${task} no se envió: Redmine no dijo de quién es la clave de API, para asignársela.`,
472 refused: (issue, why) => `Redmine rechazó el cambio en #${issue}: ${why}`,
473 localFailed: (task, why) => `No se pudo actualizar ${task}: ${why}`,
474 syncNote:
475 'Las peticiones se copian en Backlog.md como tareas (con la URL de la petición en sus referencias) y se mantienen al día campo a campo: título, estado, prioridad y descripción. Solo lectura nunca escribe en Redmine. Nunca se borra nada en ningún lado.',
476 trackerJira: 'Jira (API)',
477 issueType: 'Tipo de incidencia para las tareas nuevas',
478 issueTypeAuto: 'Tarea, o la primera',
479 noTransition: (task, status) =>
480 `${task}: el flujo de trabajo de Jira no tiene transición a «${status}» desde donde está la incidencia, así que su estado no se envió.`,
481 emailMissing: 'Jira Cloud también necesita el email de tu cuenta: pon «Jira email» en los ajustes del plugin.',
482 jira: {
483 redmineUrl: 'URL de Jira',
484 urlPlaceholder: 'https://tu-empresa.atlassian.net',
485 redmineProject: 'Clave del proyecto',
486 projectPlaceholder: 'la de sus incidencias, p. ej. APP',
487 badProject: 'Escribe la clave del proyecto, la de sus incidencias (APP-12 → APP).',
488 modeRead: 'Solo lectura (Jira → Backlog.md)',
489 apiKeySet: 'Token de API: configurado.',
490 apiKeyMissing:
491 'Token de API: falta. Pon «Jira API token» en los ajustes del plugin (/plugin, o /config), y para Jira Cloud también «Jira email»: Claude Code guarda el token en el almacén seguro.',
492 statusMapping: 'Estado de Jira → estado de Backlog.md',
493 priorityMapping: 'Prioridad de Jira → prioridad de Backlog.md',
494 excludedNote: n =>
495 `${n} ${plural(n, 'tarea que ya existía', 'tareas que ya existían')} antes de la sincronización no se ${plural(n, 'envía', 'envían')} a Jira.`,
496 inRedmine: 'Jira',
497 useRemote: 'Usar el de Jira',
498 syncDone: n =>
499 `Jira: ${n.fromRemote} desde Jira, ${n.toRemote} a Jira, ${n.conflicts} ${plural(n.conflicts, 'conflicto', 'conflictos')}.`,
500 unauthorized: 'Jira rechazó el email o el token de API (401). Revísalos en los ajustes del plugin.',
501 forbidden: 'Jira dice que este token no puede hacer eso (403).',
502 noProject: project => `Jira no tiene el proyecto «${project}» (404), o este token no puede verlo.`,
503 unreachable: detail => `No se pudo conectar con Jira: ${detail}`,
504 unexpected: detail => `Jira respondió algo inesperado: ${detail}`,
505 statusNotSent: (task, status) => `${task}: ningún estado de Jira corresponde a «${status}», así que su estado no se envió.`,
506 priorityNotSent: (task, priority) =>
507 `${task}: ninguna prioridad de Jira corresponde a «${priority}», así que su prioridad no se envió.`,
508 noMe: task => `${task} no se envió: Jira no dijo de quién es el token de API, para asignársela.`,
509 refused: (issue, why) => `Jira rechazó el cambio en ${issue}: ${why}`,
510 syncNote:
511 'Las incidencias se copian en Backlog.md como tareas (con la URL de la incidencia en sus referencias) y se mantienen al día campo a campo: título, estado, prioridad y descripción. Solo lectura nunca escribe en Jira. Nunca se borra nada en ningún lado.',
512 },
513}
514
515const tables: Record<Language, Strings> = { en, es }
516
517export function strings(language: Language): Strings {
518 return tables[language]
519}
520
521const fold = (text: string) =>
522 text
523 .normalize('NFD')
524 .replace(/[̀-ͯ]/g, '')
525 .trim()
526 .toLowerCase()
527
528/**
529 * Claude Code's `language` setting is free text ("Español", "Spanish",
530 * "English", "es"). Any language other than Spanish is shown in English.
531 */
532export function languageFromSetting(value: unknown): Language | undefined {
533 if (typeof value !== 'string' || value.trim() === '') return undefined
534 const text = fold(value)
535 return /^es\b|^es[-_]|spanish|espanol|castellano/.test(text) ? 'es' : 'en'
536}
537
538/** A POSIX or BCP 47 locale (`es_ES.UTF-8`, `es-MX`); `C` and `POSIX` say nothing. */
539export function languageFromLocale(value: string | undefined): Language | undefined {
540 if (!value) return undefined
541 const code = value.trim().split(/[_.@-]/)[0]?.toLowerCase() ?? ''
542 if (code === '' || code === 'c' || code === 'posix') return undefined
543 return code === 'es' ? 'es' : 'en'
544}
545
546/**
547 * The language to show: the option when it names one; for `auto`, the first
548 * source that says anything, in this order: Claude Code's `language` setting,
549 * `LC_ALL`, `LC_MESSAGES`, `LANG`, the runtime's Intl locale; else English.
550 */
551export function detectLanguage(sources: {
552 option?: unknown
553 setting?: unknown
554 lcAll?: string
555 lcMessages?: string
556 lang?: string
557 intl?: string
558}): Language {
559 if (sources.option === 'en' || sources.option === 'es') return sources.option
560 return (
561 languageFromSetting(sources.setting) ??
562 languageFromLocale(sources.lcAll) ??
563 languageFromLocale(sources.lcMessages) ??
564 languageFromLocale(sources.lang) ??
565 languageFromLocale(sources.intl) ??
566 'en'
567 )
568}
569hooks/jira.ts 271 lines1// Jira, the second tracker with an API: its REST API v2, the same on Jira
2// Cloud and on Jira Data Center / Server, read into the sync core's terms
3// and back. Pure: register.tsx makes the requests with $.http.fetch.
4//
5// v2 rather than v3, because v2 takes and gives the description as text
6// (wiki markup), the way Backlog.md keeps it, where v3 wants a JSON
7// document. The differences between the two kinds of Jira:
8// - Cloud (*.atlassian.net): basic authentication with the account's
9// email and an API token; the search is /search/jql, paged by token;
10// a person is an accountId.
11// - Data Center / Server: a personal access token as a bearer; the
12// search is /search, paged by position; a person is a user name.
13// Statuses do not change by a field: an issue moves along a transition of
14// its workflow, and only to the statuses its workflow reaches from where
15// it is.
16
17import { toBase64 } from './base64'
18import { redminePriorityFor } from './redmine'
19import type { RedminePriority, RedmineStatus } from './redmine'
20import type { Change, Fields, RemoteItem } from './sync'
21
22export type JiraIssue = {
23 key: string
24 subject: string
25 description: string
26 statusId: string
27 priorityId: string | null
28 assigneeId: string | null
29}
30
31const isRecord = (value: unknown): value is Record<string, unknown> =>
32 typeof value === 'object' && value !== null && !Array.isArray(value)
33
34const parse = (text: string): unknown => {
35 try {
36 return JSON.parse(text)
37 } catch {
38 return undefined
39 }
40}
41
42const str = (value: unknown) => (typeof value === 'string' ? value : typeof value === 'number' ? String(value) : undefined)
43
44/** Whether the URL is a Jira Cloud site (which takes an email with the token). */
45export function isCloud(base: string): boolean {
46 try {
47 return /\.(atlassian\.net|jira\.com)$/.test(new URL(base).hostname)
48 } catch {
49 return false
50 }
51}
52
53/** An issue's page, which a task's references hold: `…/browse/APP-12`. */
54export const browseUrl = (base: string, key: string) => `${base}/browse/${key}`
55
56/** The issue key a URL of this site names, if it names one. */
57export function issueKeyOf(base: string, url: string): string | undefined {
58 const prefix = `${base}/browse/`
59 if (!url.trim().startsWith(prefix)) return undefined
60 const key = url.trim().slice(prefix.length)
61 return /^[A-Z][A-Z0-9_]*-\d+$/.test(key) ? key : undefined
62}
63
64/** A project key as Jira spells it: upper case, letters, digits and underscores. */
65export const projectKey = (text: string) => text.trim().toUpperCase()
66
67export const PAGE = 100
68const FIELDS = 'summary,status,priority,description,assignee'
69
70/** Every issue of the project, or only the token owner's, oldest key first. */
71export const jql = (project: string, onlyMine: boolean) =>
72 `project = "${project.replace(/["\\]/g, '')}"${onlyMine ? ' AND assignee = currentUser()' : ''} ORDER BY key ASC`
73
74/** Where the next page starts: Cloud's token, or Data Center's position. */
75export type Page = { token?: string; startAt?: number }
76
77export const urls = {
78 search: (base: string, query: string, page: Page) =>
79 isCloud(base)
80 ? `${base}/rest/api/2/search/jql?jql=${encodeURIComponent(query)}&fields=${FIELDS}&maxResults=${PAGE}${
81 page.token ? `&nextPageToken=${encodeURIComponent(page.token)}` : ''
82 }`
83 : `${base}/rest/api/2/search?jql=${encodeURIComponent(query)}&fields=${FIELDS}&maxResults=${PAGE}&startAt=${page.startAt ?? 0}`,
84 project: (base: string, key: string) => `${base}/rest/api/2/project/${encodeURIComponent(key)}`,
85 statuses: (base: string, key: string) => `${base}/rest/api/2/project/${encodeURIComponent(key)}/statuses`,
86 priorities: (base: string) => (isCloud(base) ? `${base}/rest/api/2/priority/search?maxResults=100` : `${base}/rest/api/2/priority`),
87 myself: (base: string) => `${base}/rest/api/2/myself`,
88 issue: (base: string, key: string) => `${base}/rest/api/2/issue/${encodeURIComponent(key)}`,
89 issueStatus: (base: string, key: string) => `${base}/rest/api/2/issue/${encodeURIComponent(key)}?fields=status`,
90 transitions: (base: string, key: string) => `${base}/rest/api/2/issue/${encodeURIComponent(key)}/transitions`,
91 create: (base: string) => `${base}/rest/api/2/issue`,
92}
93
94/** Cloud: basic with "email:token"; Data Center: the personal access token as a bearer. */
95export function headers(base: string, email: string, token: string, body = false): Record<string, string> {
96 const auth = isCloud(base) ? `Basic ${toBase64(new TextEncoder().encode(`${email}:${token}`))}` : `Bearer ${token}`
97 return {
98 Authorization: auth,
99 Accept: 'application/json',
100 ...(body ? { 'Content-Type': 'application/json' } : {}),
101 }
102}
103
104/** A person's id: Cloud's accountId, else Data Center's user name. */
105const personOf = (value: unknown) => (isRecord(value) ? (str(value.accountId) ?? str(value.name) ?? str(value.key) ?? null) : null)
106
107/** One page of the search, and where the next starts (none: it was the last). */
108export function readSearch(text: string): { issues: JiraIssue[]; next?: Page } | undefined {
109 const doc = parse(text)
110 if (!isRecord(doc) || !Array.isArray(doc.issues)) return undefined
111 const issues = doc.issues.filter(isRecord).flatMap(raw => {
112 const fields = isRecord(raw.fields) ? raw.fields : {}
113 const status = isRecord(fields.status) ? fields.status : {}
114 const priority = isRecord(fields.priority) ? fields.priority : {}
115 const key = str(raw.key)
116 const statusId = str(status.id)
117 if (!key || !statusId) return []
118 return [
119 {
120 key,
121 subject: str(fields.summary) ?? '',
122 description: str(fields.description) ?? '',
123 statusId,
124 priorityId: str(priority.id) ?? null,
125 assigneeId: personOf(fields.assignee),
126 },
127 ]
128 })
129 // Cloud: a token until the last page. Data Center: a position and a total.
130 const token = str(doc.nextPageToken)
131 if (token && doc.isLast !== true) return { issues, next: { token } }
132 const startAt = typeof doc.startAt === 'number' ? doc.startAt : undefined
133 const total = typeof doc.total === 'number' ? doc.total : undefined
134 if (startAt !== undefined && total !== undefined && issues.length > 0 && startAt + issues.length < total) {
135 return { issues, next: { startAt: startAt + issues.length } }
136 }
137 return { issues }
138}
139
140/**
141 * The statuses the project's issue types use, once each. Jira sorts each
142 * into a category: To Do (new), In Progress (indeterminate) or Done.
143 */
144export function readStatuses(text: string): RedmineStatus[] | undefined {
145 const doc = parse(text)
146 if (!Array.isArray(doc)) return undefined
147 const seen = new Map<string, RedmineStatus>()
148 for (const type of doc.filter(isRecord)) {
149 for (const raw of Array.isArray(type.statuses) ? type.statuses.filter(isRecord) : []) {
150 const id = str(raw.id)
151 const name = str(raw.name)
152 if (!id || !name || seen.has(id)) continue
153 const category = isRecord(raw.statusCategory) ? raw.statusCategory.key : undefined
154 seen.set(id, { id, name, isClosed: category === 'done', isActive: category === 'indeterminate' })
155 }
156 }
157 return [...seen.values()]
158}
159
160/** The priorities: a list on Data Center, `{ values }` from Cloud's search. */
161export function readPriorities(text: string): RedminePriority[] | undefined {
162 const doc = parse(text)
163 const list = Array.isArray(doc) ? doc : isRecord(doc) && Array.isArray(doc.values) ? doc.values : undefined
164 if (!list) return undefined
165 return list.filter(isRecord).flatMap(raw => {
166 const id = str(raw.id)
167 const name = str(raw.name)
168 return id && name ? [{ id, name, isDefault: raw.isDefault === true }] : []
169 })
170}
171
172export type IssueType = { id: string; name: string }
173
174/** The project's issue types new tasks can be created as (not sub-tasks). */
175export function readIssueTypes(text: string): IssueType[] | undefined {
176 const doc = parse(text)
177 if (!isRecord(doc) || !Array.isArray(doc.issueTypes)) return undefined
178 return doc.issueTypes.filter(isRecord).flatMap(raw => {
179 const id = str(raw.id)
180 const name = str(raw.name)
181 return id && name && raw.subtask !== true ? [{ id, name }] : []
182 })
183}
184
185/** The issue type for new tasks: the chosen one, else Task (Tarea), else the first. */
186export function issueTypeFor(types: IssueType[], chosen: string | undefined): IssueType | undefined {
187 return (
188 types.find(type => type.id === chosen) ??
189 types.find(type => /^(task|tarea)$/i.test(type.name)) ??
190 types[0]
191 )
192}
193
194export const readMyself = (text: string): string | undefined => personOf(parse(text)) ?? undefined
195
196export const readCreated = (text: string): string | undefined => {
197 const doc = parse(text)
198 return isRecord(doc) ? str(doc.key) : undefined
199}
200
201export function readIssueStatus(text: string): string | undefined {
202 const doc = parse(text)
203 const fields = isRecord(doc) && isRecord(doc.fields) ? doc.fields : {}
204 return isRecord(fields.status) ? str(fields.status.id) : undefined
205}
206
207/** The transitions open to an issue now: each one's id and the status it leads to. */
208export function readTransitions(text: string): { id: string; to: string }[] {
209 const doc = parse(text)
210 if (!isRecord(doc) || !Array.isArray(doc.transitions)) return []
211 return doc.transitions.filter(isRecord).flatMap(raw => {
212 const id = str(raw.id)
213 const to = isRecord(raw.to) ? str(raw.to.id) : undefined
214 return id && to ? [{ id, to }] : []
215 })
216}
217
218/** Jira's own words for a refused request (`errorMessages`, `errors`), or the status. */
219export function readError(status: number, text: string): string {
220 const doc = parse(text)
221 if (isRecord(doc)) {
222 const messages = Array.isArray(doc.errorMessages) ? doc.errorMessages.map(String) : []
223 const fields = isRecord(doc.errors) ? Object.entries(doc.errors).map(([field, why]) => `${field}: ${String(why)}`) : []
224 if (messages.length + fields.length > 0) return [...messages, ...fields].join('; ')
225 }
226 return `HTTP ${status}`
227}
228
229/** An issue in Backlog.md's terms. A status the map lacks lands in `fallback`. */
230export function toRemoteIssue(
231 base: string,
232 issue: JiraIssue,
233 statusMap: Readonly<Record<string, string>>,
234 priorityMap: Readonly<Record<string, string>>,
235 fallback: string,
236): RemoteItem {
237 const fields: Fields = {
238 title: issue.subject,
239 status: statusMap[issue.statusId] ?? fallback,
240 priority: issue.priorityId === null ? null : (priorityMap[issue.priorityId] || '').toLowerCase() || null,
241 description: issue.description,
242 }
243 return { key: issue.key, url: browseUrl(base, issue.key), fields }
244}
245
246/**
247 * The `fields` of a PUT (or a create) for a change, and the fields it could
248 * not carry (a priority no Jira one maps to). The status goes apart, by a
249 * transition.
250 */
251export function fieldsBody(
252 change: Change,
253 priorityMap: Readonly<Record<string, string>>,
254 priorities: RedminePriority[],
255 current?: string | null,
256): { fields: Record<string, unknown>; skipped: string[] } {
257 const fields: Record<string, unknown> = {}
258 const skipped: string[] = []
259 if (change.title !== undefined) fields.summary = change.title
260 if (change.description !== undefined) fields.description = change.description
261 if (change.priority !== undefined && change.priority !== null) {
262 const id = redminePriorityFor(change.priority, priorityMap, priorities, current)
263 if (id === undefined) skipped.push('priority')
264 else fields.priority = { id: String(id) }
265 }
266 return { fields, skipped }
267}
268
269/** Who a new issue is assigned to: Cloud by accountId, Data Center by user name. */
270export const assigneeField = (base: string, me: string) => (isCloud(base) ? { accountId: me } : { name: me })
271hooks/redmine.ts 246 lines1// Redmine, the first outside tracker: its REST API (JSON, the key in the
2// X-Redmine-API-Key header), read into the sync core's terms and back.
3// Pure: register.tsx makes the requests with $.http.fetch.
4
5import type { Change, Fields, RemoteItem } from './sync'
6
7/** A status or priority: Redmine's id, or (from a CSV export, which has none) its name. */
8export type RedmineStatus = { id: number | string; name: string; isClosed: boolean; isActive?: boolean }
9export type RedminePriority = { id: number | string; name: string; isDefault: boolean }
10
11export type RedmineIssue = {
12 id: number
13 subject: string
14 description: string
15 statusId: number
16 priorityId: number | null
17 assigneeId: number | null
18}
19
20const isRecord = (value: unknown): value is Record<string, unknown> =>
21 typeof value === 'object' && value !== null && !Array.isArray(value)
22
23const parse = (text: string): unknown => {
24 try {
25 return JSON.parse(text)
26 } catch {
27 return undefined
28 }
29}
30
31/** The server's base URL, http or https, no trailing slash; undefined when it is not one. */
32export function normalizeUrl(text: string): string | undefined {
33 const trimmed = text.trim().replace(/\/+$/, '')
34 try {
35 const url = new URL(trimmed)
36 if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined
37 return trimmed
38 } catch {
39 return undefined
40 }
41}
42
43export const issueUrl = (base: string, id: number | string) => `${base}/issues/${id}`
44
45/** The issue id an issue URL of this server names, if it names one. */
46export function issueIdOf(base: string, url: string): number | undefined {
47 const match = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/issues/(\\d+)$`).exec(url.trim())
48 return match ? Number(match[1]) : undefined
49}
50
51export const PAGE = 100
52
53export const urls = {
54 /** Every issue of the project, open and closed, not its subprojects', by id; or only the key owner's. */
55 issues: (base: string, project: string, offset: number, onlyMine = false) =>
56 `${base}/issues.json?project_id=${encodeURIComponent(project)}&subproject_id=!*&status_id=*${onlyMine ? '&assigned_to_id=me' : ''}&sort=id&limit=${PAGE}&offset=${offset}`,
57 currentUser: (base: string) => `${base}/users/current.json?include=groups`,
58 project: (base: string, project: string) => `${base}/projects/${encodeURIComponent(project)}.json`,
59 statuses: (base: string) => `${base}/issue_statuses.json`,
60 priorities: (base: string) => `${base}/enumerations/issue_priorities.json`,
61 issue: (base: string, id: number | string) => `${base}/issues/${id}.json`,
62 create: (base: string) => `${base}/issues.json`,
63}
64
65export const headers = (key: string, body = false): Record<string, string> => ({
66 'X-Redmine-API-Key': key,
67 Accept: 'application/json',
68 ...(body ? { 'Content-Type': 'application/json' } : {}),
69})
70
71/** One page of `/issues.json`: its issues, and how many there are in all. */
72export function readIssues(text: string): { issues: RedmineIssue[]; total: number } | undefined {
73 const doc = parse(text)
74 if (!isRecord(doc) || !Array.isArray(doc.issues)) return undefined
75 const issues = doc.issues.filter(isRecord).flatMap(raw => {
76 const status = isRecord(raw.status) ? raw.status : {}
77 const priority = isRecord(raw.priority) ? raw.priority : {}
78 const assignee = isRecord(raw.assigned_to) ? raw.assigned_to : {}
79 if (typeof raw.id !== 'number' || typeof status.id !== 'number') return []
80 return [
81 {
82 id: raw.id,
83 subject: typeof raw.subject === 'string' ? raw.subject : '',
84 description: typeof raw.description === 'string' ? raw.description : '',
85 statusId: status.id,
86 priorityId: typeof priority.id === 'number' ? priority.id : null,
87 assigneeId: typeof assignee.id === 'number' ? assignee.id : null,
88 },
89 ]
90 })
91 return { issues, total: typeof doc.total_count === 'number' ? doc.total_count : issues.length }
92}
93
94export function readStatuses(text: string): RedmineStatus[] | undefined {
95 const doc = parse(text)
96 if (!isRecord(doc) || !Array.isArray(doc.issue_statuses)) return undefined
97 return doc.issue_statuses
98 .filter(isRecord)
99 .filter(raw => typeof raw.id === 'number' && typeof raw.name === 'string')
100 .map(raw => ({ id: raw.id as number, name: raw.name as string, isClosed: raw.is_closed === true }))
101}
102
103export function readPriorities(text: string): RedminePriority[] | undefined {
104 const doc = parse(text)
105 if (!isRecord(doc) || !Array.isArray(doc.issue_priorities)) return undefined
106 return doc.issue_priorities
107 .filter(isRecord)
108 .filter(raw => typeof raw.id === 'number' && typeof raw.name === 'string')
109 .map(raw => ({ id: raw.id as number, name: raw.name as string, isDefault: raw.is_default === true }))
110}
111
112/** The id of an issue Redmine answered a create with. */
113export function readCreated(text: string): number | undefined {
114 const doc = parse(text)
115 return isRecord(doc) && isRecord(doc.issue) && typeof doc.issue.id === 'number' ? doc.issue.id : undefined
116}
117
118/** The key's owner's id, then their groups' (an issue can be assigned to a group), from `/users/current.json`. */
119export function readCurrentUser(text: string): number[] | undefined {
120 const doc = parse(text)
121 if (!isRecord(doc) || !isRecord(doc.user) || typeof doc.user.id !== 'number') return undefined
122 const groups = Array.isArray(doc.user.groups) ? doc.user.groups.filter(isRecord).map(group => group.id) : []
123 return [doc.user.id, ...groups.filter((id): id is number => typeof id === 'number')]
124}
125
126/** Redmine's own words for a refused write (422 `{ errors: [...] }`), or the status. */
127export function readError(status: number, text: string): string {
128 const doc = parse(text)
129 if (isRecord(doc) && Array.isArray(doc.errors) && doc.errors.length > 0) return doc.errors.map(String).join('; ')
130 return `HTTP ${status}`
131}
132
133const fold = (text: string) =>
134 text
135 .normalize('NFD')
136 .replace(/[\u0300-\u036f]/g, '')
137 .trim()
138 .toLowerCase()
139
140const ACTIVE = /progress|curso|progreso|doing|working|feedback|resolv|resuelt|review|revision/
141const LOW = /^(low|lowest|baja?|bajo|minor|menor|trivial)$/
142const NORMAL = /^(normal|medium|media|medio)$/
143const HIGH = /^(high|highest|alta?|alto|urgent|urgente|immediate|inmediata|critical|critica|blocker|bloqueante|major|mayor)$/
144
145/**
146 * Where each Redmine status lands in Backlog.md, by default: a status of
147 * the same name; else a closed one in the final status, an active-sounding
148 * one (In Progress, Feedback, Resolved…) in the first such Backlog.md
149 * status, the rest in the first status. The person changes any of them.
150 */
151export function defaultStatusMap(redmine: RedmineStatus[], statuses: string[]): Record<string, string> {
152 const map: Record<string, string> = {}
153 const final = statuses[statuses.length - 1] ?? ''
154 const active = statuses.find(one => ACTIVE.test(fold(one))) ?? statuses[1] ?? statuses[0] ?? ''
155 for (const status of redmine) {
156 const named = statuses.find(one => fold(one) === fold(status.name))
157 const isActive = status.isActive ?? ACTIVE.test(fold(status.name))
158 map[String(status.id)] = named ?? (status.isClosed ? final : isActive ? active : statuses[0] ?? '')
159 }
160 return map
161}
162
163/** Where each Redmine priority lands among Backlog.md's (High, Medium, Low by default). */
164export function defaultPriorityMap(redmine: RedminePriority[], priorities: string[]): Record<string, string> {
165 const map: Record<string, string> = {}
166 const pick = (want: string) => priorities.find(one => one.toLowerCase() === want)
167 for (const priority of redmine) {
168 const name = fold(priority.name)
169 const named = priorities.find(one => fold(one) === name)
170 const guess = named ?? (HIGH.test(name) ? pick('high') : NORMAL.test(name) ? pick('medium') : LOW.test(name) ? pick('low') : undefined)
171 if (guess) map[String(priority.id)] = guess
172 }
173 return map
174}
175
176/** An issue in Backlog.md's terms. A status the map lacks lands in `fallback`. */
177export function toRemoteItem(
178 base: string,
179 issue: RedmineIssue,
180 statusMap: Readonly<Record<string, string>>,
181 priorityMap: Readonly<Record<string, string>>,
182 fallback: string,
183): RemoteItem {
184 const fields: Fields = {
185 title: issue.subject,
186 status: statusMap[String(issue.statusId)] ?? fallback,
187 // An unmapped priority (or one set to be left alone, '') is null: the sync leaves priority be.
188 // Lower case, as a task's JSON spells it (`backlog config get priorities` prints High).
189 priority: issue.priorityId === null ? null : (priorityMap[String(issue.priorityId)] || '').toLowerCase() || null,
190 description: issue.description,
191 }
192 return { key: String(issue.id), url: issueUrl(base, issue.id), fields }
193}
194
195/**
196 * The Redmine status for a Backlog.md status: the issue's own when it
197 * already maps there (no needless change), else the first that does.
198 */
199export function redmineStatusFor(
200 status: string,
201 statusMap: Readonly<Record<string, string>>,
202 redmine: RedmineStatus[],
203 current?: number | string,
204): number | string | undefined {
205 const matches = redmine.filter(one => statusMap[String(one.id)] === status)
206 return (matches.find(one => one.id === current) ?? matches.find(one => fold(one.name) === fold(status)) ?? matches[0])?.id
207}
208
209export function redminePriorityFor(
210 priority: string | null | undefined,
211 priorityMap: Readonly<Record<string, string>>,
212 redmine: RedminePriority[],
213 current?: number | string | null,
214): number | string | undefined {
215 if (!priority) return undefined
216 const matches = redmine.filter(one => priorityMap[String(one.id)]?.toLowerCase() === priority.toLowerCase())
217 return (matches.find(one => one.id === current) ?? matches.find(one => fold(one.name) === fold(priority)) ?? matches[0])?.id
218}
219
220/**
221 * The `issue` body of a PUT for a change, and the fields it could not carry
222 * (a status or priority no Redmine one maps to).
223 */
224export function issueBody(
225 change: Change,
226 maps: { status: Readonly<Record<string, string>>; priority: Readonly<Record<string, string>> },
227 lists: { statuses: RedmineStatus[]; priorities: RedminePriority[] },
228 current?: { statusId?: number | string; priorityId?: number | string | null },
229): { issue: Record<string, string | number>; skipped: string[] } {
230 const issue: Record<string, string | number> = {}
231 const skipped: string[] = []
232 if (change.title !== undefined) issue.subject = change.title
233 if (change.description !== undefined) issue.description = change.description
234 if (change.status !== undefined) {
235 const id = redmineStatusFor(change.status, maps.status, lists.statuses, current?.statusId)
236 if (id === undefined) skipped.push('status')
237 else issue.status_id = id
238 }
239 if (change.priority !== undefined && change.priority !== null) {
240 const id = redminePriorityFor(change.priority, maps.priority, lists.priorities, current?.priorityId)
241 if (id === undefined) skipped.push('priority')
242 else issue.priority_id = id
243 }
244 return { issue, skipped }
245}
246hooks/sync.ts 245 lines1// The sync core, the same for every outside tracker (Redmine first; Jira,
2// Excel later). Pure: it reads what each side holds now and what they held
3// at the last sync, and says what to do. register.tsx runs the actions (the
4// backlog CLI on one side, the tracker's adapter on the other).
5//
6// Each linked pair keeps a `base`: the fields as both sides agreed at the
7// last sync, in Backlog.md's terms. A field is merged three ways:
8// - same on both sides: nothing to do;
9// - changed on one side only: the other side takes it;
10// - changed on both, to different values: a conflict. Neither side is
11// touched for that field until the person picks one.
12// Read-only mode never writes to the tracker: a field the tracker changed
13// overwrites the task's, and a change made only in Backlog.md stays local.
14// With `askOnClash` (a CSV export), a field changed on both sides is a
15// conflict there too, instead of the tracker's value winning.
16
17import type { SyncConflict, SyncData, SyncField, SyncFields, SyncLink, SyncMode, SyncSettings } from '../types'
18
19export const FIELDS: readonly SyncField[] = ['title', 'status', 'priority', 'description']
20export type Field = SyncField
21export type Fields = SyncFields
22export type Conflict = SyncConflict
23export type Link = SyncLink
24export type { SyncMode }
25
26/** An item of the tracker. `skip`: fields it does not carry (a CSV without that column), left alone. */
27export type RemoteItem = { key: string; url: string; fields: Fields; skip?: readonly SyncField[] }
28
29/**
30 * A Backlog.md task as the list shows it. `description` is undefined when
31 * it was not read this time (the list carries none): it counts as unchanged.
32 */
33export type LocalItem = { id: string; references: string[]; fields: Omit<Fields, 'description'> & { description?: string } }
34
35export type Change = Partial<Fields>
36
37export type Action =
38 | { kind: 'create-local'; key: string; url: string; fields: Fields }
39 | { kind: 'update-local'; key: string; taskId: string; change: Change }
40 | { kind: 'create-remote'; taskId: string; fields: Fields }
41 | { kind: 'update-remote'; key: string; taskId: string; change: Change }
42
43export type Plan = {
44 actions: Action[]
45 /** The links once every action has gone through; register.tsx keeps the ones that did. */
46 links: Record<string, Link>
47 conflicts: Conflict[]
48}
49
50/** The same text whatever the line endings, trailing space or null. */
51const same = (a: string | null | undefined, b: string | null | undefined) =>
52 (a ?? '').replace(/\r\n/g, '\n').trimEnd() === (b ?? '').replace(/\r\n/g, '\n').trimEnd()
53
54/**
55 * Merges one field three ways. `local` undefined: not read, so unchanged.
56 * Returns what each side should take, or a conflict.
57 */
58export function mergeField(
59 base: string | null,
60 local: string | null | undefined,
61 remote: string | null,
62 mode: SyncMode,
63 askOnClash = false,
64): { toLocal?: string | null; toRemote?: string | null; agreed: string | null; isConflict?: true } {
65 const mine = local === undefined ? base : local
66 if (same(mine, remote)) return { agreed: remote }
67 const remoteChanged = !same(remote, base)
68 const localChanged = !same(mine, base)
69 if (mode === 'read' && askOnClash && remoteChanged && localChanged) return { agreed: base, isConflict: true }
70 if (mode === 'read') return remoteChanged ? { toLocal: remote, agreed: remote } : { agreed: base }
71 if (remoteChanged && !localChanged) return { toLocal: remote, agreed: remote }
72 if (localChanged && !remoteChanged) return { toRemote: mine, agreed: mine }
73 return { agreed: base, isConflict: true }
74}
75
76/**
77 * Plans one sync. `excluded`: tasks that existed before the tracker was set
78 * up, never sent to it unless the person asks. `url` of an issue is how a
79 * task is matched to it when no link is kept (the task's references).
80 */
81export function plan(input: {
82 remote: RemoteItem[]
83 local: LocalItem[]
84 links: Record<string, Link>
85 conflicts: Conflict[]
86 mode: SyncMode
87 excluded: readonly string[]
88 /** Whether new Backlog.md tasks are sent to the tracker (bidirectional only). */
89 createsRemote: boolean
90 /** Whether a reference names an item of this tracker, listed this time or not. */
91 ownsRef?: (ref: string) => boolean
92 /** Read only, but a field changed on both sides is a conflict (see mergeField). */
93 askOnClash?: boolean
94}): Plan {
95 const actions: Action[] = []
96 const links: Record<string, Link> = {}
97 const conflicts: Conflict[] = []
98 const byId = new Map(input.local.map(task => [task.id, task]))
99 const linkedTasks = new Set<string>()
100
101 for (const item of input.remote) {
102 let link = input.links[item.key]
103 // A task that names the issue in its references, with no kept link:
104 // adopt it with the task's own fields as the base, so whatever differs
105 // counts as the tracker's change and the tracker's values win.
106 if (!link || !byId.has(link.taskId)) {
107 const found = input.local.find(task => task.references.includes(item.url))
108 if (found) link = { taskId: found.id, base: { ...found.fields, description: item.fields.description } }
109 }
110 const task = link ? byId.get(link.taskId) : undefined
111 if (!link || !task) {
112 actions.push({ kind: 'create-local', key: item.key, url: item.url, fields: item.fields })
113 links[item.key] = { taskId: '', base: item.fields }
114 continue
115 }
116 linkedTasks.add(task.id)
117 const toLocal: Change = {}
118 const toRemote: Change = {}
119 const agreed = { ...link.base }
120 for (const field of FIELDS) {
121 // A tracker priority with no Backlog.md one to land in, or a field the
122 // tracker does not carry: leave it alone.
123 if ((field === 'priority' && item.fields.priority === null) || item.skip?.includes(field)) {
124 ;(agreed as Record<string, unknown>)[field] = task.fields[field] ?? link.base[field]
125 continue
126 }
127 const open = input.conflicts.find(one => one.key === item.key && one.field === field)
128 const merged = mergeField(link.base[field], task.fields[field], item.fields[field], input.mode, input.askOnClash)
129 if (merged.isConflict) {
130 conflicts.push({
131 key: item.key,
132 taskId: task.id,
133 field,
134 local: task.fields[field] ?? open?.local ?? null,
135 remote: item.fields[field],
136 })
137 continue
138 }
139 if ('toLocal' in merged) (toLocal as Record<string, unknown>)[field] = merged.toLocal
140 if ('toRemote' in merged) (toRemote as Record<string, unknown>)[field] = merged.toRemote
141 ;(agreed as Record<string, unknown>)[field] = merged.agreed
142 }
143 if (Object.keys(toLocal).length > 0) actions.push({ kind: 'update-local', key: item.key, taskId: task.id, change: toLocal })
144 if (Object.keys(toRemote).length > 0) {
145 actions.push({ kind: 'update-remote', key: item.key, taskId: task.id, change: toRemote })
146 }
147 links[item.key] = { taskId: task.id, base: agreed as Fields }
148 }
149
150 // Links whose issue is gone from the tracker's list (deleted, moved, out
151 // of reach) are kept as they were: nothing is deleted on either side.
152 for (const [key, link] of Object.entries(input.links)) {
153 if (!(key in links)) {
154 links[key] = link
155 linkedTasks.add(link.taskId)
156 }
157 }
158
159 if (input.mode === 'both' && input.createsRemote) {
160 const remoteUrls = new Set(input.remote.map(item => item.url))
161 for (const task of input.local) {
162 if (linkedTasks.has(task.id) || input.excluded.includes(task.id)) continue
163 // A task that already points at an issue of this tracker is not a new one.
164 if (task.references.some(ref => remoteUrls.has(ref) || input.ownsRef?.(ref))) continue
165 actions.push({
166 kind: 'create-remote',
167 taskId: task.id,
168 fields: { ...task.fields, description: task.fields.description ?? '' },
169 })
170 }
171 }
172 return { actions, links, conflicts }
173}
174
175/** The fields a change touches, in a fixed order, for messages and tests. */
176export const changedFields = (change: Change) => FIELDS.filter(field => field in change)
177
178const isRecord = (value: unknown): value is Record<string, unknown> =>
179 typeof value === 'object' && value !== null && !Array.isArray(value)
180
181/** A project's sync as kept in $.store, keeping only what is well formed. */
182export function readSyncStore(value: unknown): { settings: SyncSettings | null; data: SyncData } {
183 const raw = isRecord(value) ? value : {}
184 const data = isRecord(raw.data) ? raw.data : {}
185 const strings = (one: unknown) => (isRecord(one) ? Object.fromEntries(Object.entries(one).filter(([, v]) => typeof v === 'string')) : {})
186 const s = isRecord(raw.settings) ? raw.settings : undefined
187 const settings: SyncSettings | null =
188 s && (s.provider === 'redmine' || s.provider === 'jira' || s.provider === 'csv')
189 ? {
190 provider: s.provider,
191 url: typeof s.url === 'string' ? s.url : '',
192 project: typeof s.project === 'string' ? s.project : '',
193 file: typeof s.file === 'string' ? s.file : '',
194 issueType: typeof s.issueType === 'string' ? s.issueType : '',
195 mode: s.mode === 'both' ? 'both' : 'read',
196 onlyMine: s.onlyMine === true,
197 view: s.view === 'mine' ? 'mine' : 'all',
198 intervalMin: typeof s.intervalMin === 'number' && s.intervalMin >= 0 ? s.intervalMin : 5,
199 statusMap: strings(s.statusMap) as Record<string, string>,
200 priorityMap: strings(s.priorityMap) as Record<string, string>,
201 excluded: Array.isArray(s.excluded) ? s.excluded.filter(one => typeof one === 'string') : [],
202 }
203 : null
204 return {
205 settings,
206 data: {
207 links: isRecord(data.links) ? (data.links as Record<string, SyncLink>) : {},
208 conflicts: Array.isArray(data.conflicts) ? (data.conflicts as SyncConflict[]) : [],
209 meta: isRecord(data.meta) ? (data.meta as SyncData['meta']) : {},
210 statuses: Array.isArray(data.statuses) ? (data.statuses as SyncData['statuses']) : [],
211 priorities: Array.isArray(data.priorities) ? (data.priorities as SyncData['priorities']) : [],
212 issueTypes: Array.isArray(data.issueTypes) ? (data.issueTypes as SyncData['issueTypes']) : undefined,
213 me: Array.isArray(data.me) ? data.me.filter(one => typeof one === 'number' || typeof one === 'string') : undefined,
214 lastSync: typeof data.lastSync === 'string' ? data.lastSync : undefined,
215 lastError: typeof data.lastError === 'string' ? data.lastError : undefined,
216 warnings: Array.isArray(data.warnings) ? data.warnings.filter(one => typeof one === 'string') : [],
217 },
218 }
219}
220
221/**
222 * The tasks the "only mine" view hides: those linked to an issue that, at
223 * the last sync, was assigned to someone else, to nobody, or was not listed.
224 * Tasks with no issue are nobody else's, so they show.
225 */
226export function othersTasks(links: Readonly<Record<string, Link>>, meta: SyncData['meta'], me: readonly (number | string)[]): Set<string> {
227 const hidden = new Set<string>()
228 for (const [key, link] of Object.entries(links)) {
229 const assignee = meta[key]?.assigneeId
230 if (!link.taskId || (assignee !== null && assignee !== undefined && me.includes(assignee))) continue
231 hidden.add(link.taskId)
232 }
233 return hidden
234}
235
236export const EMPTY_SYNC: SyncData = { links: {}, conflicts: [], meta: {}, statuses: [], priorities: [], warnings: [] }
237
238/**
239 * Whether a linked task's description must be read again: the list carries
240 * none, and updatedAt is kept to the minute, so a task changed in the minute
241 * of the last sync or after is read.
242 */
243export const needsRead = (updatedAt: string | null, lastSync: string | undefined) =>
244 !lastSync || (updatedAt ?? '') >= `${lastSync.slice(0, 16)}:00Z`
245hooks/csv.ts 199 lines1// A CSV file Redmine exported (Issues → CSV), the tracker for those with no
2// API key: read only, read into the sync core's terms. Pure: register.tsx
3// reads the file with $.fs.
4//
5// Redmine writes the export in the language of the person's account: its
6// headers ("#", "Asunto", "Estado"…), its separator ("," or ";"), and its
7// encoding (UTF-8, or the language's own, such as Windows-1252).
8
9import { fromBase64 } from './base64'
10import type { RedminePriority, RedmineStatus } from './redmine'
11import type { Fields, RemoteItem } from './sync'
12import type { SyncField } from '../types'
13
14export { fromBase64 }
15
16/** Windows-1252's 0x80–0x9F; the rest of its bytes are Latin-1's. */
17const CP1252 =
18 '€\u0081‚ƒ„…†‡ˆ‰Š‹Œ\u008dŽ\u008f\u0090‘’“”•–—˜™š›œ\u009džŸ'
19
20/** Whether the bytes are well-formed UTF-8. */
21function isUtf8(bytes: Uint8Array): boolean {
22 for (let i = 0; i < bytes.length; ) {
23 const byte = bytes[i] as number
24 const extra = byte < 0x80 ? 0 : byte >= 0xc2 && byte < 0xe0 ? 1 : byte >= 0xe0 && byte < 0xf0 ? 2 : byte >= 0xf0 && byte < 0xf5 ? 3 : -1
25 if (extra < 0) return false
26 for (let k = 1; k <= extra; k++) if (((bytes[i + k] ?? 0) & 0xc0) !== 0x80) return false
27 i += extra + 1
28 }
29 return true
30}
31
32/** The file's text: UTF-8 (with or without its mark), else Windows-1252. */
33export function decodeText(bytes: Uint8Array): string {
34 if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return new TextDecoder().decode(bytes.subarray(3))
35 if (isUtf8(bytes)) return new TextDecoder().decode(bytes)
36 let text = ''
37 for (const byte of bytes) text += byte >= 0x80 && byte < 0xa0 ? CP1252[byte - 0x80] : String.fromCharCode(byte)
38 return text
39}
40
41/** The separator of the header line: the one of `,`, `;` or tab found most outside quotes. */
42function separatorOf(text: string): string {
43 const counts: Record<string, number> = { ',': 0, ';': 0, '\t': 0 }
44 let isQuoted = false
45 for (const char of text) {
46 if (char === '"') isQuoted = !isQuoted
47 else if (!isQuoted && (char === '\n' || char === '\r')) break
48 else if (!isQuoted && char in counts) counts[char] = (counts[char] ?? 0) + 1
49 }
50 return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? ','
51}
52
53/** The rows of a CSV: quoted fields may hold separators, quotes ("") and line breaks. */
54export function parseCsv(text: string): string[][] {
55 const separator = separatorOf(text)
56 const rows: string[][] = []
57 let row: string[] = []
58 let field = ''
59 let isQuoted = false
60 for (let i = 0; i < text.length; i++) {
61 const char = text[i] as string
62 if (isQuoted) {
63 if (char !== '"') field += char
64 else if (text[i + 1] === '"') {
65 field += '"'
66 i++
67 } else isQuoted = false
68 } else if (char === '"') isQuoted = true
69 else if (char === separator) {
70 row.push(field)
71 field = ''
72 } else if (char === '\n' || char === '\r') {
73 if (char === '\r' && text[i + 1] === '\n') i++
74 row.push(field)
75 rows.push(row)
76 row = []
77 field = ''
78 } else field += char
79 }
80 if (field !== '' || row.length > 0) {
81 row.push(field)
82 rows.push(row)
83 }
84 return rows.filter(one => one.some(cell => cell.trim() !== ''))
85}
86
87const fold = (text: string) =>
88 text
89 .normalize('NFD')
90 .replace(/[̀-ͯ]/g, '')
91 .trim()
92 .toLowerCase()
93
94/** The headers each column goes by, in the languages Redmine is used in most. */
95const HEADERS = {
96 id: /^(#|id|n[o.º°]?|issue|peticion|tarefa|ticket)$/,
97 title: /^(subject|asunto|titulo|title|summary|resumen|sujet|betreff|oggetto|assunto)$/,
98 status: /^(status|estado|statut|stato|estat)$/,
99 priority: /^(priority|prioridad|priorite|prioritat|priorita|prioridade|prioritat)$/,
100 description: /^(description|descripcion|beschreibung|descrizione|descricao|descripcio)$/,
101 closed: /^(closed|cerrada|cerrado|fecha de cierre|ferme|fermee|geschlossen|chiusa|fechada|tancada)$/,
102} as const
103
104export type Column = keyof typeof HEADERS
105export type Columns = Partial<Record<Column, number>>
106
107/** Which column holds what, from the header row. */
108export function columnsOf(headers: string[]): Columns {
109 const columns: Columns = {}
110 headers.forEach((header, i) => {
111 const name = fold(header)
112 for (const [column, pattern] of Object.entries(HEADERS) as [Column, RegExp][]) {
113 if (columns[column] === undefined && pattern.test(name)) columns[column] = i
114 }
115 })
116 return columns
117}
118
119export type ExportRow = { id: number; subject: string; status: string; priority: string; description: string; isClosed: boolean }
120
121export type Export = {
122 headers: string[]
123 columns: Columns
124 rows: ExportRow[]
125 /** The fields the file has no column for: left alone. */
126 skip: SyncField[]
127}
128
129/** An export, or the headers it has when it lacks the "#" and subject columns. */
130export function readExport(text: string): Export | { headers: string[] } {
131 const [headers = [], ...lines] = parseCsv(text)
132 const columns = columnsOf(headers)
133 if (columns.id === undefined || columns.title === undefined) return { headers }
134 const cell = (line: string[], column: Column) => {
135 const at = columns[column]
136 return at === undefined ? '' : (line[at] ?? '').trim()
137 }
138 const rows = lines.flatMap(line => {
139 const id = Number(cell(line, 'id').replace(/^#/, ''))
140 if (!Number.isInteger(id) || id <= 0) return []
141 return [
142 {
143 id,
144 subject: cell(line, 'title'),
145 status: cell(line, 'status'),
146 priority: cell(line, 'priority'),
147 description: columns.description === undefined ? '' : (line[columns.description] ?? '').replace(/\r\n/g, '\n'),
148 isClosed: cell(line, 'closed') !== '',
149 },
150 ]
151 })
152 const skip = (['status', 'priority', 'description'] as const).filter(field => columns[field] === undefined)
153 return { headers, columns, rows, skip }
154}
155
156const CLOSED = /closed|cerrad|rechaz|rejected|cancel|anulad|ferme|geschlossen|chius|fechad|tancad/
157
158/**
159 * The statuses an export names, in the order they first appear. A status is
160 * closed when a row in it has a closing date, or its name says so.
161 */
162export function statusesOf(rows: ExportRow[]): RedmineStatus[] {
163 const seen = new Map<string, RedmineStatus>()
164 for (const row of rows) {
165 if (!row.status) continue
166 const known = seen.get(row.status)
167 const isClosed = row.isClosed || CLOSED.test(fold(row.status))
168 if (!known) seen.set(row.status, { id: row.status, name: row.status, isClosed })
169 else if (isClosed) known.isClosed = true
170 }
171 return [...seen.values()]
172}
173
174export function prioritiesOf(rows: ExportRow[]): RedminePriority[] {
175 const names = [...new Set(rows.map(row => row.priority).filter(Boolean))]
176 return names.map(name => ({ id: name, name, isDefault: false }))
177}
178
179/** What a task's reference names the issue by: its URL when Redmine's is known, else "#12". */
180export const refFor = (base: string | undefined, id: number) => (base ? `${base}/issues/${id}` : `#${id}`)
181
182/** A row in Backlog.md's terms. A status the map lacks lands in `fallback`. */
183export function toRemoteRow(
184 base: string | undefined,
185 row: ExportRow,
186 skip: readonly SyncField[],
187 statusMap: Readonly<Record<string, string>>,
188 priorityMap: Readonly<Record<string, string>>,
189 fallback: string,
190): RemoteItem {
191 const fields: Fields = {
192 title: row.subject,
193 status: statusMap[row.status] ?? fallback,
194 priority: (priorityMap[row.priority] || '').toLowerCase() || null,
195 description: row.description,
196 }
197 return { key: String(row.id), url: refFor(base, row.id), fields, skip }
198}
199hooks/board.tsx 273 lines1// The board's surface module: one column per status, drawn by a `Client`
2// on the terminal and the desktop. It holds no `$`: a move or an open is
3// posted to the hooks module (`ui.message`), which runs the backlog command.
4//
5// The layout is computed, not measured, so a pointer's cell maps straight
6// to a card (`layout`, which the drawing and `hit` share). Each column is
7// `width` cells wide, two cells apart. Row 0 is its status (a chip in the
8// status's colour) and row 1 is blank; then each card is a rounded box in
9// that colour, so a card moved to another column takes that column's
10// colour: its border, its title (one line, or two when it does not fit),
11// its id and criteria, its border. A dashed slot under the cards of the
12// column a card is dragged over marks where it will land.
13//
14// Clicking a card selects it; clicking it again opens it, and clicking
15// another column then moves it there. Dragging a card to another column
16// moves it. A card moved by the pointer is let go of, so a later click
17// cannot move it again; a click on an empty spot lets go of it too. With
18// the focus here, ←/→ move the selected card to the nearest open column
19// (closed ones are skipped), ↑/↓ pick another, Enter opens it.
20
21import type { ClientModule } from 'claude-code'
22
23import type { BoardCard, BoardMessage, BoardProps } from '../types'
24
25type Drag = { id: string; from: number; over: number; wasSelected: boolean }
26/**
27 * `isArmed`: the selected card was picked by a click and has not moved since,
28 * so a click on another column moves it there. A drag that moved a card, a
29 * move by click, or a click on an empty spot leaves nothing selected; a card
30 * picked with the keys is never armed (←/→ move it).
31 */
32type State = { selected?: string; isArmed?: boolean; drag?: Drag }
33
34const MIN_WIDTH = 12
35const GAP = 2
36/** Rows above a column's first card: the chip and a blank. */
37const HEAD = 2
38/** Rows the drop slot takes: a dashed box of one line. */
39const SLOT = 3
40/** Cells inside a card: the column's width less the card's border and a cell of padding each side. */
41const innerOf = (width: number) => Math.max(1, width - 4)
42
43/** The width of one column, from the region's width or the pane's. */
44export function columnWidth(props: BoardProps, regionColumns: number): number {
45 const room = regionColumns > 0 ? regionColumns : props.width
46 const count = Math.max(1, props.columns.length)
47 return Math.max(MIN_WIDTH, Math.floor((room - GAP * (count - 1)) / count))
48}
49
50/**
51 * A title in at most two lines of `width` cells, cut at a space where one
52 * falls, the second line ending in an ellipsis if the title goes on.
53 */
54export function wrapTitle(text: string, width: number): string[] {
55 if (text.length <= width) return [text]
56 const space = text.lastIndexOf(' ', width)
57 const cut = space > width / 3 ? space : width
58 return [text.slice(0, cut).trimEnd(), fit(text.slice(cut).trimStart(), width)]
59}
60
61/** Each card's title lines, its first row and its rows, from the top of its column. */
62export type Layout = {
63 cards: { lines: string[]; top: number; rows: number }[][]
64 /** Rows of every column, the drop slot included: all are as tall as the tallest. */
65 height: number
66}
67
68export function layout(props: BoardProps, width: number): Layout {
69 const inner = innerOf(width)
70 let tallest = HEAD
71 const cards = props.columns.map(column => {
72 let top = HEAD
73 const placed = column.cards.map(card => {
74 const lines = wrapTitle(card.title, inner - (card.isHigh ? 2 : 0))
75 // The border, the title, the id line, the border.
76 const rows = lines.length + 3
77 const one = { lines, top, rows }
78 top += rows
79 return one
80 })
81 tallest = Math.max(tallest, top)
82 return placed
83 })
84 return { cards, height: tallest + SLOT }
85}
86
87/** Rows a column takes, the drop slot included. */
88export const columnHeight = (props: BoardProps, width = 40) => layout(props, width).height
89
90/** Where a card is: its column and its place (0 for the first card). */
91export function locate(props: BoardProps, id: string | undefined): { column: number; row: number } | undefined {
92 if (!id) return undefined
93 for (const [column, one] of props.columns.entries()) {
94 const at = one.cards.findIndex(card => card.id === id)
95 if (at !== -1) return { column, row: at }
96 }
97 return undefined
98}
99
100/** What lies under a cell: a column, and the card there if any. */
101export function hit(
102 props: BoardProps,
103 width: number,
104 x: number,
105 y: number,
106): { column: number; card?: BoardCard } | undefined {
107 const column = Math.floor(x / (width + GAP))
108 const placed = layout(props, width)
109 if (x < 0 || column >= props.columns.length || y < 0 || y >= placed.height) return undefined
110 const at = placed.cards[column]?.findIndex(one => y >= one.top && y < one.top + one.rows) ?? -1
111 return { column, card: at === -1 ? undefined : props.columns[column]?.cards[at] }
112}
113
114/** The nearest column from `from` in `step`'s direction that is not closed. */
115export function nextOpenColumn(props: BoardProps, from: number, step: 1 | -1): number | undefined {
116 for (let i = from + step; i >= 0 && i < props.columns.length; i += step) {
117 if (!props.columns[i]?.isClosed) return i
118 }
119 return undefined
120}
121
122/**
123 * A card's second line in `width` cells: its id and its criteria done
124 * (`BL-1 · 1/2`), closer together (`BL-1 1/2`) when that does not fit, and
125 * cut only then; the count is what a narrow column must not lose.
126 */
127export function cardLine(id: string, criteria: string | null, width: number): string {
128 if (!criteria) return fit(id, width)
129 for (const line of [`${id} · ${criteria}`, `${id} ${criteria}`]) if (line.length <= width) return line
130 return `${fit(id, Math.max(1, width - criteria.length - 1))} ${criteria}`
131}
132
133/** Cuts a text to `width` cells, an ellipsis at the cut. */
134export function fit(text: string, width: number): string {
135 if (width <= 0) return ''
136 return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
137}
138
139const Board: ClientModule<BoardProps, State> = (props, surface) => {
140 const { Box, Text } = surface.elements
141 const width = columnWidth(props, surface.columns)
142 const placed = layout(props, width)
143 const height = placed.height
144 const inner = innerOf(width)
145 const state = surface.state ?? {}
146
147 const post = (message: BoardMessage) => surface.post(message)
148 const move = (id: string, column: number | undefined) => {
149 const target = column === undefined ? undefined : props.columns[column]
150 if (target) post({ type: 'move', id, status: target.status })
151 }
152
153 // Set again on every call, so each listener sees this call's props; the
154 // state is read when the event comes, never captured.
155 surface.onPointer(event => {
156 const now = surface.state ?? {}
157 if (event.type === 'down') {
158 if (event.button !== undefined && event.button !== 'left') return
159 const at = hit(props, width, event.x, event.y)
160 if (!at) return
161 if (at.card) {
162 const id = at.card.id
163 const wasSelected = now.selected === id && now.isArmed === true
164 surface.setState({ selected: id, drag: { id, from: at.column, over: at.column, wasSelected } })
165 return
166 }
167 const from = locate(props, now.selected)
168 if (now.selected && now.isArmed && from && from.column !== at.column) move(now.selected, at.column)
169 // Moved or not, a click on an empty spot lets go of the card.
170 surface.setState({})
171 return
172 }
173 const drag = now.drag
174 if (!drag) return
175 if (event.type === 'move') {
176 const at = hit(props, width, event.x, Math.min(Math.max(0, event.y), height - 1))
177 const over = at ? at.column : drag.from
178 if (over !== drag.over) surface.setState({ ...now, drag: { ...drag, over } })
179 return
180 }
181 if (event.type === 'up') {
182 if (drag.over !== drag.from) {
183 // Moved: let go of it, so the next click cannot move it again.
184 surface.setState({})
185 move(drag.id, drag.over)
186 } else if (drag.wasSelected) {
187 surface.setState({})
188 post({ type: 'open', id: drag.id })
189 } else {
190 surface.setState({ selected: drag.id, isArmed: true })
191 }
192 }
193 })
194
195 surface.onKey(event => {
196 const now = surface.state ?? {}
197 const at = locate(props, now.selected)
198 if (!at || !now.selected) {
199 // The first card of the first open column that has one, else of any.
200 const first = (props.columns.find(one => !one.isClosed && one.cards.length > 0) ??
201 props.columns.find(one => one.cards.length > 0))?.cards[0]
202 if (first && ['up', 'down', 'left', 'right'].includes(event.key)) surface.setState({ selected: first.id, isArmed: false })
203 return
204 }
205 const cards = props.columns[at.column]?.cards ?? []
206 if (event.key === 'up' || event.key === 'down') {
207 const next = cards[at.row + (event.key === 'up' ? -1 : 1)]
208 if (next) surface.setState({ selected: next.id, isArmed: false })
209 } else if (event.key === 'left') {
210 move(now.selected, nextOpenColumn(props, at.column, -1))
211 } else if (event.key === 'right') {
212 move(now.selected, nextOpenColumn(props, at.column, 1))
213 } else if (event.key === 'return') {
214 post({ type: 'open', id: now.selected })
215 }
216 })
217
218 const drag = state.drag
219 return (
220 <Box flexDirection="column">
221 <Box flexDirection="row" columnGap={GAP}>
222 {props.columns.map((column, i) => {
223 const isTarget = drag !== undefined && drag.over === i && drag.over !== drag.from
224 const label = fit(` ${column.status} `, Math.max(1, width - String(column.total).length - 1))
225 return (
226 <Box key={`column:${column.status}`} flexDirection="column" width={width} height={height}>
227 <Text>
228 <Text backgroundColor={column.color} color="inverseText" bold>
229 {label}
230 </Text>
231 <Text dimColor> {column.total}</Text>
232 </Text>
233 <Text> </Text>
234 {column.cards.map((card, k) => {
235 const isSelected = card.id === state.selected
236 const isFaded = column.isClosed || drag?.id === card.id
237 const lines = placed.cards[i]?.[k]?.lines ?? [card.title]
238 return (
239 <Box
240 key={`card:${card.id}`}
241 flexDirection="column"
242 width={width}
243 paddingX={1}
244 borderStyle={isSelected ? 'bold' : 'round'}
245 borderColor={column.color}
246 borderDimColor={isFaded}
247 >
248 {lines.map((line, n) => (
249 <Text inverse={isSelected} dimColor={isFaded} bold={!column.isClosed}>
250 {n === 0 && card.isHigh ? <Text color="error">! </Text> : n > 0 && card.isHigh ? ' ' : ''}
251 {line}
252 </Text>
253 ))}
254 <Text inverse={isSelected} dimColor>
255 {cardLine(card.id, card.criteria, inner)}
256 </Text>
257 </Box>
258 )
259 })}
260 {isTarget ? (
261 <Box key="slot" width={width} height={SLOT} borderStyle="dashed" borderColor={column.color} />
262 ) : null}
263 </Box>
264 )
265 })}
266 </Box>
267 <Text dimColor>{props.hint}</Text>
268 </Box>
269 )
270}
271
272export default Board
273hooks/base64.ts 35 lines1// Base64 both ways: $.fs gives a file's bytes as base64, and Jira's basic
2// authentication sends "email:token" as base64. No btoa/atob in a mod.
3
4const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
5
6/** The bytes of `$.fs.read(…, { as: 'bytes' })`'s base64. */
7export function fromBase64(text: string): Uint8Array {
8 const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
9 const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))
10 let bits = 0
11 let value = 0
12 let at = 0
13 for (const char of clean) {
14 value = (value << 6) | ALPHABET.indexOf(char)
15 bits += 6
16 if (bits >= 8) {
17 bits -= 8
18 bytes[at++] = (value >> bits) & 0xff
19 }
20 }
21 return bytes.subarray(0, at)
22}
23
24/** Bytes as base64, padded. */
25export function toBase64(bytes: Uint8Array): string {
26 let text = ''
27 for (let i = 0; i < bytes.length; i += 3) {
28 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
29 text += (ALPHABET[(n >> 18) & 63] ?? '') + (ALPHABET[(n >> 12) & 63] ?? '')
30 text += i + 1 < bytes.length ? (ALPHABET[(n >> 6) & 63] ?? '') : '='
31 text += i + 2 < bytes.length ? (ALPHABET[n & 63] ?? '') : '='
32 }
33 return text
34}
35types/index.d.ts 163 lines1// The values backlog-pane keeps in $.state, and the shapes of Backlog.md's
2// JSON output (schemaVersion 1) that the pane reads.
3
4export type Checklist = { index: number; text: string; checked: boolean }
5
6/** A task as `backlog task list --json` lists it (the fields the pane uses). */
7export type TaskSummary = {
8 id: string
9 title: string
10 status: string
11 priority: string | null
12 labels: string[]
13 /** Its references: URLs or paths; a synced task holds its issue's URL here. */
14 references: string[]
15 acceptanceCriteriaCompleted: number
16 acceptanceCriteriaCount: number
17 createdAt: string | null
18 updatedAt: string | null
19}
20
21/** A task as `backlog task view <id> --json` shows it. */
22export type TaskDetail = TaskSummary & {
23 description: string | null
24 acceptanceCriteria: Checklist[]
25 implementationPlan: string | null
26}
27
28/** What the pane can say about the backlog as a whole. */
29export type Phase =
30 | { kind: 'idle' }
31 | { kind: 'loading' }
32 | { kind: 'ready' }
33 | { kind: 'not-installed' }
34 | { kind: 'not-initialized'; message: string }
35 | { kind: 'failed'; message: string }
36
37/** One card of the board: a task as its surface module draws it. */
38export type BoardCard = {
39 id: string
40 title: string
41 isHigh: boolean
42 /** Its acceptance criteria done, as `1/2`; null when it has none. */
43 criteria: string | null
44}
45
46/** One column of the board: a status, its theme colour and its cards. */
47export type BoardColumn = {
48 status: string
49 color: string
50 total: number
51 /** In the `closedStatuses` option: drawn dim, and ←/→ skip it. */
52 isClosed: boolean
53 cards: BoardCard[]
54}
55
56/** What the board's `Client` is handed: plain data, no closures. */
57export type BoardProps = {
58 columns: BoardColumn[]
59 /** The pane's body width, until the region has been laid out. */
60 width: number
61 hint: string
62}
63
64/** What the board's surface module posts to the hooks module. */
65export type BoardMessage = { type: 'move'; id: string; status: string } | { type: 'open'; id: string }
66
67/** A project's colours (theme keys) and closed statuses; `closed` absent: the option's. */
68export type Prefs = { colors: Record<string, string>; closed?: string[] }
69
70/** A task's synced fields, in Backlog.md's terms (status and priority mapped). */
71export type SyncFields = { title: string; status: string; priority: string | null; description: string }
72
73export type SyncField = 'title' | 'status' | 'priority' | 'description'
74
75/** `read`: the tracker is only read. `both`: changes go both ways. */
76export type SyncMode = 'read' | 'both'
77
78/** What the last sync agreed on for one linked pair, by the tracker's key. */
79export type SyncLink = { taskId: string; base: SyncFields }
80
81/** One field that changed on both sides since the last sync, kept until the person picks one. */
82export type SyncConflict = {
83 key: string
84 taskId: string
85 field: SyncField
86 local: string | null
87 remote: string | null
88}
89
90/**
91 * Where a project's tasks come from: Redmine's API, or a CSV file Redmine
92 * exported (read only, read when the person presses Sync now).
93 */
94export type SyncProvider = 'redmine' | 'jira' | 'csv'
95
96/** How a project syncs, as the person set it up in Integration…. */
97export type SyncSettings = {
98 provider: SyncProvider
99 /** Redmine's URL: the API's, or (CSV) the one the tasks' references point at; optional for CSV. */
100 url: string
101 project: string
102 /** CSV: the exported file, absolute or relative to the session's folder. */
103 file: string
104 /** Jira: the issue type new tasks are created as (its id); '' picks Task, or the first. */
105 issueType?: string
106 mode: SyncMode
107 /** Only the issues assigned to the key's owner; new tasks sent to Redmine are assigned to them. */
108 onlyMine: boolean
109 /** The board shows every task, or hides those linked to issues not assigned to the key's owner. */
110 view: 'all' | 'mine'
111 /** Minutes between automatic syncs while the pane is open; 0: only by the button. */
112 intervalMin: number
113 /** Redmine status id (CSV: name) → Backlog.md status, and the same for priorities. */
114 statusMap: Record<string, string>
115 priorityMap: Record<string, string>
116 /** Tasks that existed when the sync was set up: never sent to Redmine unless asked. */
117 excluded: string[]
118}
119
120/** What a project's sync knows between runs. */
121export type SyncData = {
122 links: Record<string, SyncLink>
123 conflicts: SyncConflict[]
124 /** Each issue's Redmine status and priority ids, from the last read. */
125 meta: Record<string, { statusId: number | string; priorityId: number | string | null; assigneeId?: number | string | null }>
126 /** The key's owner (and, in Redmine, their groups), as the tracker said at the last sync. */
127 me?: (number | string)[]
128 statuses: { id: number | string; name: string; isClosed: boolean }[]
129 priorities: { id: number | string; name: string; isDefault: boolean }[]
130 /** Jira: the project's issue types new tasks can be created as. */
131 issueTypes?: { id: string; name: string }[]
132 lastSync?: string
133 lastError?: string
134 warnings: string[]
135}
136
137/** The project's sync as the pane draws it. */
138export type SyncView = { settings: SyncSettings | null; data: SyncData; isSyncing: boolean }
139
140declare module 'claude-code' {
141 interface PluginState {
142 'backlog-pane': {
143 phase: Phase
144 tasks: TaskSummary[]
145 statuses: string[]
146 priorities: string[]
147 selectedId: string | null
148 detail: TaskDetail | null
149 openGroups: string[]
150 notice: string | null
151 editing: string | null
152 isCreating: boolean
153 isBoardFailed: boolean
154 isManagingStatuses: boolean
155 newStatusAt: number | null
156 prefs: Prefs
157 prefsKey: string | null
158 sync: SyncView
159 isManagingSync: boolean
160 }
161 }
162}
163