diary-md 0.1.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.
diary_md/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """diary-md: Tools for managing markdown-based diary entries."""
2
+
3
+ from diary_md.exceptions import DiaryParseError
4
+ from diary_md.exchange import EXCHANGE_RATES_TO_EUR, get_exchange_rate
5
+ from diary_md.models import DateHeader, ExpenseLine
6
+ from diary_md.parser import (
7
+ find_or_create_date_section,
8
+ find_section_end,
9
+ find_section_in_date,
10
+ markdown_to_dict,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "DiaryParseError",
17
+ "DateHeader",
18
+ "ExpenseLine",
19
+ "get_exchange_rate",
20
+ "EXCHANGE_RATES_TO_EUR",
21
+ "markdown_to_dict",
22
+ "find_or_create_date_section",
23
+ "find_section_in_date",
24
+ "find_section_end",
25
+ ]
diary_md/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.1'
32
+ __version_tuple__ = version_tuple = (0, 1, 1)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1 @@
1
+ """CLI commands for diary-md."""
diary_md/cli/digest.py ADDED
@@ -0,0 +1,321 @@
1
+ """diary-digest: Analyze and extract information from diary files."""
2
+
3
+ import fnmatch
4
+ import json
5
+ import re
6
+ import sys
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from diary_md.exceptions import DiaryParseError
13
+ from diary_md.exchange import get_exchange_rate
14
+ from diary_md.parser import markdown_to_dict, parse_diary_to_list
15
+
16
+ DATE_FORMAT = "%Y-%m-%d"
17
+
18
+ # Default config file location
19
+ DEFAULT_CONFIG_FILE = Path.home() / ".config" / "diary-md" / "config.json"
20
+
21
+
22
+ def load_config(config_file: Path | None = None) -> dict:
23
+ """Load configuration from JSON file.
24
+
25
+ Config format (simple):
26
+ {
27
+ "allowable_subsections": ["Expenses", "Maintenance", ...]
28
+ }
29
+
30
+ Config format (with diary-specific patterns):
31
+ {
32
+ "diary_configs": {
33
+ "**/solveig/*": {
34
+ "allowable_subsections": ["Expenses", "Maintenance", ...]
35
+ },
36
+ "**/furusetalle9/*": {
37
+ "allowable_subsections": ["Kostnader", "Tidsbruk", ...]
38
+ }
39
+ },
40
+ "allowable_subsections": ["default", "sections", "..."]
41
+ }
42
+ """
43
+ if config_file is None:
44
+ config_file = DEFAULT_CONFIG_FILE
45
+
46
+ if not config_file.exists():
47
+ return {}
48
+
49
+ try:
50
+ with open(config_file, encoding='utf-8') as f:
51
+ return json.load(f)
52
+ except (OSError, json.JSONDecodeError):
53
+ return {}
54
+
55
+
56
+ def get_config_for_diary(cfg: dict, diary_path: str) -> dict:
57
+ """Get the configuration for a specific diary file.
58
+
59
+ Matches diary path against patterns in diary_configs.
60
+ Falls back to top-level config if no pattern matches.
61
+ """
62
+ diary_configs = cfg.get('diary_configs', {})
63
+
64
+ for pattern, diary_cfg in diary_configs.items():
65
+ if fnmatch.fnmatch(diary_path, pattern):
66
+ return diary_cfg
67
+
68
+ # Return top-level config as default
69
+ return cfg
70
+
71
+
72
+ @click.group()
73
+ @click.option('--start', help='Only show dates at or after this date', type=click.DateTime(formats=[DATE_FORMAT]))
74
+ @click.option('--begin', 'start', help='alias for start', type=click.DateTime(formats=[DATE_FORMAT]))
75
+ @click.option('--since', 'start', help='alias for start', type=click.DateTime(formats=[DATE_FORMAT]))
76
+ @click.option('--from', 'start', help='alias for start', type=click.DateTime(formats=[DATE_FORMAT]))
77
+ @click.option('--end', help='Only show dates up until and including this date', type=click.DateTime(formats=[DATE_FORMAT]))
78
+ @click.option('--to', 'end', help='alias for end', type=click.DateTime(formats=[DATE_FORMAT]))
79
+ @click.option('--until', 'end', help='alias for end', type=click.DateTime(formats=[DATE_FORMAT]))
80
+ @click.option('--diary', type=click.File('r'), default=(sys.stdin,), multiple=True)
81
+ @click.pass_context
82
+ def digest(ctx, diary, start, end):
83
+ """Analyze and extract information from markdown diary files."""
84
+ ctx.ensure_object(dict)
85
+ ctx.obj['md_dict'] = {}
86
+ ctx.obj['diary_paths'] = []
87
+ for d in diary:
88
+ ctx.obj['md_dict'].update(markdown_to_dict(d))
89
+ # Track diary paths for config lookup
90
+ if hasattr(d, 'name') and d.name != '<stdin>':
91
+ ctx.obj['diary_paths'].append(d.name)
92
+ ctx.obj['diary_list'] = parse_diary_to_list(ctx.obj['md_dict'], start=start, end=end)
93
+
94
+
95
+ @digest.command()
96
+ @click.pass_context
97
+ @click.option('--section', multiple=True, help='Section(s) to extract')
98
+ def select_subsection(ctx, section):
99
+ """Extract specific subsections from diary entries."""
100
+ header = ""
101
+ for x in ctx.obj['diary_list']:
102
+ if x['trip'] != header:
103
+ click.echo(f"# {x['trip']}")
104
+ click.echo()
105
+ header = x['trip']
106
+ day_print = False
107
+ for s in section:
108
+ if s in x:
109
+ if not day_print:
110
+ click.echo(f"## {x['dow']} {x['date']} {x['itenary']}")
111
+ click.echo()
112
+ day_print = True
113
+ click.echo(f"### {s}")
114
+ click.echo(x[s]['__content__'])
115
+
116
+
117
+ @digest.command()
118
+ @click.pass_context
119
+ def export_json(ctx):
120
+ """Export diary as JSON."""
121
+ click.echo(json.dumps(ctx.obj['diary_list']))
122
+
123
+
124
+ @digest.command()
125
+ @click.pass_context
126
+ @click.option('--config', type=click.Path(exists=True, path_type=Path),
127
+ help=f'Config file (default: {DEFAULT_CONFIG_FILE})')
128
+ def find_all_subsections(ctx, config):
129
+ """Find all subsection titles used in the diary.
130
+
131
+ If a config file defines allowable_subsections, validates against that list.
132
+ Otherwise, just reports all found subsections.
133
+
134
+ Supports diary-specific configs with glob patterns in diary_configs section.
135
+ """
136
+ md_dict = ctx.obj['md_dict']
137
+ diary_paths = ctx.obj.get('diary_paths', [])
138
+
139
+ # Load config and get diary-specific settings
140
+ cfg = load_config(config)
141
+
142
+ # If we have diary paths, try to get diary-specific config
143
+ allowable_from_config = []
144
+ if diary_paths:
145
+ # Use first diary's config (could be enhanced to merge configs)
146
+ diary_cfg = get_config_for_diary(cfg, diary_paths[0])
147
+ allowable_from_config = diary_cfg.get('allowable_subsections', [])
148
+ else:
149
+ allowable_from_config = cfg.get('allowable_subsections', [])
150
+
151
+ # Always allow internal keys
152
+ allowable_subsection_titles = {'__content__', '__file_position__', '__file_name__'}
153
+ allowable_subsection_titles.update(allowable_from_config)
154
+
155
+ subsection_titles = set()
156
+
157
+ def _looks_like_date(key: str) -> bool:
158
+ """Check if key looks like a date header."""
159
+ import re
160
+ return bool(re.match(r'^[A-Za-zæøåÆØÅ]+ 20\d\d-\d\d-\d\d', key))
161
+
162
+ # Check if top-level keys are date headers (no trip wrapper)
163
+ non_meta_keys = [k for k in md_dict if not k.startswith('__')]
164
+ direct_dates = non_meta_keys and all(_looks_like_date(k) for k in non_meta_keys)
165
+
166
+ if direct_dates:
167
+ # Top-level is date headers directly
168
+ for day in md_dict:
169
+ if not isinstance(day, str) or day.startswith('__'):
170
+ continue
171
+ day_data = md_dict[day]
172
+ if not isinstance(day_data, dict):
173
+ continue
174
+ for subtitle in day_data:
175
+ if not isinstance(subtitle, str) or subtitle.startswith('__'):
176
+ continue
177
+ subsection_titles.add(subtitle)
178
+ if allowable_from_config and subtitle not in allowable_subsection_titles:
179
+ click.echo(f"Not allowed: {subtitle} in {day}")
180
+ else:
181
+ # Normal structure: trip headers containing date headers
182
+ for headline in md_dict:
183
+ if not isinstance(md_dict[headline], dict):
184
+ continue
185
+ for day in md_dict[headline]:
186
+ if not isinstance(day, str) or day.startswith('__'):
187
+ continue
188
+ day_data = md_dict[headline][day]
189
+ if not isinstance(day_data, dict):
190
+ continue
191
+ for subtitle in day_data:
192
+ if not isinstance(subtitle, str) or subtitle.startswith('__'):
193
+ continue
194
+ subsection_titles.add(subtitle)
195
+ if allowable_from_config and subtitle not in allowable_subsection_titles:
196
+ click.echo(f"Not allowed: {subtitle} in {headline}->{day}")
197
+
198
+ if allowable_from_config:
199
+ user_allowable = set(allowable_from_config)
200
+ click.echo(f"Allowable, but missing: {user_allowable - subsection_titles!r}")
201
+ click.echo(f"Not allowable, but found: {subsection_titles - user_allowable!r}")
202
+ else:
203
+ click.echo(f"Found subsections: {subsection_titles!r}")
204
+ click.echo(f"(No config file found at {DEFAULT_CONFIG_FILE} - showing all found sections)")
205
+
206
+
207
+ @digest.command()
208
+ @click.pass_context
209
+ def expenses(ctx):
210
+ """Summarize expenses from diary entries."""
211
+ unaccounted_content = []
212
+ accounted = []
213
+
214
+ for entry in ctx.obj['diary_list']:
215
+ if 'Expenses' not in entry:
216
+ continue
217
+
218
+ unaccounted = ""
219
+ if '__content__' not in entry['Expenses']:
220
+ raise DiaryParseError(
221
+ "Expenses section has no content",
222
+ file_name=entry.get('__file_name__'),
223
+ file_position=entry.get('__file_position__'),
224
+ section=f"{entry['dow']} {entry['date']} {entry.get('itenary', '')}",
225
+ date=entry['date']
226
+ )
227
+
228
+ expense_date = entry['date'] # YYYY-MM-DD format
229
+ expenses_text = entry['Expenses']['__content__'].strip().split('\n')
230
+
231
+ for expense in expenses_text:
232
+ if not unaccounted:
233
+ expense = expense.strip()
234
+ if not expense:
235
+ continue
236
+ findings = re.match(r"^\* ([A-Z]{3}) (-?\d+(?:\.\d+)?) - (.*)$", expense)
237
+ if findings:
238
+ accounted.append((findings.group(1), findings.group(2), findings.group(3), expense_date))
239
+ else:
240
+ unaccounted += expense
241
+
242
+ if unaccounted:
243
+ unaccounted_content.append(f"## {entry['dow']} {entry['date']} {entry['itenary']}\n")
244
+ unaccounted_content.append(unaccounted)
245
+
246
+ base_currency = 'EUR'
247
+
248
+ my_expenses = 0
249
+ total_expenses = 0
250
+ shared_expenses_per_head = 0
251
+ paid_by = defaultdict(float)
252
+ expenses_by_category = defaultdict(float)
253
+ conversion_warnings = []
254
+
255
+ for expense in accounted:
256
+ (currency, amount, details, expense_date) = expense
257
+ amount = float(amount)
258
+
259
+ if currency != base_currency:
260
+ rate = get_exchange_rate(currency, expense_date)
261
+ if rate is None:
262
+ conversion_warnings.append(f"Unknown currency {currency} on {expense_date}")
263
+ continue
264
+ amount = amount * rate
265
+ currency = base_currency
266
+
267
+ total_expenses += amount
268
+ category = details.split(' - ')[0]
269
+ if ' (' in category:
270
+ category = category.split(' (')[0]
271
+
272
+ paidbyf = re.search(r"- paid by ([^ ]*)", details)
273
+ if paidbyf:
274
+ paid_by[paidbyf.group(1)] += amount
275
+
276
+ sharedf = re.search(r" - DIV(\d+)", details)
277
+ if sharedf:
278
+ divisor = int(sharedf.group(1))
279
+ amount /= divisor
280
+ shared_expenses_per_head += amount
281
+
282
+ expenses_by_category[category] += amount
283
+ my_expenses += amount
284
+
285
+ click.echo("# Unaccounted text under expenses (look through)")
286
+ click.echo()
287
+ click.echo("\n".join(unaccounted_content))
288
+ click.echo("# Expenses by payer")
289
+ click.echo()
290
+ for payer in paid_by:
291
+ click.echo(f" * {base_currency} {paid_by[payer]:5.2f} - {payer}")
292
+ click.echo()
293
+ click.echo("# Expenses by category")
294
+ click.echo()
295
+ categories = list(expenses_by_category.keys())
296
+ categories.sort(key=lambda x: expenses_by_category[x])
297
+ for cat in categories:
298
+ click.echo(f" * {base_currency} {expenses_by_category[cat]:5.2f} - {cat}")
299
+ click.echo()
300
+
301
+ if conversion_warnings:
302
+ click.echo("# Currency conversion warnings")
303
+ click.echo()
304
+ for warning in conversion_warnings:
305
+ click.echo(f" * {warning}")
306
+ click.echo()
307
+
308
+ click.echo("# Totals")
309
+ click.echo()
310
+ click.echo(f"Total expenses: {base_currency} {total_expenses:5.2f}")
311
+ click.echo(f"Shared expenses per head: {base_currency} {shared_expenses_per_head:5.2f}")
312
+ click.echo(f"My expenses: {base_currency} {my_expenses:5.2f}")
313
+
314
+
315
+ def main():
316
+ """Entry point for diary-digest command."""
317
+ digest()
318
+
319
+
320
+ if __name__ == '__main__':
321
+ main()