SLOPSHOPPER

backlog-pane

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

newpanecommandprocessnetworktimer
v0.6.0MITupdated 2026-10-26ezmg/backlog-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · backlog-pane
│ ┃ Backlog ✕ › fix the failing auth test and add an audit log call │ ┃ Loading tasks… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /backlog │ ⎿ backlog-pane: Backlog pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Backlog
Loading tasks…
README

<img src=".claude-plugin/icon.png" alt="" width="96" align="right">

backlog-pane

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 backlog CLI 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.

Features

  • Live board. On the terminal and the desktop app, one column per status in your configured order, headed by the status as a chip in its colour. Each card is a box bordered in its status's colour, with its title (on two lines if it needs them), its ID and its criteria done; a card moved to another column takes that column's colour. Drag a card to another column to move it, or use the keyboard (see Shortcuts). The final status (usually Done) shows only its most recent tasks. VS Code and mobile show the same tasks as a list grouped by status, with the final status folded.
  • Statuses, from the pane. Statuses… lists every status with its task count. There you add one and pick where it goes, delete one that no task is in, pick each status's colour, and tick Closed for statuses such as Cancelled. Closed ones are drawn dim and folded like the final status, and ◀/▶ and the board's arrow keys skip them; you can still move a task there with the status picker or by dragging.
  • Colours that follow your theme. Statuses use theme colours, so they suit light and dark themes. Unless you pick one, the first status, an In Progress one, and the final one each have their own, and the others take turns. High-priority tasks get a ! in the error colour, so the meaning does not depend on colour alone.
  • New task. + New task opens a title field. Cancel closes it, and it closes by itself once the task is created.
  • Move a task. In the task view, ◀ previous status and next status ▶ (keys h and l) move it one step, on every surface. The status picker jumps anywhere.
  • Edit a task. Title, priority (from backlog config get priorities, else High/Medium/Low), and acceptance criteria. You can tick, add, edit, and delete criteria; delete asks first.
  • Long text. Edit the description and the implementation plan in a field of several lines (Claude Code 2.1.301 and later), or in your own editor on older versions (see Editing long text).
  • Work on this task drafts this prompt, using the task's real ID (whatever your project's prefix is):
  Work on BL-7 only. Research the codebase and write an implementation plan in the task. Wait for my approval before coding.
  • Edit with Claude drafts 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.

  • Redmine sync. Integration… links the project to a Redmine project, read only or both ways. Issues become tasks and stay in step field by field, with a button and every few minutes. See Integration (Redmine).
  • Jira sync. The same as Redmine's, with Jira Cloud or Data Center / Server: read only or both ways, all issues or only yours, conflicts asked. See Integration (Jira).
  • Excel / CSV, with no API key. Export the issues from Redmine as CSV and the pane reads them into tasks, read only, when you press Sync now. See Integration (Excel / CSV).
  • English and Spanish. The pane, the command and both drafts. See the language option.
  • CLI only, with one exception. Every change to a task runs a 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).
  • Clear errors. The pane tells you, with a Retry button, when the CLI is not installed, the folder has no Backlog.md project, or the live list keeps failing.

Shortcuts

Give the pane the keyboard with ctrl+x then tab, or click it. Tab moves between controls and Esc returns to the prompt.

WhereKey or actionWhat it does
Board (terminal, desktop)Drag a card to another columnMoves the task there.
Board (terminal, desktop)Click a card, then a free spot in another columnMoves the selected card there.
BoardClick the selected card againOpens the task.
BoardClick a free spotLets 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 itEnterOpens the selected task.
BoardEscGives the keyboard back.
Task viewhMoves the task to the previous open status (◀).
Task viewlMoves the task to the next open status (▶).
Multi-line fieldEnterSaves.
Multi-line fieldShift+Enter, or \ then EnterStarts a new line.

h and l work while the pane has the keyboard, not while you type in the prompt box.

Statuses

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:

  • Add one: pick its Position (at the start, or after any status; by default before the final one), type its name and press Enter. A name can't be empty, repeat another (whatever the case), hold a comma, a quote or a line break, or pass 40 characters.
  • Delete one: Delete shows only beside a status no task is in, and asks first. The status new tasks start in (backlog config get defaultStatus) is kept, and a project always keeps at least two.

Backlog.md has no command for this, so the pane:

  1. finds the config as Backlog.md does, from the session's folder up;
  2. rewrites only its statuses: line, every other line left as it was;
  3. runs 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:

  • Colour: one of the theme's colours (Blue, Steel blue, Teal, Green, Amber, Orange, Red, Purple, Lavender, Grey), or Automatic for the one the pane would give it. The status's chip, its cards' borders and its swatch take it at once. Mobile cycles through them with a button.
  • Closed: a closed status is folded and dim, and ◀/▶ and the board's arrow keys skip it. The final status is always final, so it has no box.

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.

