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 +25 -0
- diary_md/_version.py +34 -0
- diary_md/cli/__init__.py +1 -0
- diary_md/cli/digest.py +321 -0
- diary_md/cli/reconcile.py +973 -0
- diary_md/cli/update.py +242 -0
- diary_md/exceptions.py +34 -0
- diary_md/exchange.py +126 -0
- diary_md/git.py +166 -0
- diary_md/models.py +147 -0
- diary_md/parser.py +404 -0
- diary_md-0.1.1.dist-info/METADATA +183 -0
- diary_md-0.1.1.dist-info/RECORD +16 -0
- diary_md-0.1.1.dist-info/WHEEL +4 -0
- diary_md-0.1.1.dist-info/entry_points.txt +4 -0
- diary_md-0.1.1.dist-info/licenses/LICENSE +661 -0
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
|
diary_md/cli/__init__.py
ADDED
|
@@ -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()
|