@goodandready/dsh-kanban 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/filters.js CHANGED
@@ -1,243 +1,288 @@
1
- // Отборы доски: репозиторий и пространства меток.
2
- //
3
- // Пространства не зашиты списком. Метки живут в Gitea и меняются без нашего
4
- // участия — набор уже менялся дважды за час, — поэтому пространства собираются
5
- // из того, что реально пришло на карточки. Зашитый список начал бы врать при
6
- // первой же правке разметки.
7
-
8
- /** Отбор по репозиторию стоит в одном ряду с метками, но метка ему не нужна. */
9
- export const REPO = 'repo'
10
-
11
- /** Автор задачи — такое же измерение отбора, как метка, но живёт своим полем. */
12
- export const AUTHOR = 'author'
13
-
14
- /**
15
- * Значение для задач без автора.
16
- *
17
- * Свои задачи заводятся на доске и автора не имеют. Выбросить их из отбора
18
- * значило бы отвечать на вопрос «кто завёл» неполным списком: «никто» — тоже
19
- * ответ, и по нему тоже отбирают.
20
- */
21
- export const NO_AUTHOR = '—'
22
-
23
- /**
24
- * Порядок известных пространств.
25
- *
26
- * Алфавит поставил бы `priority` перед `type`, а смотрят в первую очередь на
27
- * тип и срочность. Незнакомые пространства дописываются в конец по алфавиту:
28
- * заведут в Gitea новое — оно появится само, без правки кода.
29
- */
30
- export const KNOWN_ORDER = ['type', 'priority', 'status', 'scope', 'risk', 'signal', 'release']
31
-
32
- /** Разбор метки на пространство и значение; без `/` — пространства нет. */
33
- export function splitLabel(name) {
34
- const text = String(name ?? '')
35
- const at = text.indexOf('/')
36
- if (at <= 0 || at === text.length - 1) return undefined
37
- return { ns: text.slice(0, at), value: text.slice(at + 1) }
38
- }
39
-
40
- /**
41
- * Из чего собрать отборы.
42
- *
43
- * Метки без пространства (`epic`, `hotfix`) своего отбора не получают: список
44
- * из двух значений ради двух меток — мебель. На карточках они видны и находятся
45
- * поиском.
46
- *
47
- * Считаем по ВСЕМ переданным задачам, а не по уже отобранным: счётчик рядом со
48
- * значением отвечает на вопрос «сколько там всего», а не «сколько осталось
49
- * после моего же выбора».
50
- *
51
- * @returns {Array<{ns: string, values: Array<{value: string, count: number}>}>}
52
- */
53
- export function facetsOf(tasks) {
54
- const byNs = new Map()
55
- const add = (ns, value) => {
56
- if (value === '') return
57
- if (!byNs.has(ns)) byNs.set(ns, new Map())
58
- const values = byNs.get(ns)
59
- values.set(value, (values.get(value) ?? 0) + 1)
60
- }
61
-
62
- for (const task of tasks ?? []) {
63
- add(REPO, typeof task?.repo === 'string' ? task.repo : '')
64
- add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
65
- for (const name of task?.labels ?? []) {
66
- const parsed = splitLabel(name)
67
- if (parsed !== undefined) add(parsed.ns, parsed.value)
68
- }
69
- }
70
-
71
- return [...byNs.entries()]
72
- .map(([ns, values]) => ({
73
- ns,
74
- values: [...values.entries()]
75
- .map(([value, count]) => ({ value, count }))
76
- .sort((a, b) => a.value.localeCompare(b.value)),
77
- }))
78
- .sort((a, b) => rank(a.ns) - rank(b.ns) || a.ns.localeCompare(b.ns))
79
- }
80
-
81
- /** Репозиторий первым, затем известные пространства, затем всё прочее. */
82
- function rank(ns) {
83
- if (ns === REPO) return -2
84
- if (ns === AUTHOR) return -1
85
- const at = KNOWN_ORDER.indexOf(ns)
86
- return at === -1 ? KNOWN_ORDER.length : at
87
- }
88
-
89
- /**
90
- * Подходит ли задача под выбранное.
91
- *
92
- * Между пространствами — И, внутри пространства — ИЛИ. «Срочные баги» это
93
- * `priority/high` И `type/bug`; а выбор `high` вместе с `critical` означает
94
- * «любая из двух», потому что срочность у задачи одна и «И» внутри
95
- * пространства всегда давало бы пусто.
96
- *
97
- * @param {object} selected карта пространство -> список выбранных значений
98
- */
99
- export function matchesFilters(task, selected) {
100
- for (const [ns, values] of Object.entries(selected ?? {})) {
101
- const wanted = values ?? []
102
- if (wanted.length === 0) continue
103
- if (!hasAny(task, ns, wanted)) return false
104
- }
105
- return true
106
- }
107
-
108
- function hasAny(task, ns, wanted) {
109
- if (ns === REPO) return wanted.includes(task?.repo ?? '')
110
- if (ns === AUTHOR) {
111
- const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
112
- return wanted.includes(author)
113
- }
114
- for (const name of task?.labels ?? []) {
115
- const parsed = splitLabel(name)
116
- if (parsed !== undefined && parsed.ns === ns && wanted.includes(parsed.value)) return true
117
- }
118
- return false
119
- }
120
-
121
- /** Выбрано ли хоть что-нибудь: пустой набор отборов доску не сужает. */
122
- export function anySelected(selected) {
123
- return Object.values(selected ?? {}).some((values) => (values ?? []).length > 0)
124
- }
125
-
126
- /**
127
- * Переключить одно значение.
128
- *
129
- * Возвращает новую карту, а не правит прежнюю: состояние отборов живёт в
130
- * React, и правка на месте не вызвала бы перерисовку.
131
- */
132
- export function toggleValue(selected, ns, value) {
133
- const current = (selected ?? {})[ns] ?? []
134
- const next = current.includes(value)
135
- ? current.filter((v) => v !== value)
136
- : [...current, value]
137
- const out = { ...(selected ?? {}) }
138
- if (next.length === 0) delete out[ns]
139
- else out[ns] = next
140
- return out
141
- }
142
-
143
- /** Снять всё разом. */
144
- export function clearFilters() {
145
- return {}
146
- }
147
-
148
- /**
149
- * Метки задачи, разобранные по пространствам.
150
- *
151
- * Едет на карточке вместе с задачей: браузерная половина — отдельный бандл и
152
- * позвать этот модуль не может, а разбор, написанный там во второй раз,
153
- * однажды разойдётся с этим.
154
- *
155
- * @returns {object} карта пространство -> список значений
156
- */
157
- export function facetsOfTask(task) {
158
- const out = {}
159
- for (const name of task?.labels ?? []) {
160
- const parsed = splitLabel(name)
161
- if (parsed === undefined) continue
162
- if (out[parsed.ns] === undefined) out[parsed.ns] = []
163
- if (!out[parsed.ns].includes(parsed.value)) out[parsed.ns].push(parsed.value)
164
- }
165
- return out
166
- }
167
-
168
- /**
169
- * Цвет метки из ответа Gitea.
170
- *
171
- * Приходит шестизначным кодом без решётки (`0e8a16`), но встречается и с ней.
172
- * Всё, что не похоже на шестнадцатеричный код, считаем отсутствием цвета:
173
- * значение попадает прямо в стиль, и пускать туда что попало нельзя.
174
- *
175
- * @returns {string|undefined} код без решётки
176
- */
177
- export function labelColor(value) {
178
- const text = String(value ?? '').trim().replace(/^#/, '')
179
- return /^[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : undefined
180
- }
181
-
182
- /**
183
- * Карта имя метки -> цвет из списка меток issue.
184
- *
185
- * Держим отдельно от имён: имена читают отборы, сверка, чип и поиск, и менять
186
- * их устройство ради оформления было бы дорого и ни к чему.
187
- */
188
- export function colorsOfIssue(labels) {
189
- const out = {}
190
- for (const l of labels ?? []) {
191
- const name = typeof l === 'string' ? l : l?.name
192
- if (typeof name !== 'string' || name === '') continue
193
- const color = labelColor(typeof l === 'string' ? undefined : l?.color)
194
- if (color !== undefined) out[name] = color
195
- }
196
- return out
197
- }
198
-
199
- /**
200
- * Вес задачи при подъёме наверх колонки.
201
- *
202
- * Ждущие впереди идущих намеренно: идущая работа идёт сама, а ждущая стоит
203
- * из-за человека, и он должен увидеть её первой.
204
- *
205
- * Ручной порядок не переписывается — внутри каждой ступени он сохраняется, а
206
- * подъём работает поверх него.
207
- */
208
- export function liveRank(task) {
209
- const state = task?.state
210
- if (task?.waiting === true || state === 'waiting') return 0
211
- if (state === 'running') return 1
212
- if (state === 'stopped') return 2
213
- return 3
214
- }
215
-
216
- /** Поднять идущее наверх, не теряя прежнего порядка. */
217
- export function sortByLife(tasks) {
218
- return (tasks ?? [])
219
- .map((task, at) => ({ task, at }))
220
- .sort((a, b) => liveRank(a.task) - liveRank(b.task) || a.at - b.at)
221
- .map((x) => x.task)
222
- }
223
-
224
- /**
225
- * Разложить задачи по проектам.
226
- *
227
- * Крупные группы впереди: проект с двадцатью задачами не должен теряться под
228
- * однозадачными. При равенстве — по имени, чтобы порядок не плясал между
229
- * обновлениями.
230
- *
231
- * @returns {Array<{repo: string, tasks: Array<object>}>}
232
- */
233
- export function groupByRepo(tasks) {
234
- const byRepo = new Map()
235
- for (const task of tasks ?? []) {
236
- const repo = typeof task?.repo === 'string' && task.repo !== '' ? task.repo : ''
237
- if (!byRepo.has(repo)) byRepo.set(repo, [])
238
- byRepo.get(repo).push(task)
239
- }
240
- return [...byRepo.entries()]
241
- .map(([repo, list]) => ({ repo, tasks: list }))
242
- .sort((a, b) => b.tasks.length - a.tasks.length || a.repo.localeCompare(b.repo))
243
- }
1
+ // Отборы доски: репозиторий и пространства меток.
2
+ //
3
+ // Пространства не зашиты списком. Метки живут в Gitea и меняются без нашего
4
+ // участия — набор уже менялся дважды за час, — поэтому пространства собираются
5
+ // из того, что реально пришло на карточки. Зашитый список начал бы врать при
6
+ // первой же правке разметки.
7
+
8
+ /** Отбор по репозиторию стоит в одном ряду с метками, но метка ему не нужна. */
9
+ export const REPO = 'repo'
10
+
11
+ /** Автор задачи — такое же измерение отбора, как метка, но живёт своим полем. */
12
+ export const AUTHOR = 'author'
13
+
14
+ /** Приоритет — локальное свойство доски, своё измерение отбора. */
15
+ export const PRIORITY = 'priority'
16
+
17
+ /**
18
+ * Значение для задач без автора.
19
+ *
20
+ * Свои задачи заводятся на доске и автора не имеют. Выбросить их из отбора
21
+ * значило бы отвечать на вопрос «кто завёл» неполным списком: «никто» — тоже
22
+ * ответ, и по нему тоже отбирают.
23
+ */
24
+ export const NO_AUTHOR = '—'
25
+
26
+ /** Порядок приоритетов в отборе: срочное сверху. */
27
+ export const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
28
+
29
+ /**
30
+ * Порядок известных пространств.
31
+ *
32
+ * Алфавит поставил бы `priority` перед `type`, а смотрят в первую очередь на
33
+ * тип и срочность. Незнакомые пространства дописываются в конец по алфавиту:
34
+ * заведут в Gitea новое — оно появится само, без правки кода.
35
+ */
36
+ export const KNOWN_ORDER = ['type', 'priority', 'status', 'scope', 'risk', 'signal', 'release']
37
+
38
+ /** Разбор метки на пространство и значение; без `/` — пространства нет. */
39
+ export function splitLabel(name) {
40
+ const text = String(name ?? '')
41
+ const at = text.indexOf('/')
42
+ if (at <= 0 || at === text.length - 1) return undefined
43
+ return { ns: text.slice(0, at), value: text.slice(at + 1) }
44
+ }
45
+
46
+ /**
47
+ * Из чего собрать отборы.
48
+ *
49
+ * Метки без пространства (`epic`, `hotfix`) своего отбора не получают: список
50
+ * из двух значений ради двух меток — мебель. На карточках они видны и находятся
51
+ * поиском.
52
+ *
53
+ * Считаем по ВСЕМ переданным задачам, а не по уже отобранным: счётчик рядом со
54
+ * значением отвечает на вопрос «сколько там всего», а не «сколько осталось
55
+ * после моего же выбора».
56
+ *
57
+ * @returns {Array<{ns: string, values: Array<{value: string, count: number}>}>}
58
+ */
59
+ export function facetsOf(tasks) {
60
+ const byNs = new Map()
61
+ const add = (ns, value) => {
62
+ if (value === '') return
63
+ if (!byNs.has(ns)) byNs.set(ns, new Map())
64
+ const values = byNs.get(ns)
65
+ values.set(value, (values.get(value) ?? 0) + 1)
66
+ }
67
+
68
+ for (const task of tasks ?? []) {
69
+ add(REPO, typeof task?.repo === 'string' ? task.repo : '')
70
+ add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
71
+ // Приоритет живёт и полем (свои карточки), и меткой `priority/*` (задачи
72
+ // из Gitea). Оба попадают в одно измерение — отбор по срочности не должен
73
+ // зависеть от того, откуда пришло значение.
74
+ add(PRIORITY, typeof task?.priority === 'string' ? task.priority : '')
75
+ for (const name of task?.labels ?? []) {
76
+ const parsed = splitLabel(name)
77
+ if (parsed !== undefined) add(parsed.ns, parsed.value)
78
+ }
79
+ }
80
+
81
+ return [...byNs.entries()]
82
+ .map(([ns, values]) => ({
83
+ ns,
84
+ values: [...values.entries()]
85
+ .map(([value, count]) => ({ value, count }))
86
+ .sort((a, b) => valueRank(ns, a.value) - valueRank(ns, b.value) || a.value.localeCompare(b.value)),
87
+ }))
88
+ .sort((a, b) => rank(a.ns) - rank(b.ns) || a.ns.localeCompare(b.ns))
89
+ }
90
+
91
+ /** Репозиторий первым, затем известные пространства, затем всё прочее. */
92
+ function rank(ns) {
93
+ if (ns === REPO) return -2
94
+ if (ns === AUTHOR) return -1
95
+ const at = KNOWN_ORDER.indexOf(ns)
96
+ return at === -1 ? KNOWN_ORDER.length : at
97
+ }
98
+
99
+ /**
100
+ * Порядок значений внутри пространства.
101
+ *
102
+ * Приоритет — «срочное сначала», а не алфавит: `critical` сперва, `low`
103
+ * в самом низу. Для остальных — обычное сравнение строк.
104
+ */
105
+ function valueRank(ns, value) {
106
+ if (ns !== PRIORITY) return 0
107
+ const at = PRIORITY_ORDER.indexOf(value)
108
+ return at === -1 ? PRIORITY_ORDER.length : at
109
+ }
110
+
111
+ /**
112
+ * Подходит ли задача под выбранное.
113
+ *
114
+ * Между пространствами — И, внутри пространства — ИЛИ. «Срочные баги» это
115
+ * `priority/high` И `type/bug`; а выбор `high` вместе с `critical` означает
116
+ * «любая из двух», потому что срочность у задачи одна и «И» внутри
117
+ * пространства всегда давало бы пусто.
118
+ *
119
+ * @param {object} selected карта пространство -> список выбранных значений
120
+ */
121
+ export function matchesFilters(task, selected) {
122
+ for (const [ns, values] of Object.entries(selected ?? {})) {
123
+ const wanted = values ?? []
124
+ if (wanted.length === 0) continue
125
+ if (!hasAny(task, ns, wanted)) return false
126
+ }
127
+ return true
128
+ }
129
+
130
+ function hasAny(task, ns, wanted) {
131
+ if (ns === REPO) return wanted.includes(task?.repo ?? '')
132
+ if (ns === AUTHOR) {
133
+ const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
134
+ return wanted.includes(author)
135
+ }
136
+ if (ns === PRIORITY) {
137
+ // Приоритет и полем, и меткой `priority/*` — отбор по срочности не должен
138
+ // зависеть от источника значения.
139
+ if (wanted.includes(typeof task?.priority === 'string' ? task.priority : '')) return true
140
+ for (const name of task?.labels ?? []) {
141
+ const parsed = splitLabel(name)
142
+ if (parsed !== undefined && parsed.ns === PRIORITY && wanted.includes(parsed.value)) return true
143
+ }
144
+ return false
145
+ }
146
+ for (const name of task?.labels ?? []) {
147
+ const parsed = splitLabel(name)
148
+ if (parsed !== undefined && parsed.ns === ns && wanted.includes(parsed.value)) return true
149
+ }
150
+ return false
151
+ }
152
+
153
+ /** Выбрано ли хоть что-нибудь: пустой набор отборов доску не сужает. */
154
+ export function anySelected(selected) {
155
+ return Object.values(selected ?? {}).some((values) => (values ?? []).length > 0)
156
+ }
157
+
158
+ /**
159
+ * Переключить одно значение.
160
+ *
161
+ * Возвращает новую карту, а не правит прежнюю: состояние отборов живёт в
162
+ * React, и правка на месте не вызвала бы перерисовку.
163
+ */
164
+ export function toggleValue(selected, ns, value) {
165
+ const current = (selected ?? {})[ns] ?? []
166
+ const next = current.includes(value)
167
+ ? current.filter((v) => v !== value)
168
+ : [...current, value]
169
+ const out = { ...(selected ?? {}) }
170
+ if (next.length === 0) delete out[ns]
171
+ else out[ns] = next
172
+ return out
173
+ }
174
+
175
+ /** Снять всё разом. */
176
+ export function clearFilters() {
177
+ return {}
178
+ }
179
+
180
+ /**
181
+ * Метки задачи, разобранные по пространствам.
182
+ *
183
+ * Едет на карточке вместе с задачей: браузерная половина — отдельный бандл и
184
+ * позвать этот модуль не может, а разбор, написанный там во второй раз,
185
+ * однажды разойдётся с этим.
186
+ *
187
+ * @returns {object} карта пространство -> список значений
188
+ */
189
+ export function facetsOfTask(task) {
190
+ const out = {}
191
+ for (const name of task?.labels ?? []) {
192
+ const parsed = splitLabel(name)
193
+ if (parsed === undefined) continue
194
+ if (out[parsed.ns] === undefined) out[parsed.ns] = []
195
+ if (!out[parsed.ns].includes(parsed.value)) out[parsed.ns].push(parsed.value)
196
+ }
197
+ return out
198
+ }
199
+
200
+ /**
201
+ * Цвет метки из ответа Gitea.
202
+ *
203
+ * Приходит шестизначным кодом без решётки (`0e8a16`), но встречается и с ней.
204
+ * Всё, что не похоже на шестнадцатеричный код, считаем отсутствием цвета:
205
+ * значение попадает прямо в стиль, и пускать туда что попало нельзя.
206
+ *
207
+ * @returns {string|undefined} код без решётки
208
+ */
209
+ export function labelColor(value) {
210
+ const text = String(value ?? '').trim().replace(/^#/, '')
211
+ return /^[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : undefined
212
+ }
213
+
214
+ /**
215
+ * Карта имя метки -> цвет из списка меток issue.
216
+ *
217
+ * Держим отдельно от имён: имена читают отборы, сверка, чип и поиск, и менять
218
+ * их устройство ради оформления было бы дорого и ни к чему.
219
+ */
220
+ export function colorsOfIssue(labels) {
221
+ const out = {}
222
+ for (const l of labels ?? []) {
223
+ const name = typeof l === 'string' ? l : l?.name
224
+ if (typeof name !== 'string' || name === '') continue
225
+ const color = labelColor(typeof l === 'string' ? undefined : l?.color)
226
+ if (color !== undefined) out[name] = color
227
+ }
228
+ return out
229
+ }
230
+
231
+ /**
232
+ * Вес задачи при подъёме наверх колонки.
233
+ *
234
+ * Ждущие впереди идущих намеренно: идущая работа идёт сама, а ждущая стоит
235
+ * из-за человека, и он должен увидеть её первой.
236
+ *
237
+ * Ручной порядок не переписывается — внутри каждой ступени он сохраняется, а
238
+ * подъём работает поверх него.
239
+ */
240
+ export function liveRank(task) {
241
+ const state = task?.state
242
+ if (task?.waiting === true || state === 'waiting') return 0
243
+ if (state === 'running') return 1
244
+ if (state === 'stopped') return 2
245
+ return 3
246
+ }
247
+
248
+ /** Вес приоритета при подъёме: срочное впереди тихого. */
249
+ export function priorityWeight(priority) {
250
+ const at = PRIORITY_ORDER.indexOf(priority)
251
+ return at === -1 ? PRIORITY_ORDER.length : at
252
+ }
253
+
254
+ /**
255
+ * Поднять идущее наверх и приоритетное следом, не теряя прежнего порядка.
256
+ *
257
+ * Две независимые оси: сначала жизнь (ждёт/идёт/стоит), затем срочность
258
+ * (critical → low). Ручной порядок внутри ступени сохраняется.
259
+ */
260
+ export function sortByLife(tasks) {
261
+ return (tasks ?? [])
262
+ .map((task, at) => ({ task, at }))
263
+ .sort((a, b) => liveRank(a.task) - liveRank(b.task)
264
+ || priorityWeight(a.task?.priority) - priorityWeight(b.task?.priority)
265
+ || a.at - b.at)
266
+ .map((x) => x.task)
267
+ }
268
+
269
+ /**
270
+ * Разложить задачи по проектам.
271
+ *
272
+ * Крупные группы впереди: проект с двадцатью задачами не должен теряться под
273
+ * однозадачными. При равенстве — по имени, чтобы порядок не плясал между
274
+ * обновлениями.
275
+ *
276
+ * @returns {Array<{repo: string, tasks: Array<object>}>}
277
+ */
278
+ export function groupByRepo(tasks) {
279
+ const byRepo = new Map()
280
+ for (const task of tasks ?? []) {
281
+ const repo = typeof task?.repo === 'string' && task.repo !== '' ? task.repo : ''
282
+ if (!byRepo.has(repo)) byRepo.set(repo, [])
283
+ byRepo.get(repo).push(task)
284
+ }
285
+ return [...byRepo.entries()]
286
+ .map(([repo, list]) => ({ repo, tasks: list }))
287
+ .sort((a, b) => b.tasks.length - a.tasks.length || a.repo.localeCompare(b.repo))
288
+ }
package/lib/index.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  MAX_BODY_BYTES, isTrustedRequest, parseTaskPath,
23
23
  buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
24
24
  taskBody,
25
- taskLog, taskBySession,
25
+ taskLog, taskBySession, exportBoard, importBoard,
26
26
  } from './routes.js'
27
27
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
28
28
  import {
@@ -519,6 +519,28 @@ export function apply(ctx, config) {
519
519
  },
520
520
  }), 'dsh-kanban: маршрут доски')
521
521
 
522
+ // Снимок доски — ОДИН ресурс с двумя действиями, а не две ручки.
523
+ //
524
+ // Прежде выгрузка звалась `/export`, а загрузка занимала `/import` — тот
525
+ // самый путь, на котором с первого дня живёт импорт issue из Gitea. Два
526
+ // обработчика на одном пути означают, что один из них не отвечает вовсе, и
527
+ // какой именно — решает порядок регистрации, а не мы.
528
+ ctx.effect(() => ctx.webServer.register({
529
+ kind: 'exact',
530
+ path: '/dsh-kanban/snapshot',
531
+ handler: async (req, res) => {
532
+ if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
533
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
534
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
535
+ try {
536
+ const input = await readBody(req)
537
+ reply(res, importBoard({ store, input }))
538
+ } catch {
539
+ writeJson(res, 400, { error: 'bad-request' })
540
+ }
541
+ },
542
+ }), 'dsh-kanban: снимок доски')
543
+
522
544
  ctx.effect(() => ctx.webServer.register({
523
545
  // Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
524
546
  // сводится к равенству пути, и всё, что глубже, уходит в статику.
@@ -556,6 +578,17 @@ export function apply(ctx, config) {
556
578
  }
557
579
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
558
580
  if (req.method === 'DELETE' && action === undefined) { reply(res, deleteTask({ store, id })); return }
581
+ // Правка полей карточки: title, body, метки, приоритет. Приоритет —
582
+ // локальное свойство доски, в Gitea не пишется (updateTask это знает).
583
+ if (req.method === 'POST' && action === 'update') {
584
+ try {
585
+ const input = await readBody(req)
586
+ reply(res, updateTask({ store, id, input }))
587
+ } catch {
588
+ writeJson(res, 400, { error: 'bad-request' })
589
+ }
590
+ return
591
+ }
559
592
  if (req.method === 'POST' && action === 'refresh') {
560
593
  reply(res, await refreshTask({ gitea, store, id }))
561
594
  return