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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
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!': 'Добро пожаловать!',
@@ -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 { renderUpd(await api('GET', '/api/update-check?refresh=1')); }
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
  };
@@ -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
- setState('wiz-path-state', `${tr('no such folder on this computer')}: ${e.message || path}`, 'bad');
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; }
@@ -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' });