Integration (Redmine)

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:

  1. Get your API key in Redmine (My account → API access key; the server must have Enable REST web service on).
  2. Put it in the plugin's Redmine API key option (/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.
  3. In the pane, press Integration…, pick Redmine as the tracker, and type the server's URL (https://redmine.example.com) and the project's identifier (as in its URL, for example my-app).
  4. Pick the Mode, which Issues (all of the project, or only those assigned to you) and how often to Sync, then Sync now.

Modes:

  • Read only (Redmine → Backlog.md), the default: nothing is ever written to Redmine. A field changed in Redmine overwrites the task's. A change you make only in Backlog.md stays there, until Redmine changes that field.
  • Both ways: a field changed on one side is copied to the other. New Backlog.md tasks are created in Redmine too, and get the new issue's URL in their references.

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.

Keeping the API key safe

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:

  • macOS keeps it in the Keychain, which asks before a program reads it.
  • Windows and Linux keep it in ~/.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:

  1. Deny reading it, in ~/.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.

  1. Give the key only the rights it needs. Use a Redmine user whose role can only view issues if you sync read only, so a leaked key can change nothing.
  2. Regenerate it in Redmine (My account → API access key) if you think it leaked. The old one stops working at once.

Integration (Jira)

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:

  1. Get a token:
  2. Cloud: an API token at id.atlassian.com → Security → API tokens. Put it in the plugin's Jira API token option, and your Atlassian account's email in Jira email.
  3. Data Center / Server: a personal access token (Profile → Personal Access Tokens). Put it in Jira API token and leave Jira email empty.

The token is a sensitive option, kept in secure storage like Redmine's key (see Keeping the API key safe).

  1. In Integration…, pick Jira (API), and type the site's URL (https://your-company.atlassian.net) and the project's key (the letters of its issue keys: APP for APP-12).
  2. Pick the mode, the issues and the interval, then Sync now.

What is different from Redmine:

  • Statuses move by transitions. Jira does not let a status be set: an issue moves along its workflow. The pane uses the transition to the task's status when one is open from where the issue is. If the workflow has none (say, To Do straight to Done), the status is not sent, the pane says so, and it tries again on the next sync. Move the issue in Jira, or one step at a time in Backlog.md.
  • The default mapping uses Jira's status categories: To Do ones go to the first status, In Progress ones to yours, Done ones to the final one. Priorities map by name (Highest and High → High, Medium → Medium, Low and Lowest → Low).
  • New tasks (both ways) are created as a Task (or the first issue type the project has; Issue type for new tasks picks another), then moved along to the task's status. With only mine, they are assigned to you.
  • Descriptions go across as text: Jira's wiki markup one way, Backlog.md's Markdown the other, with no conversion.
  • References hold each issue's page: 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.

Integration (Excel / CSV)

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:

  1. In Redmine, open Issues and filter them as you like (for example Assignee: «me», to get only yours). At the bottom, under Also available in, click CSV, tick Description, and Export.
  2. Save the file, say C:\Users\you\Downloads\issues.csv.
  3. In the pane, press Integration…, pick Excel / CSV (Redmine export) as the tracker, and type the file's path (absolute, relative to the session's folder, or starting with ~).
  4. Optionally, type your Redmine's URL. The tasks' references then hold each issue's URL (…/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.
  5. Press Sync now.

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.

Editing long text

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:

  1. backlog config get defaultEditor
  2. VISUAL
  3. EDITOR
  4. Notepad, on Windows

The 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.

Screenshots

<!-- 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.

Requirements

  • Claude Code 2.1.295 or later, with mods: the CLI, or the Code tab of the Claude Desktop app. Earlier releases cannot draw the task rows (buttons with children arrived in 2.1.295). The inline multi-line field needs 2.1.301; before that, the external editor stands in. Developed against 2.1.310 and checked with 2.1.296 (see Development). plugin.json has no field for a minimum version, so it is stated here only.
  • Backlog.md CLI on the machine, with JSON output (schemaVersion 1). Tested with 1.55.3.
  npm i -g backlog.md

bun add -g backlog.md and brew install backlog-md also work.

  • A project initialized with backlog init. The pane uses the session's working folder.

Install

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.

Usage

  1. Open a session in a folder that has a Backlog.md project.
  2. Run /backlog.
  3. Drag cards between columns, or click a card twice to open it. On VS Code and mobile, tap a task in the list.
  4. In a task, change the status, title, priority, criteria, description or plan. Or press Work on this task or Edit with Claude, and review the draft before you press Enter.

To close the pane, click its close mark or press ctrl+x then x. Closing it stops the --watch process.

Options

OptionDefaultWhat 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.
doneLimit5How 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

Source 10 files
hooks/register.tsx 2255 lines
1// 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 lines
1// 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}
539
hooks/i18n.ts 569 lines
1// 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}
569
hooks/jira.ts 271 lines
1// 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 })
271
hooks/redmine.ts 246 lines
1// 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}
246
hooks/sync.ts 245 lines
1// 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`
245
hooks/csv.ts 199 lines
1// 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}
199
hooks/board.tsx 273 lines
1// 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
273
hooks/base64.ts 35 lines
1// 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}
35
types/index.d.ts 163 lines
1// 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