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 ADDED
@@ -0,0 +1 @@
1
+ """knbn — terminal-native personal Kanban board."""
knbn/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """knbn — terminal Kanban board."""
2
+
3
+ from knbn.cli import cli
4
+
5
+
6
+ def main() -> None:
7
+ cli()
8
+
9
+
10
+ if __name__ == '__main__':
11
+ main()
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