knbn 0.0.1__py3-none-any.whl
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.
- knbn/__init__.py +1 -0
- knbn/__main__.py +11 -0
- knbn/app.py +207 -0
- knbn/cli.py +175 -0
- knbn/config.py +272 -0
- knbn/defaults/__init__.py +0 -0
- knbn/defaults/settings.json +51 -0
- knbn/model/__init__.py +0 -0
- knbn/model/slug.py +30 -0
- knbn/model/store.py +175 -0
- knbn/model/task.py +64 -0
- knbn/py.typed +0 -0
- knbn/setup.py +158 -0
- knbn/views/__init__.py +0 -0
- knbn/views/_columns.py +45 -0
- knbn/views/_row.py +35 -0
- knbn/views/_row_list.py +109 -0
- knbn/views/done_week.py +98 -0
- knbn/views/kanban.py +479 -0
- knbn/views/tabular.py +80 -0
- knbn/widgets/__init__.py +0 -0
- knbn/widgets/_confirm.py +42 -0
- knbn/widgets/card.py +127 -0
- knbn/widgets/date_picker.py +200 -0
- knbn/widgets/form.py +254 -0
- knbn/widgets/help.py +84 -0
- knbn-0.0.1.dist-info/METADATA +104 -0
- knbn-0.0.1.dist-info/RECORD +31 -0
- knbn-0.0.1.dist-info/WHEEL +4 -0
- knbn-0.0.1.dist-info/entry_points.txt +3 -0
- knbn-0.0.1.dist-info/licenses/LICENSE.txt +201 -0
knbn/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""knbn — terminal-native personal Kanban board."""
|
knbn/__main__.py
ADDED
knbn/app.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Textual TUI application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.reactive import reactive
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Footer, Static
|
|
13
|
+
from textual.widgets._footer import FooterKey
|
|
14
|
+
|
|
15
|
+
from knbn.config import (
|
|
16
|
+
BoardConfig,
|
|
17
|
+
BoardConfigError,
|
|
18
|
+
build_default_board_config,
|
|
19
|
+
load_board_config,
|
|
20
|
+
load_settings,
|
|
21
|
+
save_settings,
|
|
22
|
+
)
|
|
23
|
+
from knbn.model.store import ensure_data_dir, load_tasks
|
|
24
|
+
from knbn.model.task import Task
|
|
25
|
+
|
|
26
|
+
_VIEW_ACTION = {
|
|
27
|
+
'kanban': 'show_kanban',
|
|
28
|
+
'tabular': 'show_tabular',
|
|
29
|
+
'closed': 'show_closed',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class KnbnFooter(Footer):
|
|
34
|
+
"""Footer that highlights the active view tab key and shows the data dir."""
|
|
35
|
+
|
|
36
|
+
active_view: reactive[str] = reactive('kanban')
|
|
37
|
+
|
|
38
|
+
def __init__(self, data_dir: Path) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
home = Path.home()
|
|
41
|
+
try:
|
|
42
|
+
rel = data_dir.relative_to(home)
|
|
43
|
+
self._data_dir_display = '~' if rel == Path() else f'~/{rel}'
|
|
44
|
+
except ValueError:
|
|
45
|
+
self._data_dir_display = str(data_dir)
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
yield from super().compose()
|
|
49
|
+
yield Static(self._data_dir_display, id='data-dir-label')
|
|
50
|
+
|
|
51
|
+
def bindings_changed(self, screen: Screen) -> None:
|
|
52
|
+
super().bindings_changed(screen)
|
|
53
|
+
self.call_after_refresh(self._apply_active_class)
|
|
54
|
+
|
|
55
|
+
def watch_active_view(self) -> None:
|
|
56
|
+
self.call_after_refresh(self._apply_active_class)
|
|
57
|
+
|
|
58
|
+
def _apply_active_class(self) -> None:
|
|
59
|
+
active_action = _VIEW_ACTION.get(self.active_view, '')
|
|
60
|
+
for key_widget in self.query(FooterKey):
|
|
61
|
+
key_widget.set_class(key_widget.action == active_action, '-active-tab')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class KnbnApp(App[None]):
|
|
65
|
+
"""Terminal Kanban board."""
|
|
66
|
+
|
|
67
|
+
TITLE = 'knbn'
|
|
68
|
+
CSS = """
|
|
69
|
+
Screen {
|
|
70
|
+
background: $surface;
|
|
71
|
+
}
|
|
72
|
+
#view-container {
|
|
73
|
+
height: 1fr;
|
|
74
|
+
}
|
|
75
|
+
FooterKey.-active-tab .footer-key--key {
|
|
76
|
+
color: $footer-key-background;
|
|
77
|
+
background: $footer-key-foreground;
|
|
78
|
+
}
|
|
79
|
+
FooterKey.-active-tab .footer-key--description {
|
|
80
|
+
color: $footer-key-foreground;
|
|
81
|
+
background: $footer-description-background;
|
|
82
|
+
text-style: bold;
|
|
83
|
+
}
|
|
84
|
+
#data-dir-label {
|
|
85
|
+
width: 1fr;
|
|
86
|
+
text-align: right;
|
|
87
|
+
text-style: dim;
|
|
88
|
+
padding: 0 1;
|
|
89
|
+
}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
BINDINGS = [
|
|
93
|
+
Binding('1', 'show_kanban', 'Kanban', show=True),
|
|
94
|
+
Binding('2', 'show_tabular', 'Tabular', show=True),
|
|
95
|
+
Binding('3', 'show_closed', 'Closed', show=True),
|
|
96
|
+
Binding('q', 'quit', 'Quit', show=True),
|
|
97
|
+
Binding('r', 'reload', 'Reload', show=True),
|
|
98
|
+
Binding('a', 'add_task', 'Add', show=True),
|
|
99
|
+
Binding('question_mark', 'help', 'Help', show=True),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
def __init__(self, data_dir: Path, theme: str = 'textual-dark') -> None:
|
|
103
|
+
self.data_dir = data_dir
|
|
104
|
+
super().__init__()
|
|
105
|
+
self._tasks: list[Task] = []
|
|
106
|
+
self._current_view: str = 'kanban'
|
|
107
|
+
self.theme = theme
|
|
108
|
+
self.board_config: BoardConfig = build_default_board_config()
|
|
109
|
+
|
|
110
|
+
def on_mount(self) -> None:
|
|
111
|
+
size = self.app.size
|
|
112
|
+
if size.width < 100 or size.height < 30:
|
|
113
|
+
self.exit(message='Terminal too small. Minimum size: 100×30.')
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
self.board_config = load_board_config(self.data_dir)
|
|
117
|
+
except BoardConfigError as e:
|
|
118
|
+
self.exit(message=f'Board config error: {e}')
|
|
119
|
+
return
|
|
120
|
+
self._reload_tasks()
|
|
121
|
+
self._show_view('kanban')
|
|
122
|
+
|
|
123
|
+
def _reload_tasks(self) -> None:
|
|
124
|
+
ensure_data_dir(self.data_dir)
|
|
125
|
+
self._tasks = load_tasks(self.data_dir)
|
|
126
|
+
|
|
127
|
+
def _show_view(self, view: str) -> None:
|
|
128
|
+
from knbn.views.done_week import ClosedView
|
|
129
|
+
from knbn.views.kanban import KanbanView
|
|
130
|
+
from knbn.views.tabular import TabularView
|
|
131
|
+
|
|
132
|
+
self._reload_tasks()
|
|
133
|
+
container = self.query_one('#view-container')
|
|
134
|
+
container.remove_children()
|
|
135
|
+
if view == 'kanban':
|
|
136
|
+
container.mount(KanbanView(self._tasks, self.data_dir))
|
|
137
|
+
elif view == 'tabular':
|
|
138
|
+
container.mount(TabularView(self._tasks, self.data_dir))
|
|
139
|
+
elif view == 'closed':
|
|
140
|
+
container.mount(ClosedView(self._tasks, self.data_dir))
|
|
141
|
+
self._current_view = view
|
|
142
|
+
self.query_one(KnbnFooter).active_view = view
|
|
143
|
+
|
|
144
|
+
def compose(self) -> ComposeResult:
|
|
145
|
+
yield Static(id='view-container')
|
|
146
|
+
yield KnbnFooter(self.data_dir)
|
|
147
|
+
|
|
148
|
+
def action_reload(self) -> None:
|
|
149
|
+
self._show_view(self._current_view)
|
|
150
|
+
|
|
151
|
+
def action_show_kanban(self) -> None:
|
|
152
|
+
self._show_view('kanban')
|
|
153
|
+
|
|
154
|
+
def action_show_tabular(self) -> None:
|
|
155
|
+
self._show_view('tabular')
|
|
156
|
+
|
|
157
|
+
def action_show_closed(self) -> None:
|
|
158
|
+
self._show_view('closed')
|
|
159
|
+
|
|
160
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
161
|
+
yield SystemCommand(
|
|
162
|
+
'Theme', 'Change the current theme', self.action_change_theme
|
|
163
|
+
)
|
|
164
|
+
yield SystemCommand('Quit', 'Quit the application', self.action_quit)
|
|
165
|
+
if screen.query('HelpPanel'):
|
|
166
|
+
yield SystemCommand(
|
|
167
|
+
'Keys', 'Hide the keys panel', self.action_hide_help_panel
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
yield SystemCommand(
|
|
171
|
+
'Keys', 'Show help for the focused widget', self.action_show_help_panel
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def action_add_task(self) -> None:
|
|
175
|
+
from knbn.views.kanban import KanbanView
|
|
176
|
+
from knbn.widgets.form import TaskForm
|
|
177
|
+
|
|
178
|
+
initial_status: str | None = None
|
|
179
|
+
initial_priority: str | None = None
|
|
180
|
+
if self._current_view == 'kanban':
|
|
181
|
+
kanban_views = self.query(KanbanView)
|
|
182
|
+
if kanban_views:
|
|
183
|
+
ctx = kanban_views.first(KanbanView).get_lane_context()
|
|
184
|
+
if ctx is not None:
|
|
185
|
+
initial_status, initial_priority = ctx
|
|
186
|
+
|
|
187
|
+
self.push_screen(
|
|
188
|
+
TaskForm(
|
|
189
|
+
data_dir=self.data_dir,
|
|
190
|
+
task=None,
|
|
191
|
+
initial_status=initial_status,
|
|
192
|
+
initial_priority=initial_priority,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def action_help(self) -> None:
|
|
197
|
+
from knbn.widgets.help import HelpOverlay
|
|
198
|
+
|
|
199
|
+
self.push_screen(HelpOverlay())
|
|
200
|
+
|
|
201
|
+
def on_task_form_task_saved(self) -> None:
|
|
202
|
+
self._show_view(self._current_view)
|
|
203
|
+
|
|
204
|
+
def watch_theme(self, theme: str) -> None:
|
|
205
|
+
settings = load_settings(self.data_dir)
|
|
206
|
+
settings['app']['theme'] = theme
|
|
207
|
+
save_settings(self.data_dir, settings)
|
knbn/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""CLI commands for knbn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from knbn.config import load_board_config, resolve_data_dir
|
|
10
|
+
from knbn.model.store import add_task, ensure_data_dir
|
|
11
|
+
from knbn.model.task import Task, now_str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _prompt_select(label: str, options: list[str], default: str) -> str:
|
|
15
|
+
opts_str = ' '.join(f'({i + 1}) {opt}' for i, opt in enumerate(options))
|
|
16
|
+
default_idx = options.index(default) + 1 if default in options else 1
|
|
17
|
+
while True:
|
|
18
|
+
idx: int = click.prompt(
|
|
19
|
+
f'{label}\n {opts_str}',
|
|
20
|
+
type=int,
|
|
21
|
+
default=default_idx,
|
|
22
|
+
)
|
|
23
|
+
if 1 <= idx <= len(options):
|
|
24
|
+
return options[idx - 1]
|
|
25
|
+
click.echo(f'Please enter a number between 1 and {len(options)}.')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group(invoke_without_command=True)
|
|
29
|
+
@click.pass_context
|
|
30
|
+
def cli(ctx: click.Context) -> None:
|
|
31
|
+
if ctx.invoked_subcommand is None:
|
|
32
|
+
ctx.invoke(board)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cli.command()
|
|
36
|
+
@click.argument('path', required=False)
|
|
37
|
+
def init(path: str | None) -> None:
|
|
38
|
+
"""Initialize the knbn data directory."""
|
|
39
|
+
data_dir = Path(path) if path else resolve_data_dir()
|
|
40
|
+
from knbn.setup import run_setup_wizard, should_run_wizard
|
|
41
|
+
|
|
42
|
+
already_existed = data_dir.exists()
|
|
43
|
+
if should_run_wizard(data_dir):
|
|
44
|
+
run_setup_wizard(data_dir)
|
|
45
|
+
ensure_data_dir(data_dir)
|
|
46
|
+
if already_existed:
|
|
47
|
+
print(f'Already initialized at {data_dir}')
|
|
48
|
+
else:
|
|
49
|
+
print(f'Initialized knbn data at {data_dir}')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command()
|
|
53
|
+
def board() -> None:
|
|
54
|
+
"""Launch the interactive Kanban board."""
|
|
55
|
+
from knbn.app import KnbnApp # pragma: no cover
|
|
56
|
+
from knbn.config import get_app_setting # pragma: no cover
|
|
57
|
+
from knbn.setup import run_setup_wizard, should_run_wizard # pragma: no cover
|
|
58
|
+
|
|
59
|
+
data_dir = resolve_data_dir() # pragma: no cover
|
|
60
|
+
if should_run_wizard(data_dir): # pragma: no cover
|
|
61
|
+
run_setup_wizard(data_dir) # pragma: no cover
|
|
62
|
+
ensure_data_dir(data_dir) # pragma: no cover
|
|
63
|
+
theme = get_app_setting(data_dir, 'theme', 'textual-dark') # pragma: no cover
|
|
64
|
+
KnbnApp(data_dir=data_dir, theme=theme).run() # pragma: no cover
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.command()
|
|
68
|
+
@click.option('--title', '-t', default=None, help='Task title (skips prompt)')
|
|
69
|
+
@click.option(
|
|
70
|
+
'--status-default', 'status_default', is_flag=True, help='Use default status'
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
'--priority-default',
|
|
74
|
+
'priority_default',
|
|
75
|
+
is_flag=True,
|
|
76
|
+
help='Use default priority',
|
|
77
|
+
)
|
|
78
|
+
@click.option(
|
|
79
|
+
'--category-default',
|
|
80
|
+
'category_default',
|
|
81
|
+
is_flag=True,
|
|
82
|
+
help='Use default category',
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
'--no-resource', 'no_resource', is_flag=True, help='Skip key resource prompt'
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
'--no-free-text', 'no_free_text', is_flag=True, help='Skip free-text field prompts'
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
'--fast', '-f', is_flag=True, help='Use all defaults and skip all optional prompts'
|
|
92
|
+
)
|
|
93
|
+
def add(
|
|
94
|
+
title: str | None,
|
|
95
|
+
status_default: bool,
|
|
96
|
+
priority_default: bool,
|
|
97
|
+
category_default: bool,
|
|
98
|
+
no_resource: bool,
|
|
99
|
+
no_free_text: bool,
|
|
100
|
+
fast: bool,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Quickly add a new task."""
|
|
103
|
+
if fast:
|
|
104
|
+
status_default = priority_default = category_default = no_resource = (
|
|
105
|
+
no_free_text
|
|
106
|
+
) = True
|
|
107
|
+
|
|
108
|
+
data_dir = resolve_data_dir()
|
|
109
|
+
ensure_data_dir(data_dir)
|
|
110
|
+
board_config = load_board_config(data_dir)
|
|
111
|
+
|
|
112
|
+
active_statuses = board_config.active_statuses
|
|
113
|
+
category_names = [c.name for c in board_config.categories]
|
|
114
|
+
priorities = board_config.priorities
|
|
115
|
+
default_status = active_statuses[0]
|
|
116
|
+
default_priority = priorities[len(priorities) // 2]
|
|
117
|
+
default_category = category_names[-1] if category_names else ''
|
|
118
|
+
|
|
119
|
+
# Title
|
|
120
|
+
if title is None:
|
|
121
|
+
title = click.prompt(click.style('Title', bold=True), type=str)
|
|
122
|
+
|
|
123
|
+
# Status
|
|
124
|
+
if status_default:
|
|
125
|
+
status = default_status
|
|
126
|
+
else:
|
|
127
|
+
status = _prompt_select('Status', active_statuses, default_status)
|
|
128
|
+
|
|
129
|
+
# Priority
|
|
130
|
+
if priority_default:
|
|
131
|
+
priority = default_priority
|
|
132
|
+
else:
|
|
133
|
+
priority = _prompt_select('Priority', priorities, default_priority)
|
|
134
|
+
|
|
135
|
+
# Category
|
|
136
|
+
if category_default:
|
|
137
|
+
category = default_category
|
|
138
|
+
else:
|
|
139
|
+
category = _prompt_select('Category', category_names, default_category)
|
|
140
|
+
|
|
141
|
+
# Key Resource
|
|
142
|
+
key_resource = ''
|
|
143
|
+
if not no_resource:
|
|
144
|
+
raw = click.prompt(
|
|
145
|
+
click.style('Key Resource', bold=True) + ' (URL, Enter to skip)',
|
|
146
|
+
default='',
|
|
147
|
+
)
|
|
148
|
+
key_resource = '' if raw in ('', '-') else raw
|
|
149
|
+
|
|
150
|
+
# Free-text fields
|
|
151
|
+
free_texts: dict[str, str] = {}
|
|
152
|
+
if not no_free_text:
|
|
153
|
+
for i, label in board_config.active_free_text_fields():
|
|
154
|
+
raw = click.prompt(
|
|
155
|
+
click.style(label, bold=True) + ' (Enter to skip)',
|
|
156
|
+
default='',
|
|
157
|
+
)
|
|
158
|
+
free_texts[f'free_text_{i + 1}'] = raw
|
|
159
|
+
|
|
160
|
+
now = now_str()
|
|
161
|
+
task = Task(
|
|
162
|
+
title=title,
|
|
163
|
+
category=category,
|
|
164
|
+
status=status,
|
|
165
|
+
priority=priority,
|
|
166
|
+
date_created=now,
|
|
167
|
+
date_modified=now,
|
|
168
|
+
due='',
|
|
169
|
+
key_resource=key_resource,
|
|
170
|
+
free_text_1=free_texts.get('free_text_1', ''),
|
|
171
|
+
free_text_2=free_texts.get('free_text_2', ''),
|
|
172
|
+
free_text_3=free_texts.get('free_text_3', ''),
|
|
173
|
+
)
|
|
174
|
+
add_task(data_dir, task)
|
|
175
|
+
print(f'✓ Task added: {title}')
|
knbn/config.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Data directory resolution, user settings, and board configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_ENV_VAR = 'KNBN_DATA_DIR'
|
|
14
|
+
_DEFAULT_DIR = Path.home() / '.knbn'
|
|
15
|
+
_SETTINGS_FILENAME = 'settings.json'
|
|
16
|
+
_SETTINGS_TMP_FILENAME = '.settings.json.tmp'
|
|
17
|
+
|
|
18
|
+
_HEX_COLOR_RE = re.compile(r'^#[0-9a-f]{6}$')
|
|
19
|
+
NAME_MIN = 2
|
|
20
|
+
NAME_MAX = 20
|
|
21
|
+
|
|
22
|
+
CATEGORY_COLOR_PALETTE: list[str] = [
|
|
23
|
+
'#e879a0',
|
|
24
|
+
'#f4a7b9',
|
|
25
|
+
'#7ec8e3',
|
|
26
|
+
'#5b9bd5',
|
|
27
|
+
'#4dbfbf',
|
|
28
|
+
'#f5a623',
|
|
29
|
+
'#cccccc',
|
|
30
|
+
'#a78bfa',
|
|
31
|
+
'#34d399',
|
|
32
|
+
'#fbbf24',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BoardConfigError(Exception):
|
|
37
|
+
"""Raised when board configuration in settings.json is invalid."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CategoryConfig:
|
|
42
|
+
name: str
|
|
43
|
+
color: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class BoardConfig:
|
|
48
|
+
active_statuses: list[str]
|
|
49
|
+
default_active_status: str
|
|
50
|
+
terminal_statuses: list[str]
|
|
51
|
+
default_terminal_status: str
|
|
52
|
+
priorities: list[str]
|
|
53
|
+
categories: list[CategoryConfig]
|
|
54
|
+
free_text_fields: list[str]
|
|
55
|
+
|
|
56
|
+
def category_color(self, name: str) -> str:
|
|
57
|
+
for cat in self.categories:
|
|
58
|
+
if cat.name == name:
|
|
59
|
+
return cat.color
|
|
60
|
+
return '#888888'
|
|
61
|
+
|
|
62
|
+
def active_free_text_fields(self) -> list[tuple[int, str]]:
|
|
63
|
+
return [(i, label) for i, label in enumerate(self.free_text_fields) if label]
|
|
64
|
+
|
|
65
|
+
def all_statuses(self) -> list[str]:
|
|
66
|
+
return self.active_statuses + self.terminal_statuses
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_default_board_config() -> dict[str, Any]:
|
|
70
|
+
"""Return the board config block from the bundled defaults/settings.json."""
|
|
71
|
+
resource = files('knbn').joinpath('defaults/settings.json')
|
|
72
|
+
try:
|
|
73
|
+
raw = resource.read_text(encoding='utf-8')
|
|
74
|
+
except (FileNotFoundError, OSError) as exc:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f'knbn defaults file not found — package may be incomplete: {exc}'
|
|
77
|
+
) from exc
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(raw)
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f'knbn defaults/settings.json is not valid JSON: {exc}'
|
|
83
|
+
) from exc
|
|
84
|
+
return dict(data['board'])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
SETTINGS_DEFAULTS: dict[str, Any] = {
|
|
88
|
+
'app': {
|
|
89
|
+
'theme': 'textual-dark',
|
|
90
|
+
'deadline_warning_hours': '24',
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_data_dir() -> Path:
|
|
96
|
+
env_val = os.environ.get(_ENV_VAR)
|
|
97
|
+
if env_val:
|
|
98
|
+
return Path(env_val)
|
|
99
|
+
return _DEFAULT_DIR
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_settings(data_dir: Path) -> dict[str, Any]:
|
|
103
|
+
settings_file = data_dir / _SETTINGS_FILENAME
|
|
104
|
+
result: dict[str, Any] = {
|
|
105
|
+
'app': dict(SETTINGS_DEFAULTS['app']),
|
|
106
|
+
}
|
|
107
|
+
if not settings_file.exists():
|
|
108
|
+
return result
|
|
109
|
+
try:
|
|
110
|
+
raw = json.loads(settings_file.read_text(encoding='utf-8'))
|
|
111
|
+
if isinstance(raw, dict):
|
|
112
|
+
if 'app' in raw and isinstance(raw['app'], dict):
|
|
113
|
+
result['app'].update(raw['app'])
|
|
114
|
+
if 'board' in raw:
|
|
115
|
+
result['board'] = raw['board']
|
|
116
|
+
except (json.JSONDecodeError, OSError):
|
|
117
|
+
pass
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save_settings(data_dir: Path, settings: dict[str, Any]) -> None:
|
|
122
|
+
tmp_file = data_dir / _SETTINGS_TMP_FILENAME
|
|
123
|
+
tmp_file.write_text(json.dumps(settings, indent=2), encoding='utf-8')
|
|
124
|
+
tmp_file.rename(data_dir / _SETTINGS_FILENAME)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_app_setting(data_dir: Path, key: str, default: Any = '') -> Any:
|
|
128
|
+
settings = load_settings(data_dir)
|
|
129
|
+
return settings.get('app', {}).get(key, default)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _validate_name(value: str, field: str) -> None:
|
|
133
|
+
if not (NAME_MIN <= len(value) <= NAME_MAX):
|
|
134
|
+
raise BoardConfigError(
|
|
135
|
+
f'{field} name {value!r} must be between {NAME_MIN} and {NAME_MAX} characters'
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_default_board_config() -> BoardConfig:
|
|
140
|
+
d = load_default_board_config()
|
|
141
|
+
categories = [
|
|
142
|
+
CategoryConfig(name=c['name'], color=c['color']) for c in d['categories']
|
|
143
|
+
]
|
|
144
|
+
return BoardConfig(
|
|
145
|
+
active_statuses=list(d['active_statuses']),
|
|
146
|
+
default_active_status=d['default_active_status'],
|
|
147
|
+
terminal_statuses=list(d['terminal_statuses']),
|
|
148
|
+
default_terminal_status=d['default_terminal_status'],
|
|
149
|
+
priorities=list(d['priorities']),
|
|
150
|
+
categories=categories,
|
|
151
|
+
free_text_fields=_parse_free_text_fields(d),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_active_statuses(board: dict[str, Any]) -> tuple[list[str], str]:
|
|
156
|
+
active = board.get('active_statuses', [])
|
|
157
|
+
if not isinstance(active, list) or not (2 <= len(active) <= 5):
|
|
158
|
+
raise BoardConfigError(
|
|
159
|
+
f'active_statuses must have 2-5 entries, got '
|
|
160
|
+
f'{len(active) if isinstance(active, list) else "invalid"}'
|
|
161
|
+
)
|
|
162
|
+
for name in active:
|
|
163
|
+
_validate_name(str(name), 'active_statuses')
|
|
164
|
+
default = board.get('default_active_status', '')
|
|
165
|
+
if default not in active:
|
|
166
|
+
raise BoardConfigError(
|
|
167
|
+
f'default_active_status {default!r} not found in active_statuses'
|
|
168
|
+
)
|
|
169
|
+
return [str(s) for s in active], str(default)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_terminal_statuses(board: dict[str, Any]) -> tuple[list[str], str]:
|
|
173
|
+
terminal = board.get('terminal_statuses', [])
|
|
174
|
+
if not isinstance(terminal, list) or not (1 <= len(terminal) <= 3):
|
|
175
|
+
raise BoardConfigError(
|
|
176
|
+
f'terminal_statuses must have 1-3 entries, got '
|
|
177
|
+
f'{len(terminal) if isinstance(terminal, list) else "invalid"}'
|
|
178
|
+
)
|
|
179
|
+
for name in terminal:
|
|
180
|
+
_validate_name(str(name), 'terminal_statuses')
|
|
181
|
+
default = board.get('default_terminal_status', '')
|
|
182
|
+
if default not in terminal:
|
|
183
|
+
raise BoardConfigError(
|
|
184
|
+
f'default_terminal_status {default!r} not found in terminal_statuses'
|
|
185
|
+
)
|
|
186
|
+
return [str(s) for s in terminal], str(default)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _parse_priorities(board: dict[str, Any]) -> list[str]:
|
|
190
|
+
priorities = board.get('priorities', [])
|
|
191
|
+
if not isinstance(priorities, list) or not (1 <= len(priorities) <= 5):
|
|
192
|
+
raise BoardConfigError(
|
|
193
|
+
f'priorities must have 1-5 entries, got '
|
|
194
|
+
f'{len(priorities) if isinstance(priorities, list) else "invalid"}'
|
|
195
|
+
)
|
|
196
|
+
for name in priorities:
|
|
197
|
+
_validate_name(str(name), 'priorities')
|
|
198
|
+
return [str(p) for p in priorities]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_categories(board: dict[str, Any]) -> list[CategoryConfig]:
|
|
202
|
+
raw_cats = board.get('categories', [])
|
|
203
|
+
if not isinstance(raw_cats, list) or not (1 <= len(raw_cats) <= 10):
|
|
204
|
+
raise BoardConfigError(
|
|
205
|
+
f'categories must have 1-10 entries, got '
|
|
206
|
+
f'{len(raw_cats) if isinstance(raw_cats, list) else "invalid"}'
|
|
207
|
+
)
|
|
208
|
+
categories: list[CategoryConfig] = []
|
|
209
|
+
for cat in raw_cats:
|
|
210
|
+
if not isinstance(cat, dict):
|
|
211
|
+
raise BoardConfigError(
|
|
212
|
+
'each category must be a JSON object with name and color'
|
|
213
|
+
)
|
|
214
|
+
cat_name = str(cat.get('name', ''))
|
|
215
|
+
cat_color = str(cat.get('color', ''))
|
|
216
|
+
_validate_name(cat_name, 'categories')
|
|
217
|
+
if not _HEX_COLOR_RE.match(cat_color):
|
|
218
|
+
raise BoardConfigError(
|
|
219
|
+
f'category color {cat_color!r} must match #[0-9a-f]{{6}}'
|
|
220
|
+
)
|
|
221
|
+
categories.append(CategoryConfig(name=cat_name, color=cat_color))
|
|
222
|
+
return categories
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_free_text_fields(board: dict[str, Any]) -> list[str]:
|
|
226
|
+
raw = board.get('free_text_fields', [])
|
|
227
|
+
if not isinstance(raw, list) or len(raw) > 3:
|
|
228
|
+
raise BoardConfigError(
|
|
229
|
+
f'free_text_fields must have 0-3 entries, got '
|
|
230
|
+
f'{len(raw) if isinstance(raw, list) else "invalid"}'
|
|
231
|
+
)
|
|
232
|
+
fields: list[str] = []
|
|
233
|
+
for label in raw:
|
|
234
|
+
label_str = str(label)
|
|
235
|
+
if label_str and not (NAME_MIN <= len(label_str) <= NAME_MAX):
|
|
236
|
+
raise BoardConfigError(
|
|
237
|
+
f'free_text_fields label {label_str!r} must be between '
|
|
238
|
+
f'{NAME_MIN} and {NAME_MAX} characters'
|
|
239
|
+
)
|
|
240
|
+
fields.append(label_str)
|
|
241
|
+
while len(fields) < 3:
|
|
242
|
+
fields.append('')
|
|
243
|
+
return fields
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def load_board_config(data_dir: Path) -> BoardConfig:
|
|
247
|
+
settings_file = data_dir / _SETTINGS_FILENAME
|
|
248
|
+
if not settings_file.exists():
|
|
249
|
+
return build_default_board_config()
|
|
250
|
+
try:
|
|
251
|
+
raw = json.loads(settings_file.read_text(encoding='utf-8'))
|
|
252
|
+
if not isinstance(raw, dict) or 'board' not in raw:
|
|
253
|
+
return build_default_board_config()
|
|
254
|
+
board = raw['board']
|
|
255
|
+
except (json.JSONDecodeError, OSError):
|
|
256
|
+
return build_default_board_config()
|
|
257
|
+
if not isinstance(board, dict):
|
|
258
|
+
raise BoardConfigError('board block must be a JSON object')
|
|
259
|
+
active, default_active = _parse_active_statuses(board)
|
|
260
|
+
terminal, default_terminal = _parse_terminal_statuses(board)
|
|
261
|
+
priorities = _parse_priorities(board)
|
|
262
|
+
categories = _parse_categories(board)
|
|
263
|
+
free_text_fields = _parse_free_text_fields(board)
|
|
264
|
+
return BoardConfig(
|
|
265
|
+
active_statuses=active,
|
|
266
|
+
default_active_status=default_active,
|
|
267
|
+
terminal_statuses=terminal,
|
|
268
|
+
default_terminal_status=default_terminal,
|
|
269
|
+
priorities=priorities,
|
|
270
|
+
categories=categories,
|
|
271
|
+
free_text_fields=free_text_fields,
|
|
272
|
+
)
|
|
File without changes
|