local-kanban 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/public/js/core.js +7 -0
- package/public/js/settings.js +45 -2
- package/public/js/sidebar.js +4 -1
- package/public/style.css +13 -0
- package/src/routes/system.js +52 -13
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
3
3
|
Notable changes, newest first. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
4
4
|
and the project uses [semantic versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
Anything not shipped yet is collected under **Unreleased**; releasing renames that section to the
|
|
7
|
+
version it goes out as and opens an empty one again. Released sections are history and stay as they are.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.13.0]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **The board tells you what's new.** The changelog was written for every release and shown nowhere:
|
|
16
|
+
the only place to read it was the package page on npm. The About screen now has a "What's new"
|
|
17
|
+
block. For the version you are running it reads the `CHANGELOG.md` that ships next to the code, so
|
|
18
|
+
it needs no network at all. When an update is waiting, it shows the notes of the version you do
|
|
19
|
+
*not* have yet, read from that release's page on GitHub — the decision to update is made on what
|
|
20
|
+
actually changed, not on a version number. Releases now get such a page: publishing puts the
|
|
21
|
+
changelog section for that version into it, which also gives the npm page real releases to link to.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **A folder the board is not allowed to read is no longer reported as missing.** The Check button
|
|
26
|
+
next to the project folder answered every refusal the same way — "no such folder on this computer" —
|
|
27
|
+
whether the folder was absent, was a file, or simply could not be read. For a folder without
|
|
28
|
+
permissions that is plainly untrue: it is there, the board was not let in, and the answer sent you
|
|
29
|
+
off to fix a path that was already correct. Each case now says what actually happened, and a check
|
|
30
|
+
that could not reach the server says so instead of blaming the path.
|
|
31
|
+
|
|
6
32
|
## [1.12.0]
|
|
7
33
|
|
|
8
34
|
### Added
|
package/package.json
CHANGED
package/public/js/core.js
CHANGED
|
@@ -375,9 +375,11 @@ const I18N_RU = {
|
|
|
375
375
|
'Claude moves these columns itself': 'эти колонки двигает сам Claude',
|
|
376
376
|
'no file': 'файла нет',
|
|
377
377
|
'no folders inside': 'внутри нет папок',
|
|
378
|
+
'no notes for this version': 'для этой версии заметок нет',
|
|
378
379
|
'no such folder on this computer': 'такой папки на этом компьютере нет',
|
|
379
380
|
'not deleted': 'не удалено',
|
|
380
381
|
'not installed': 'не установлен',
|
|
382
|
+
'not installed yet': 'ещё не установлена',
|
|
381
383
|
'not set': 'не задан',
|
|
382
384
|
'not set — no deploy': 'не задан — деплоя нет',
|
|
383
385
|
'not there': 'нет файла',
|
|
@@ -425,11 +427,13 @@ const I18N_RU = {
|
|
|
425
427
|
'subfolders': 'подпапок',
|
|
426
428
|
'key is free': 'ключ свободен',
|
|
427
429
|
'task prefix': 'префикс задач',
|
|
430
|
+
'the folder is there, but the board cannot read it — check its permissions': 'папка есть, но доска не может её прочитать — посмотри права доступа',
|
|
428
431
|
'this key is already taken by': 'этот ключ уже занят проектом',
|
|
429
432
|
'no such host — check the name, or add it to your ~/.ssh/config': 'такого хоста нет — проверь имя или добавь его в свой ~/.ssh/config',
|
|
430
433
|
'the server refused the key — a password will not help here, the key has to be authorized': 'сервер не принял ключ — пароль тут не поможет, ключ надо разрешить на сервере',
|
|
431
434
|
'the host key is unknown — connect once from your terminal and confirm the fingerprint': 'ключ хоста неизвестен — подключись разок из терминала и подтверди отпечаток',
|
|
432
435
|
'the server did not answer — wrong address, wrong port, or it is down': 'сервер не ответил — не тот адрес, не тот порт или он лежит',
|
|
436
|
+
'could not check the folder': 'не удалось проверить папку',
|
|
433
437
|
'could not connect': 'подключиться не вышло',
|
|
434
438
|
'on create, git clone puts it on THIS computer at': 'при создании git clone положит его на ЭТОТ компьютер в',
|
|
435
439
|
'An existing folder is refused, not overwritten. Git credentials are not asked here — access comes from your own git and ssh setup.': 'Если папка уже есть — откажет, а не перезапишет. Логин git тут не спрашивается: доступ берётся из твоих git-настроек и ssh-ключей.',
|
|
@@ -535,6 +539,7 @@ const I18N_RU = {
|
|
|
535
539
|
'recovered on retry': 'починилось повторной попыткой',
|
|
536
540
|
'related': 'связана',
|
|
537
541
|
'Read': 'Открыть',
|
|
542
|
+
'release page': 'страница релиза',
|
|
538
543
|
'Reload now': 'Обновить сейчас',
|
|
539
544
|
'Reload the page': 'Обновить страницу',
|
|
540
545
|
'remove': 'убрать',
|
|
@@ -636,6 +641,7 @@ const I18N_RU = {
|
|
|
636
641
|
'The board could not reach the server — the change was not saved': 'доска не достучалась до сервера — правка не сохранена',
|
|
637
642
|
'capped: the task sat in a working column too long': 'урезано: задача слишком долго лежала в рабочей колонке',
|
|
638
643
|
'To do': 'Сделать',
|
|
644
|
+
'this path is not a folder': 'по этому пути не папка',
|
|
639
645
|
'Today': 'Сегодня',
|
|
640
646
|
'Top projects': 'Топ проектов',
|
|
641
647
|
'Tue': 'Вт',
|
|
@@ -657,6 +663,7 @@ const I18N_RU = {
|
|
|
657
663
|
'waiting for the board to come back…': 'жду, пока доска поднимется…',
|
|
658
664
|
'Waiting for you': 'Ждёт тебя',
|
|
659
665
|
'Wed': 'Ср',
|
|
666
|
+
"What's new": 'Что нового',
|
|
660
667
|
'Week': 'Неделя',
|
|
661
668
|
'Weeks': 'Недели',
|
|
662
669
|
'Welcome!': 'Добро пожаловать!',
|
package/public/js/settings.js
CHANGED
|
@@ -6,6 +6,35 @@ import { checkVersion, pendingReload } from './init.js';
|
|
|
6
6
|
import { loadProjects, popLayer, pushLayer, setupPromptBlockHTML, styledAlert, styledConfirm, styledPrompt, wireSetupPrompt } from './sidebar.js';
|
|
7
7
|
import { SHORT_MONTHS, SOUND_LIB, STATUS_LABELS, audioCtx, lastMissedAt, lastSseAt, missedSounds, pageLoadedAt, playNamedSound, refresh, relTime, titleOr, updateSoundBadge } from './sse.js';
|
|
8
8
|
|
|
9
|
+
function mdInline(text) {
|
|
10
|
+
return esc(text)
|
|
11
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
12
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
13
|
+
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>')
|
|
14
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
15
|
+
}
|
|
16
|
+
export function mdLite(md) {
|
|
17
|
+
const out = [];
|
|
18
|
+
let list = false;
|
|
19
|
+
const closeList = () => { if (list) { out.push('</ul>'); list = false; } };
|
|
20
|
+
for (const block of String(md || '').split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean)) {
|
|
21
|
+
const flat = block.split('\n').map((l) => l.trim()).join(' ');
|
|
22
|
+
const head = flat.match(/^#{1,6}\s+(.*)$/);
|
|
23
|
+
if (head) { closeList(); out.push(`<h4>${mdInline(head[1])}</h4>`); continue; }
|
|
24
|
+
if (/^[-*]\s+/.test(block)) {
|
|
25
|
+
if (!list) { out.push('<ul>'); list = true; }
|
|
26
|
+
for (const item of block.split(/\n(?=[-*]\s)/)) {
|
|
27
|
+
out.push(`<li>${mdInline(item.replace(/^[-*]\s+/, '').split('\n').map((l) => l.trim()).join(' '))}</li>`);
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
closeList();
|
|
32
|
+
out.push(`<p>${mdInline(flat)}</p>`);
|
|
33
|
+
}
|
|
34
|
+
closeList();
|
|
35
|
+
return out.join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
const SETTINGS_DEFAULTS = { sound: true, soundReview: 'ding', soundDone: 'click', startScreen: 'dashboard', dashRange: 'week', theme: 'system', keepDrawer: false, notifyReview: false, compactCards: false, linkLines: true, keymap: {} };
|
|
10
39
|
export function getSetting(k) {
|
|
11
40
|
const v = localStorage.getItem('kb.set.' + k);
|
|
@@ -424,6 +453,7 @@ async function renderSection(sec) {
|
|
|
424
453
|
+ `<div class="set-col hidden" id="about-upd-fail"><span class="set-lab">${tr('Run it yourself')}<small>${tr('the board is not allowed to do this — the command is the same one it just tried')}</small></span>`
|
|
425
454
|
+ '<pre class="onb-code" id="about-upd-cmd"></pre></div>'
|
|
426
455
|
+ `<div class="set-row hidden" id="about-dev-row"><span class="set-lab">${tr('dev branch')}<small id="about-dev-sub">…</small></span><span class="about-fresh" id="about-dev"></span></div>`
|
|
456
|
+
+ `<div class="set-col"><span class="set-lab">${tr("What's new")}<small id="about-news-sub">…</small></span><div class="about-news" id="about-news">…</div></div>`
|
|
427
457
|
+ `<div class="set-row"><span class="set-lab">${tr('Check for updates')}<small id="about-recheck-sub">${tr('re-read branch state from GitHub')}</small></span><button class="btn-ghost" id="about-recheck">${tr('Check')}</button></div>`
|
|
428
458
|
+ `<div class="set-row"><span class="set-lab">${tr('Reload the page')}<small>${tr('re-read the board code right now')}</small></span><button class="btn-ghost" id="about-reload">${tr('Reload now')}</button></div>`
|
|
429
459
|
+ `<div class="set-row"><span class="set-lab">${tr('Using the board at work')}<small>${tr('free for everyone, nothing switched off — if it earns you money, $12 a year per person keeps it maintained')}</small></span>`
|
|
@@ -475,8 +505,21 @@ async function renderSection(sec) {
|
|
|
475
505
|
? `${u.dev.sha}${u.dev.date ? ` ${tr('at')} ${fmtDbTime(u.dev.date.slice(0, 19).replace('T', ' '))}` : ''}${u.dev.message ? ` · ${u.dev.message}` : ''}`
|
|
476
506
|
: tr('dev has no commits beyond main');
|
|
477
507
|
};
|
|
508
|
+
const loadNews = async (upd, forced) => {
|
|
509
|
+
const box = $('about-news'); const sub = $('about-news-sub');
|
|
510
|
+
if (!box || !sub) return;
|
|
511
|
+
try {
|
|
512
|
+
const remote = upd?.update_available === true ? `?remote=1${forced ? '&refresh=1' : ''}` : '';
|
|
513
|
+
const n = await api('GET', `/api/whats-new${remote}`, null, { quiet: true });
|
|
514
|
+
box.innerHTML = n?.notes ? mdLite(n.notes) : `<p class="muted">${esc(tr('no notes for this version'))}</p>`;
|
|
515
|
+
const ver = n?.version ? `${tr('version')} ${n.version}${n.source === 'github' ? ` · ${tr('not installed yet')}` : ''}` : '';
|
|
516
|
+
const link = /^https:\/\//.test(n?.url || '')
|
|
517
|
+
? ` · <a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer">${esc(tr('release page'))}</a>` : '';
|
|
518
|
+
sub.innerHTML = esc(ver) + link;
|
|
519
|
+
} catch { box.textContent = '—'; sub.textContent = ''; }
|
|
520
|
+
};
|
|
478
521
|
api('GET', '/api/about').catch(() => null).then((a) => { about = a; })
|
|
479
|
-
.then(() => api('GET', '/api/update-check')).then(renderUpd)
|
|
522
|
+
.then(() => api('GET', '/api/update-check')).then((u) => { renderUpd(u); return loadNews(u); })
|
|
480
523
|
.catch(() => { const s = $('about-upd-sub'); if (s) s.textContent = '—'; });
|
|
481
524
|
const showFailCmd = (cmd) => {
|
|
482
525
|
const wrap = $('about-upd-fail');
|
|
@@ -531,7 +574,7 @@ async function renderSection(sec) {
|
|
|
531
574
|
$('about-recheck').onclick = async () => {
|
|
532
575
|
const btn = $('about-recheck');
|
|
533
576
|
btn.disabled = true; $('about-upd-sub').textContent = tr('checking…');
|
|
534
|
-
try {
|
|
577
|
+
try { const u = await api('GET', '/api/update-check?refresh=1'); renderUpd(u); await loadNews(u, true); }
|
|
535
578
|
catch { $('about-upd-sub').textContent = '—'; }
|
|
536
579
|
btn.disabled = false;
|
|
537
580
|
};
|
package/public/js/sidebar.js
CHANGED
|
@@ -374,7 +374,10 @@ export async function checkPath(path) {
|
|
|
374
374
|
setState('wiz-path-state', `${tr('the folder is there')} · ${r.dirs.length} ${tr('subfolders')}`, 'ok');
|
|
375
375
|
return true;
|
|
376
376
|
} catch (e) {
|
|
377
|
-
|
|
377
|
+
const said = { 404: tr('no such folder on this computer'),
|
|
378
|
+
400: tr('this path is not a folder'),
|
|
379
|
+
403: tr('the folder is there, but the board cannot read it — check its permissions') }[e.status];
|
|
380
|
+
setState('wiz-path-state', said || `${tr('could not check the folder')}: ${e.message || path}`, 'bad');
|
|
378
381
|
return false;
|
|
379
382
|
}
|
|
380
383
|
}
|
package/public/style.css
CHANGED
|
@@ -995,6 +995,19 @@ body.dragging-board .col-add-ghost { display: none; }
|
|
|
995
995
|
.theme-opt { border: none; background: var(--panel); color: var(--muted); font: inherit; font-size: 12.5px; padding: 6px 12px; cursor: pointer; }
|
|
996
996
|
.theme-opt + .theme-opt { border-left: 1px solid var(--border); }
|
|
997
997
|
.theme-opt.active { background: var(--accent); color: #fff; }
|
|
998
|
+
.about-news {
|
|
999
|
+
max-height: 260px; overflow-y: auto; font-size: 12.5px; line-height: 1.5;
|
|
1000
|
+
border: 1px solid var(--border-soft); border-radius: 8px; padding: 10px 12px; background: var(--panel);
|
|
1001
|
+
}
|
|
1002
|
+
.about-news h4 { font-size: 12.5px; font-weight: 600; margin: 12px 0 6px; color: var(--muted); }
|
|
1003
|
+
.about-news h4:first-child { margin-top: 0; }
|
|
1004
|
+
.about-news p { margin: 0 0 8px; }
|
|
1005
|
+
.about-news ul { margin: 0 0 8px; padding-left: 18px; }
|
|
1006
|
+
.about-news li { margin-bottom: 7px; }
|
|
1007
|
+
.about-news li:last-child, .about-news p:last-child { margin-bottom: 0; }
|
|
1008
|
+
.about-news code { font-size: 11.5px; background: var(--border-soft); border-radius: 4px; padding: 1px 4px; }
|
|
1009
|
+
.about-news a { color: var(--accent); }
|
|
1010
|
+
|
|
998
1011
|
.bk-list { display: flex; flex-direction: column; gap: 0; margin-top: 6px; }
|
|
999
1012
|
.bk-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; padding: 7px 0; border-top: 1px solid var(--border-soft); }
|
|
1000
1013
|
.bk-row:first-child { border-top: none; }
|
package/src/routes/system.js
CHANGED
|
@@ -20,8 +20,8 @@ let selfPkgCache;
|
|
|
20
20
|
function selfPkg() {
|
|
21
21
|
if (selfPkgCache === undefined) {
|
|
22
22
|
try {
|
|
23
|
-
const { name, version } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
|
|
24
|
-
selfPkgCache = name && version ? { name, version } : null;
|
|
23
|
+
const { name, version, repository } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
|
|
24
|
+
selfPkgCache = name && version ? { name, version, repository: repository?.url || repository || null } : null;
|
|
25
25
|
} catch { selfPkgCache = null; }
|
|
26
26
|
}
|
|
27
27
|
return selfPkgCache;
|
|
@@ -82,6 +82,32 @@ export async function registryCheck() {
|
|
|
82
82
|
} catch { return null; }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
export async function ghApi(path) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(`https://api.github.com/${path}`, {
|
|
88
|
+
headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
|
|
89
|
+
signal: AbortSignal.timeout(8000),
|
|
90
|
+
});
|
|
91
|
+
if (res.ok) return await res.json();
|
|
92
|
+
} catch { }
|
|
93
|
+
try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
|
|
94
|
+
catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function changelogSection(text, version) {
|
|
98
|
+
const head = new RegExp(`^##\\s*\\[?${String(version).replace(/\./g, '\\.')}\\]?`, 'm');
|
|
99
|
+
const m = head.exec(String(text || ''));
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
const rest = text.slice(m.index + m[0].length);
|
|
102
|
+
const end = rest.search(/^## /m);
|
|
103
|
+
return (end === -1 ? rest : rest.slice(0, end)).trim() || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function repoSlug(url) {
|
|
107
|
+
const m = String(url || '').match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#.*)?$/);
|
|
108
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
const UPLOAD_FLOOR = 64 * 1024 * 1024;
|
|
86
112
|
function uploadLimit() {
|
|
87
113
|
try { return Math.max(UPLOAD_FLOOR, statSync(join(DATA_DIR, 'kanban.db')).size * 4); } catch { return UPLOAD_FLOOR; }
|
|
@@ -255,17 +281,6 @@ export default async function systemRoutes(app) {
|
|
|
255
281
|
const git = (args) => new Promise((resolve) => {
|
|
256
282
|
execFile('git', args, { cwd: ROOT, timeout: 8000 }, (err, out) => resolve(err ? null : out.trim()));
|
|
257
283
|
});
|
|
258
|
-
const ghApi = async (path) => {
|
|
259
|
-
try {
|
|
260
|
-
const res = await fetch(`https://api.github.com/${path}`, {
|
|
261
|
-
headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
|
|
262
|
-
signal: AbortSignal.timeout(8000),
|
|
263
|
-
});
|
|
264
|
-
if (res.ok) return await res.json();
|
|
265
|
-
} catch { }
|
|
266
|
-
try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
|
|
267
|
-
catch { return null; }
|
|
268
|
-
};
|
|
269
284
|
let data = { update_available: null, local: null, remote: null, branch: null, tag: null, dev: null };
|
|
270
285
|
try {
|
|
271
286
|
const [local, branch, origin, tag] = await Promise.all([
|
|
@@ -300,6 +315,30 @@ export default async function systemRoutes(app) {
|
|
|
300
315
|
return data;
|
|
301
316
|
});
|
|
302
317
|
|
|
318
|
+
const NEWS_OK_MS = 3600000;
|
|
319
|
+
const NEWS_FAIL_MS = 60000;
|
|
320
|
+
let newsCache = { at: 0, ttl: 0, data: null };
|
|
321
|
+
app.get('/api/whats-new', async (req) => {
|
|
322
|
+
const version = selfPkg()?.version || null;
|
|
323
|
+
const own = () => {
|
|
324
|
+
let text = null;
|
|
325
|
+
try { text = readFileSync(join(ROOT_DIR, 'CHANGELOG.md'), 'utf8'); } catch { }
|
|
326
|
+
return { version, notes: (text && version) ? changelogSection(text, version) : null, source: 'changelog', url: null };
|
|
327
|
+
};
|
|
328
|
+
if (!req.query?.remote) return own();
|
|
329
|
+
if (!req.query?.refresh && newsCache.data && Date.now() - newsCache.at < newsCache.ttl) return newsCache.data;
|
|
330
|
+
const repo = repoSlug(selfPkg()?.repository);
|
|
331
|
+
const rel = repo ? await ghApi(`repos/${repo}/releases/latest`) : null;
|
|
332
|
+
const tag = String(rel?.tag_name || '').replace(/^v/, '');
|
|
333
|
+
const body = String(rel?.body || '').trim();
|
|
334
|
+
const got = Boolean(tag && body);
|
|
335
|
+
const data = got
|
|
336
|
+
? { version: tag, notes: body, source: 'github', url: rel.html_url || null }
|
|
337
|
+
: own();
|
|
338
|
+
newsCache = { at: Date.now(), ttl: got ? NEWS_OK_MS : NEWS_FAIL_MS, data };
|
|
339
|
+
return data;
|
|
340
|
+
});
|
|
341
|
+
|
|
303
342
|
let updateRunning = null;
|
|
304
343
|
app.post('/api/update', async (req, reply) => {
|
|
305
344
|
if (updateRunning) return reply.code(409).send({ error: 'an update is already running' });
|