duras 1.0.0__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.
- duras/__init__.py +1 -0
- duras/cli.py +310 -0
- duras/core.py +813 -0
- duras-1.0.0.dist-info/METADATA +229 -0
- duras-1.0.0.dist-info/RECORD +9 -0
- duras-1.0.0.dist-info/WHEEL +5 -0
- duras-1.0.0.dist-info/entry_points.txt +2 -0
- duras-1.0.0.dist-info/licenses/LICENSE +16 -0
- duras-1.0.0.dist-info/top_level.txt +1 -0
duras/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .core import VERSION
|
duras/cli.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# duras — daily notes as plain text files, with search and optional encryption.
|
|
4
|
+
# No external dependencies (GnuPG optional for confidential notes).
|
|
5
|
+
#
|
|
6
|
+
# Copyright (c) 2026 Sergiy Duras
|
|
7
|
+
# SPDX-License-Identifier: ISC
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .core import (
|
|
13
|
+
VERSION,
|
|
14
|
+
DurasError,
|
|
15
|
+
DurasExternalError,
|
|
16
|
+
DurasInputError,
|
|
17
|
+
DurasNotFoundError,
|
|
18
|
+
_ensure_notes_dir_perms,
|
|
19
|
+
_parse_date_strict,
|
|
20
|
+
cmd_append,
|
|
21
|
+
cmd_audit,
|
|
22
|
+
cmd_dir,
|
|
23
|
+
cmd_echo,
|
|
24
|
+
cmd_export,
|
|
25
|
+
cmd_list,
|
|
26
|
+
cmd_mv,
|
|
27
|
+
cmd_near,
|
|
28
|
+
cmd_open,
|
|
29
|
+
cmd_path,
|
|
30
|
+
cmd_search,
|
|
31
|
+
cmd_show,
|
|
32
|
+
cmd_stats,
|
|
33
|
+
cmd_tags,
|
|
34
|
+
cmd_today,
|
|
35
|
+
parse_date,
|
|
36
|
+
today,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
41
|
+
p = argparse.ArgumentParser(
|
|
42
|
+
prog="duras",
|
|
43
|
+
description="plain-text daily notes with optional encryption",
|
|
44
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
45
|
+
epilog="""
|
|
46
|
+
SUBCOMMANDS
|
|
47
|
+
open [DATE] [-- ARGS] Open note in $EDITOR; pass ARGS after --
|
|
48
|
+
append [-d DATE] TEXT Append timestamped line; "-" reads stdin
|
|
49
|
+
show [DATE] Print note to stdout
|
|
50
|
+
list [-n N] List recent notes (default: 10, 0 = all)
|
|
51
|
+
search KEYWORD [-i] Search plain notes (literal, -i = ignore case)
|
|
52
|
+
tags [TAG] List all #tags or notes containing #TAG
|
|
53
|
+
stats Show counts, size, date range, streak
|
|
54
|
+
export [DIR] [--encrypt] Archive notes to tar.gz (optionally GPG)
|
|
55
|
+
path [DATE] Print absolute note path
|
|
56
|
+
dir Print notes root directory
|
|
57
|
+
today Print today's date (YYYY-MM-DD)
|
|
58
|
+
audit Validate notes directory structure
|
|
59
|
+
echo [DATE] List notes sharing the same MM-DD across years
|
|
60
|
+
near [DATE] List notes within ±3 days of a date
|
|
61
|
+
mv FROM TO Move a note from one date to another (YYYY-MM-DD only)
|
|
62
|
+
|
|
63
|
+
DATE FORMATS
|
|
64
|
+
YYYY-MM-DD Absolute date (e.g. 2026-01-15)
|
|
65
|
+
INTEGER Relative offset: 0 (today), -1 (yesterday)
|
|
66
|
+
|
|
67
|
+
ENVIRONMENT
|
|
68
|
+
DURAS_DIR Notes directory (default: ~/Documents/Notes)
|
|
69
|
+
EDITOR Editor (fallback: nano, vi, ed)
|
|
70
|
+
DURAS_GPG_KEY GPG recipient (default: self)
|
|
71
|
+
|
|
72
|
+
COMMON WORKFLOWS
|
|
73
|
+
duras Open today's note
|
|
74
|
+
duras open -1 Open yesterday's note
|
|
75
|
+
duras open -- +10 Open note and jump to line 10 in editor
|
|
76
|
+
|
|
77
|
+
duras append "note" Append to today
|
|
78
|
+
duras append -d -1 "x" Append to yesterday
|
|
79
|
+
cat file.txt | duras append - Append stdin
|
|
80
|
+
|
|
81
|
+
duras -c open Open encrypted note
|
|
82
|
+
duras -c append "secret" Append encrypted entry
|
|
83
|
+
duras show -c Show encrypted note
|
|
84
|
+
|
|
85
|
+
duras list Show recent notes
|
|
86
|
+
duras list -n 0 Show all notes
|
|
87
|
+
|
|
88
|
+
duras search "error" Search notes
|
|
89
|
+
duras search "todo" -i Case-insensitive search
|
|
90
|
+
|
|
91
|
+
duras tags List all tags
|
|
92
|
+
duras tags project Notes with #project
|
|
93
|
+
|
|
94
|
+
duras export ~/backup Create archive
|
|
95
|
+
duras export ~/backup --encrypt Encrypted archive
|
|
96
|
+
|
|
97
|
+
duras audit Verify notes directory is structurally clean
|
|
98
|
+
|
|
99
|
+
duras echo List past notes on today's date in history
|
|
100
|
+
duras echo 2026-03-15 List past notes on March 15 across all years
|
|
101
|
+
duras near List notes within ±3 days of today
|
|
102
|
+
duras near 2026-01-01 List notes around a specific date
|
|
103
|
+
|
|
104
|
+
duras mv 2026-04-17 2026-04-16 Move a misdated note (YYYY-MM-DD only)
|
|
105
|
+
|
|
106
|
+
NOTES
|
|
107
|
+
Files are plain text (.dn); encrypted notes use .dn.gpg
|
|
108
|
+
Encrypted notes are skipped by search and tags
|
|
109
|
+
|
|
110
|
+
EXIT CODES
|
|
111
|
+
0 Success
|
|
112
|
+
1 Generic error
|
|
113
|
+
2 Not found
|
|
114
|
+
3 Invalid input
|
|
115
|
+
4 External tool failure
|
|
116
|
+
|
|
117
|
+
Documentation:
|
|
118
|
+
https://codeberg.org/duras/duras
|
|
119
|
+
""",
|
|
120
|
+
)
|
|
121
|
+
p.add_argument("--version", action="version", version=f"duras {VERSION}")
|
|
122
|
+
p.add_argument(
|
|
123
|
+
"-c",
|
|
124
|
+
"--confidential",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="use gpg encryption for this note",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
sub = p.add_subparsers(dest="cmd")
|
|
130
|
+
|
|
131
|
+
p_open = sub.add_parser("open", help="open note in $EDITOR")
|
|
132
|
+
p_open.add_argument(
|
|
133
|
+
"date", nargs="?", help="YYYY-MM-DD or offset (default: 0)"
|
|
134
|
+
)
|
|
135
|
+
p_open.add_argument(
|
|
136
|
+
"extra",
|
|
137
|
+
nargs=argparse.REMAINDER,
|
|
138
|
+
help="args passed to $EDITOR (after --)",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
p_app = sub.add_parser("append", help="append text without editor")
|
|
142
|
+
p_app.add_argument(
|
|
143
|
+
"-d", "--date", dest="date", help="YYYY-MM-DD or offset (default: 0)"
|
|
144
|
+
)
|
|
145
|
+
p_app.add_argument("text", help="text to append or '-' for stdin")
|
|
146
|
+
|
|
147
|
+
p_show = sub.add_parser("show", help="print note to stdout")
|
|
148
|
+
p_show.add_argument(
|
|
149
|
+
"date", nargs="?", help="YYYY-MM-DD or offset (default: 0)"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
p_list = sub.add_parser("list", help="list recent notes")
|
|
153
|
+
p_list.add_argument(
|
|
154
|
+
"-n",
|
|
155
|
+
"--count",
|
|
156
|
+
type=int,
|
|
157
|
+
default=10,
|
|
158
|
+
metavar="N",
|
|
159
|
+
help="notes to show (0 = all)",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
p_search = sub.add_parser("search", help="search notes for keyword")
|
|
163
|
+
p_search.add_argument("keyword", help="text to search for")
|
|
164
|
+
p_search.add_argument(
|
|
165
|
+
"-i",
|
|
166
|
+
"--ignore-case",
|
|
167
|
+
action="store_true",
|
|
168
|
+
help="case-insensitive search",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
p_tags = sub.add_parser("tags", help="list all #tags (or notes for a tag)")
|
|
172
|
+
p_tags.add_argument(
|
|
173
|
+
"tag", nargs="?", default="", help="#TAG to filter by (optional)"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
sub.add_parser("stats", help="show note counts, size, and streak")
|
|
177
|
+
|
|
178
|
+
p_export = sub.add_parser("export", help="archive notes to tar.gz")
|
|
179
|
+
p_export.add_argument(
|
|
180
|
+
"dest",
|
|
181
|
+
nargs="?",
|
|
182
|
+
default=".",
|
|
183
|
+
help="destination directory (default: .)",
|
|
184
|
+
)
|
|
185
|
+
p_export.add_argument(
|
|
186
|
+
"--encrypt", action="store_true", help="encrypt the archive with gpg"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
p_path = sub.add_parser("path", help="print note file path")
|
|
190
|
+
p_path.add_argument(
|
|
191
|
+
"date", nargs="?", help="YYYY-MM-DD or offset (default: 0)"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
sub.add_parser("dir", help="print notes directory")
|
|
195
|
+
sub.add_parser("today", help="print today's date")
|
|
196
|
+
sub.add_parser("audit", help="validate notes directory structure")
|
|
197
|
+
|
|
198
|
+
p_echo = sub.add_parser(
|
|
199
|
+
"echo", help="list notes sharing the same MM-DD across years"
|
|
200
|
+
)
|
|
201
|
+
p_echo.add_argument(
|
|
202
|
+
"date", nargs="?", help="YYYY-MM-DD or offset (default: today)"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
p_near = sub.add_parser("near", help="list notes within ±3 days of a date")
|
|
206
|
+
p_near.add_argument(
|
|
207
|
+
"date", nargs="?", help="YYYY-MM-DD or offset (default: today)"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
p_mv = sub.add_parser("mv", help="move a note from one date to another")
|
|
211
|
+
p_mv.add_argument(
|
|
212
|
+
"old_date", metavar="FROM", help="source date (YYYY-MM-DD)"
|
|
213
|
+
)
|
|
214
|
+
p_mv.add_argument(
|
|
215
|
+
"new_date", metavar="TO", help="destination date (YYYY-MM-DD)"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return p
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main() -> None:
|
|
222
|
+
argv = sys.argv[1:]
|
|
223
|
+
|
|
224
|
+
parser = build_parser()
|
|
225
|
+
args = parser.parse_args(argv)
|
|
226
|
+
confidential = args.confidential
|
|
227
|
+
|
|
228
|
+
_ensure_notes_dir_perms()
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
if args.cmd is None:
|
|
232
|
+
extra_args = []
|
|
233
|
+
if "--" in argv:
|
|
234
|
+
split = argv.index("--")
|
|
235
|
+
extra_args = argv[split + 1 :]
|
|
236
|
+
cmd_open(today(), confidential, extra_args)
|
|
237
|
+
|
|
238
|
+
elif args.cmd == "open":
|
|
239
|
+
date = parse_date(args.date) if args.date else today()
|
|
240
|
+
ea = [a for a in (args.extra or []) if a != "--"]
|
|
241
|
+
cmd_open(date, confidential, ea)
|
|
242
|
+
|
|
243
|
+
elif args.cmd == "append":
|
|
244
|
+
date = parse_date(args.date) if args.date else today()
|
|
245
|
+
if args.text == "-":
|
|
246
|
+
text = sys.stdin.read().rstrip("\n")
|
|
247
|
+
if not text:
|
|
248
|
+
raise DurasInputError("no text read from stdin")
|
|
249
|
+
else:
|
|
250
|
+
text = args.text
|
|
251
|
+
cmd_append(date, text, confidential)
|
|
252
|
+
|
|
253
|
+
elif args.cmd == "show":
|
|
254
|
+
date = parse_date(args.date) if args.date else today()
|
|
255
|
+
cmd_show(date, confidential)
|
|
256
|
+
|
|
257
|
+
elif args.cmd == "list":
|
|
258
|
+
cmd_list(args.count)
|
|
259
|
+
|
|
260
|
+
elif args.cmd == "search":
|
|
261
|
+
cmd_search(args.keyword, args.ignore_case)
|
|
262
|
+
|
|
263
|
+
elif args.cmd == "tags":
|
|
264
|
+
cmd_tags(args.tag)
|
|
265
|
+
|
|
266
|
+
elif args.cmd == "stats":
|
|
267
|
+
cmd_stats()
|
|
268
|
+
|
|
269
|
+
elif args.cmd == "export":
|
|
270
|
+
cmd_export(args.dest, args.encrypt)
|
|
271
|
+
|
|
272
|
+
elif args.cmd == "path":
|
|
273
|
+
date = parse_date(args.date) if args.date else today()
|
|
274
|
+
cmd_path(date, confidential)
|
|
275
|
+
|
|
276
|
+
elif args.cmd == "dir":
|
|
277
|
+
cmd_dir()
|
|
278
|
+
|
|
279
|
+
elif args.cmd == "today":
|
|
280
|
+
cmd_today()
|
|
281
|
+
|
|
282
|
+
elif args.cmd == "audit":
|
|
283
|
+
count = cmd_audit()
|
|
284
|
+
if count:
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
|
|
287
|
+
elif args.cmd == "echo":
|
|
288
|
+
date = parse_date(args.date) if args.date else today()
|
|
289
|
+
cmd_echo(date)
|
|
290
|
+
|
|
291
|
+
elif args.cmd == "near":
|
|
292
|
+
date = parse_date(args.date) if args.date else today()
|
|
293
|
+
cmd_near(date)
|
|
294
|
+
|
|
295
|
+
elif args.cmd == "mv":
|
|
296
|
+
old_date = _parse_date_strict(args.old_date)
|
|
297
|
+
new_date = _parse_date_strict(args.new_date)
|
|
298
|
+
cmd_mv(old_date, new_date)
|
|
299
|
+
|
|
300
|
+
else:
|
|
301
|
+
parser.print_help()
|
|
302
|
+
sys.exit(1)
|
|
303
|
+
|
|
304
|
+
except DurasError as e:
|
|
305
|
+
print(f"duras: {e}", file=sys.stderr)
|
|
306
|
+
sys.exit(e.exit_code)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
main()
|
duras/core.py
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# duras — daily notes as plain text files, with search and optional encryption.
|
|
4
|
+
# core library: all domain logic. Imported by cli.py.
|
|
5
|
+
#
|
|
6
|
+
# Copyright (c) 2026 Sergiy Duras
|
|
7
|
+
# SPDX-License-Identifier: ISC
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
VERSION = "1.0.0"
|
|
22
|
+
|
|
23
|
+
DEFAULT_NOTES_DIR = os.path.join(os.path.expanduser("~"), "Documents", "Notes")
|
|
24
|
+
NOTE_EXT = ".dn"
|
|
25
|
+
ENC_EXT = ".dn.gpg"
|
|
26
|
+
DATE_FMT = "%Y-%m-%d"
|
|
27
|
+
DATETIME_FMT = "%Y-%m-%d %H:%M"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DurasError(Exception):
|
|
31
|
+
"""Raised by core on any recoverable user-facing error."""
|
|
32
|
+
|
|
33
|
+
exit_code = 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DurasNotFoundError(DurasError):
|
|
37
|
+
"""Note or directory not found. exit_code=2."""
|
|
38
|
+
|
|
39
|
+
exit_code = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DurasInputError(DurasError):
|
|
43
|
+
"""Invalid user input (bad date, wrong flag, etc.). exit_code=3."""
|
|
44
|
+
|
|
45
|
+
exit_code = 3
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DurasExternalError(DurasError):
|
|
49
|
+
"""External tool failure (gpg, editor). exit_code=4."""
|
|
50
|
+
|
|
51
|
+
exit_code = 4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def warn(msg: str) -> None:
|
|
55
|
+
print(f"duras: warning: {msg}", file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def notes_dir() -> str:
|
|
59
|
+
d = os.environ.get("DURAS_DIR", DEFAULT_NOTES_DIR)
|
|
60
|
+
return os.path.abspath(os.path.expanduser(d))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def editor() -> list[str]:
|
|
64
|
+
ed = os.environ.get("EDITOR")
|
|
65
|
+
if ed:
|
|
66
|
+
return shlex.split(ed)
|
|
67
|
+
|
|
68
|
+
for candidate in ("nano", "vi", "ed"):
|
|
69
|
+
if shutil.which(candidate):
|
|
70
|
+
return [candidate]
|
|
71
|
+
|
|
72
|
+
raise DurasExternalError("no editor found: set $EDITOR")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def gpg_key() -> str:
|
|
76
|
+
return os.environ.get("DURAS_GPG_KEY") or ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def today() -> datetime.date:
|
|
80
|
+
return datetime.date.today()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_date(s: str) -> datetime.date:
|
|
84
|
+
try:
|
|
85
|
+
return datetime.datetime.strptime(s, DATE_FMT).date()
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
try:
|
|
89
|
+
return today() + datetime.timedelta(days=int(s))
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise DurasInputError(
|
|
92
|
+
f"invalid date '{s}' — expected YYYY-MM-DD or integer offset (e.g. -1, 0, 7)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def note_path(date: datetime.date, confidential: bool = False) -> str:
|
|
97
|
+
ext = ENC_EXT if confidential else NOTE_EXT
|
|
98
|
+
d = notes_dir()
|
|
99
|
+
return os.path.join(
|
|
100
|
+
d,
|
|
101
|
+
f"{date.year:04d}",
|
|
102
|
+
f"{date.month:02d}",
|
|
103
|
+
f"{date.strftime(DATE_FMT)}{ext}",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def all_note_files() -> list[str]:
|
|
108
|
+
d = notes_dir()
|
|
109
|
+
found: list[str] = []
|
|
110
|
+
if not os.path.isdir(d):
|
|
111
|
+
return found
|
|
112
|
+
for root, _, files in os.walk(d):
|
|
113
|
+
for name in files:
|
|
114
|
+
if name.endswith(NOTE_EXT) or name.endswith(ENC_EXT):
|
|
115
|
+
found.append(os.path.join(root, name))
|
|
116
|
+
found.sort(key=os.path.basename)
|
|
117
|
+
return found
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_broken_symlink(entry: "os.DirEntry[str]") -> bool:
|
|
121
|
+
return entry.is_symlink() and not os.path.exists(entry.path)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_note_filename(
|
|
125
|
+
name: str,
|
|
126
|
+
) -> tuple[Optional[datetime.date], str]:
|
|
127
|
+
if name.endswith(ENC_EXT):
|
|
128
|
+
stem = name[: -len(ENC_EXT)]
|
|
129
|
+
elif name.endswith(NOTE_EXT):
|
|
130
|
+
stem = name[: -len(NOTE_EXT)]
|
|
131
|
+
else:
|
|
132
|
+
return None, "not a .dn or .dn.gpg file"
|
|
133
|
+
try:
|
|
134
|
+
return datetime.datetime.strptime(stem, DATE_FMT).date(), ""
|
|
135
|
+
except ValueError:
|
|
136
|
+
return None, f"filename date {stem!r} is not a valid YYYY-MM-DD date"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _header_lines(date: datetime.date, is_today: bool) -> int:
|
|
140
|
+
return 3
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def ensure_dir(path: str) -> None:
|
|
144
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ensure_notes_dir_perms() -> None:
|
|
148
|
+
d = notes_dir()
|
|
149
|
+
try:
|
|
150
|
+
os.makedirs(d, exist_ok=True)
|
|
151
|
+
mode = os.stat(d).st_mode & 0o777
|
|
152
|
+
if mode & 0o077:
|
|
153
|
+
os.chmod(d, 0o700)
|
|
154
|
+
except OSError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _secure_tmpdir() -> str:
|
|
159
|
+
shm = "/dev/shm"
|
|
160
|
+
if os.path.isdir(shm) and os.access(shm, os.W_OK):
|
|
161
|
+
try:
|
|
162
|
+
return tempfile.mkdtemp(dir=shm, prefix="duras-")
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
return tempfile.mkdtemp(prefix="duras-")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def atomic_write(path: str, content: str) -> None:
|
|
169
|
+
dir_ = os.path.dirname(path)
|
|
170
|
+
os.makedirs(dir_, exist_ok=True)
|
|
171
|
+
fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".duras-")
|
|
172
|
+
try:
|
|
173
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
174
|
+
f.write(content)
|
|
175
|
+
f.flush()
|
|
176
|
+
os.fsync(f.fileno())
|
|
177
|
+
os.chmod(tmp, 0o600)
|
|
178
|
+
os.replace(tmp, path)
|
|
179
|
+
dir_fd = os.open(dir_, os.O_RDONLY)
|
|
180
|
+
try:
|
|
181
|
+
os.fsync(dir_fd)
|
|
182
|
+
finally:
|
|
183
|
+
os.close(dir_fd)
|
|
184
|
+
except Exception:
|
|
185
|
+
try:
|
|
186
|
+
os.unlink(tmp)
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def atomic_write_bytes(path: str, data: bytes) -> None:
|
|
193
|
+
dir_ = os.path.dirname(path)
|
|
194
|
+
os.makedirs(dir_, exist_ok=True)
|
|
195
|
+
fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".duras-")
|
|
196
|
+
try:
|
|
197
|
+
with os.fdopen(fd, "wb") as f:
|
|
198
|
+
f.write(data)
|
|
199
|
+
f.flush()
|
|
200
|
+
os.fsync(f.fileno())
|
|
201
|
+
os.chmod(tmp, 0o600)
|
|
202
|
+
os.replace(tmp, path)
|
|
203
|
+
dir_fd = os.open(dir_, os.O_RDONLY)
|
|
204
|
+
try:
|
|
205
|
+
os.fsync(dir_fd)
|
|
206
|
+
finally:
|
|
207
|
+
os.close(dir_fd)
|
|
208
|
+
except Exception:
|
|
209
|
+
try:
|
|
210
|
+
os.unlink(tmp)
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
raise
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def note_header(date: datetime.date, is_today: bool = True) -> str:
|
|
217
|
+
now = datetime.datetime.now()
|
|
218
|
+
if is_today:
|
|
219
|
+
return f"{date.strftime(DATE_FMT)}\n{now.strftime('%H:%M')}\n\n"
|
|
220
|
+
return (
|
|
221
|
+
f"{date.strftime(DATE_FMT)}\ncreated: {now.strftime(DATETIME_FMT)}\n\n"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def init_note(path: str, date: datetime.date, is_today: bool = True) -> None:
|
|
226
|
+
atomic_write(path, note_header(date, is_today))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def append_text(path: str, text: str) -> None:
|
|
230
|
+
now = datetime.datetime.now().strftime(DATETIME_FMT)
|
|
231
|
+
line = f"[{now}] {text}\n"
|
|
232
|
+
existing = ""
|
|
233
|
+
if os.path.exists(path):
|
|
234
|
+
try:
|
|
235
|
+
with open(path, encoding="utf-8", errors="strict") as f:
|
|
236
|
+
existing = f.read()
|
|
237
|
+
except UnicodeDecodeError as e:
|
|
238
|
+
raise DurasError(
|
|
239
|
+
f"note is not valid UTF-8, will not append: {path}: {e}"
|
|
240
|
+
)
|
|
241
|
+
if existing and not existing.endswith("\n"):
|
|
242
|
+
existing += "\n"
|
|
243
|
+
atomic_write(path, existing + line)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def open_in_editor(path: str, extra_args: Optional[list[str]] = None) -> None:
|
|
247
|
+
args = editor() + (extra_args or []) + [path]
|
|
248
|
+
try:
|
|
249
|
+
result = subprocess.run(args)
|
|
250
|
+
except FileNotFoundError:
|
|
251
|
+
raise DurasExternalError(
|
|
252
|
+
f"editor not found: {args[0]!r} — set $EDITOR"
|
|
253
|
+
)
|
|
254
|
+
if result.returncode != 0:
|
|
255
|
+
raise DurasExternalError(
|
|
256
|
+
f"editor exited with status {result.returncode}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def gpg_encrypt(plaintext: bytes, dest: str) -> None:
|
|
261
|
+
key = gpg_key()
|
|
262
|
+
dir_ = os.path.dirname(dest) or "."
|
|
263
|
+
os.makedirs(dir_, exist_ok=True)
|
|
264
|
+
fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".duras-")
|
|
265
|
+
try:
|
|
266
|
+
os.close(fd)
|
|
267
|
+
cmd = ["gpg", "--batch", "--yes", "--quiet", "--output", tmp]
|
|
268
|
+
if key:
|
|
269
|
+
cmd += ["--recipient", key, "--encrypt"]
|
|
270
|
+
else:
|
|
271
|
+
cmd += ["--default-recipient-self", "--encrypt"]
|
|
272
|
+
result = subprocess.run(cmd, input=plaintext, capture_output=True)
|
|
273
|
+
if result.returncode != 0:
|
|
274
|
+
raise DurasExternalError(
|
|
275
|
+
f"gpg encryption failed: {result.stderr.decode(errors='replace').strip()}"
|
|
276
|
+
)
|
|
277
|
+
os.chmod(tmp, 0o600)
|
|
278
|
+
os.replace(tmp, dest)
|
|
279
|
+
except Exception:
|
|
280
|
+
try:
|
|
281
|
+
os.unlink(tmp)
|
|
282
|
+
except OSError:
|
|
283
|
+
pass
|
|
284
|
+
raise
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def gpg_decrypt(path: str) -> bytes:
|
|
288
|
+
result = subprocess.run(
|
|
289
|
+
["gpg", "--batch", "--quiet", "--decrypt", path],
|
|
290
|
+
capture_output=True,
|
|
291
|
+
)
|
|
292
|
+
if result.returncode != 0:
|
|
293
|
+
raise DurasExternalError(
|
|
294
|
+
f"gpg decryption failed: {result.stderr.decode(errors='replace').strip()}"
|
|
295
|
+
)
|
|
296
|
+
return result.stdout
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def open_confidential_in_editor(
|
|
300
|
+
enc_path: str,
|
|
301
|
+
date: datetime.date,
|
|
302
|
+
extra_args: Optional[list[str]] = None,
|
|
303
|
+
is_today: bool = True,
|
|
304
|
+
) -> None:
|
|
305
|
+
tmp_dir = _secure_tmpdir()
|
|
306
|
+
tmp_path = os.path.join(tmp_dir, f"{date.strftime(DATE_FMT)}.dn")
|
|
307
|
+
try:
|
|
308
|
+
plaintext = (
|
|
309
|
+
gpg_decrypt(enc_path)
|
|
310
|
+
if os.path.exists(enc_path)
|
|
311
|
+
else note_header(date, is_today).encode("utf-8")
|
|
312
|
+
)
|
|
313
|
+
with open(tmp_path, "wb") as f:
|
|
314
|
+
f.write(plaintext)
|
|
315
|
+
os.chmod(tmp_path, 0o600)
|
|
316
|
+
|
|
317
|
+
open_in_editor(tmp_path, extra_args)
|
|
318
|
+
|
|
319
|
+
with open(tmp_path, "rb") as f:
|
|
320
|
+
new_plaintext = f.read()
|
|
321
|
+
ensure_dir(enc_path)
|
|
322
|
+
gpg_encrypt(new_plaintext, enc_path)
|
|
323
|
+
finally:
|
|
324
|
+
try:
|
|
325
|
+
os.unlink(tmp_path)
|
|
326
|
+
except OSError:
|
|
327
|
+
pass
|
|
328
|
+
try:
|
|
329
|
+
os.rmdir(tmp_dir)
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cmd_open(
|
|
335
|
+
date: datetime.date, confidential: bool, extra_args: list[str]
|
|
336
|
+
) -> None:
|
|
337
|
+
is_today = date == today()
|
|
338
|
+
if date > today():
|
|
339
|
+
raise DurasInputError(
|
|
340
|
+
f"cannot open a note for a future date: {date.strftime(DATE_FMT)}"
|
|
341
|
+
)
|
|
342
|
+
if confidential:
|
|
343
|
+
enc_path = note_path(date, confidential=True)
|
|
344
|
+
ensure_dir(enc_path)
|
|
345
|
+
open_confidential_in_editor(enc_path, date, extra_args, is_today)
|
|
346
|
+
else:
|
|
347
|
+
path = note_path(date)
|
|
348
|
+
ensure_dir(path)
|
|
349
|
+
is_new = not os.path.exists(path)
|
|
350
|
+
if is_new:
|
|
351
|
+
init_note(path, date, is_today)
|
|
352
|
+
ea = extra_args or (
|
|
353
|
+
["+" + str(_header_lines(date, is_today) + 1)] if is_new else []
|
|
354
|
+
)
|
|
355
|
+
open_in_editor(path, ea)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_append_confidential(date: datetime.date, text: str) -> None:
|
|
359
|
+
enc_path = note_path(date, confidential=True)
|
|
360
|
+
is_today = date == today()
|
|
361
|
+
now = datetime.datetime.now().strftime(DATETIME_FMT)
|
|
362
|
+
line = f"[{now}] {text}\n"
|
|
363
|
+
if os.path.exists(enc_path):
|
|
364
|
+
existing = gpg_decrypt(enc_path).decode("utf-8", errors="replace")
|
|
365
|
+
if existing and not existing.endswith("\n"):
|
|
366
|
+
existing += "\n"
|
|
367
|
+
new_content = existing + line
|
|
368
|
+
else:
|
|
369
|
+
ensure_dir(enc_path)
|
|
370
|
+
new_content = note_header(date, is_today) + line
|
|
371
|
+
gpg_encrypt(new_content.encode("utf-8"), enc_path)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def cmd_append(
|
|
375
|
+
date: datetime.date, text: str, confidential: bool = False
|
|
376
|
+
) -> None:
|
|
377
|
+
if date > today():
|
|
378
|
+
raise DurasInputError(
|
|
379
|
+
f"cannot append to a future date: {date.strftime(DATE_FMT)}"
|
|
380
|
+
)
|
|
381
|
+
if confidential:
|
|
382
|
+
cmd_append_confidential(date, text)
|
|
383
|
+
return
|
|
384
|
+
is_today = date == today()
|
|
385
|
+
path = note_path(date)
|
|
386
|
+
ensure_dir(path)
|
|
387
|
+
if not os.path.exists(path):
|
|
388
|
+
init_note(path, date, is_today)
|
|
389
|
+
append_text(path, text)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def cmd_show(date: datetime.date, confidential: bool = False) -> None:
|
|
393
|
+
plain = note_path(date)
|
|
394
|
+
enc = note_path(date, confidential=True)
|
|
395
|
+
plain_exists = os.path.exists(plain)
|
|
396
|
+
enc_exists = os.path.exists(enc)
|
|
397
|
+
if confidential:
|
|
398
|
+
if enc_exists:
|
|
399
|
+
data = gpg_decrypt(enc)
|
|
400
|
+
sys.stdout.buffer.write(data)
|
|
401
|
+
elif plain_exists:
|
|
402
|
+
raise DurasInputError(
|
|
403
|
+
f"no encrypted note for {date.strftime(DATE_FMT)} (plain note exists; omit -c)"
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
raise DurasNotFoundError(f"no note for {date.strftime(DATE_FMT)}")
|
|
407
|
+
else:
|
|
408
|
+
if plain_exists:
|
|
409
|
+
if enc_exists:
|
|
410
|
+
warn(
|
|
411
|
+
"both plain and encrypted notes exist for this date; showing plain (use -c for encrypted)"
|
|
412
|
+
)
|
|
413
|
+
try:
|
|
414
|
+
with open(plain, encoding="utf-8", errors="strict") as f:
|
|
415
|
+
sys.stdout.write(f.read())
|
|
416
|
+
except UnicodeDecodeError as e:
|
|
417
|
+
raise DurasError(f"note is not valid UTF-8: {plain}: {e}")
|
|
418
|
+
elif enc_exists:
|
|
419
|
+
raise DurasInputError(
|
|
420
|
+
"only encrypted note exists for this date; use -c to decrypt"
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
raise DurasNotFoundError(f"no note for {date.strftime(DATE_FMT)}")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def cmd_list(count: int) -> None:
|
|
427
|
+
files = all_note_files()
|
|
428
|
+
if not files:
|
|
429
|
+
print("no notes found")
|
|
430
|
+
return
|
|
431
|
+
shown = files[-count:] if count > 0 else files
|
|
432
|
+
for path in reversed(shown):
|
|
433
|
+
size = os.path.getsize(path)
|
|
434
|
+
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(path))
|
|
435
|
+
name = os.path.basename(path)
|
|
436
|
+
enc = " ⚿" if path.endswith(".gpg") else ""
|
|
437
|
+
print(
|
|
438
|
+
f"· {mtime.strftime('%Y-%m-%d %H:%M')} {size/1024:>5.1f}KB {name}{enc}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def cmd_search(keyword: str, ignore_case: bool = False) -> None:
|
|
443
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
444
|
+
rx = re.compile(re.escape(keyword), flags)
|
|
445
|
+
skipped = 0
|
|
446
|
+
found = False
|
|
447
|
+
for path in all_note_files():
|
|
448
|
+
if path.endswith(".gpg"):
|
|
449
|
+
skipped += 1
|
|
450
|
+
continue
|
|
451
|
+
try:
|
|
452
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
453
|
+
file_matches: list[tuple[int, str]] = []
|
|
454
|
+
for lineno, line in enumerate(f, 1):
|
|
455
|
+
if rx.search(line):
|
|
456
|
+
file_matches.append((lineno, line.rstrip()))
|
|
457
|
+
except OSError as e:
|
|
458
|
+
warn(f"cannot read {path}: {e}")
|
|
459
|
+
continue
|
|
460
|
+
if file_matches:
|
|
461
|
+
found = True
|
|
462
|
+
print(path)
|
|
463
|
+
for lineno, line in file_matches:
|
|
464
|
+
print(f" {lineno}: {line}")
|
|
465
|
+
if skipped:
|
|
466
|
+
warn(f"{skipped} encrypted note(s) not searched")
|
|
467
|
+
if not found:
|
|
468
|
+
print(f"no matches for '{keyword}'")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def cmd_tags(tag: str = "") -> None:
|
|
472
|
+
tag_rx = re.compile(r"#([A-Za-z0-9_-]+)")
|
|
473
|
+
if tag:
|
|
474
|
+
needle = tag.lstrip("#").lower()
|
|
475
|
+
skipped = 0
|
|
476
|
+
found = False
|
|
477
|
+
for path in all_note_files():
|
|
478
|
+
if path.endswith(".gpg"):
|
|
479
|
+
skipped += 1
|
|
480
|
+
continue
|
|
481
|
+
try:
|
|
482
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
483
|
+
for line in f:
|
|
484
|
+
if any(
|
|
485
|
+
m.group(1).lower() == needle
|
|
486
|
+
for m in tag_rx.finditer(line)
|
|
487
|
+
):
|
|
488
|
+
print(path)
|
|
489
|
+
found = True
|
|
490
|
+
break
|
|
491
|
+
except OSError as e:
|
|
492
|
+
warn(f"cannot read {path}: {e}")
|
|
493
|
+
if skipped:
|
|
494
|
+
warn(f"{skipped} encrypted note(s) not searched")
|
|
495
|
+
if not found:
|
|
496
|
+
print(f"no notes tagged #{needle}")
|
|
497
|
+
return
|
|
498
|
+
tags: dict[str, int] = {}
|
|
499
|
+
skipped = 0
|
|
500
|
+
for path in all_note_files():
|
|
501
|
+
if path.endswith(".gpg"):
|
|
502
|
+
skipped += 1
|
|
503
|
+
continue
|
|
504
|
+
try:
|
|
505
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
506
|
+
for line in f:
|
|
507
|
+
for m in tag_rx.finditer(line):
|
|
508
|
+
t = m.group(1).lower()
|
|
509
|
+
tags[t] = tags.get(t, 0) + 1
|
|
510
|
+
except OSError as e:
|
|
511
|
+
warn(f"cannot read {path}: {e}")
|
|
512
|
+
if skipped:
|
|
513
|
+
warn(f"{skipped} encrypted note(s) not searched")
|
|
514
|
+
if not tags:
|
|
515
|
+
print("no tags found")
|
|
516
|
+
return
|
|
517
|
+
width = max(len(t) for t in tags)
|
|
518
|
+
for t, count in sorted(tags.items()):
|
|
519
|
+
print(f"#{t:<{width}} {count}")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def cmd_stats() -> None:
|
|
523
|
+
files = all_note_files()
|
|
524
|
+
if not files:
|
|
525
|
+
print("no notes found")
|
|
526
|
+
return
|
|
527
|
+
total_size = sum(os.path.getsize(p) for p in files)
|
|
528
|
+
plain = [p for p in files if not p.endswith(".gpg")]
|
|
529
|
+
enc = [p for p in files if p.endswith(".gpg")]
|
|
530
|
+
date_rx = re.compile(r"(\d{4}-\d{2}-\d{2})\.dn")
|
|
531
|
+
dates: set[datetime.date] = set()
|
|
532
|
+
for p in files:
|
|
533
|
+
m = date_rx.search(os.path.basename(p))
|
|
534
|
+
if m:
|
|
535
|
+
try:
|
|
536
|
+
dates.add(datetime.date.fromisoformat(m.group(1)))
|
|
537
|
+
except ValueError:
|
|
538
|
+
pass
|
|
539
|
+
streak = 0
|
|
540
|
+
if dates:
|
|
541
|
+
check = today()
|
|
542
|
+
while check in dates:
|
|
543
|
+
streak += 1
|
|
544
|
+
check -= datetime.timedelta(days=1)
|
|
545
|
+
oldest = min(dates).isoformat() if dates else "—"
|
|
546
|
+
newest = max(dates).isoformat() if dates else "—"
|
|
547
|
+
print(
|
|
548
|
+
f"notes: {len(plain)} plain, {len(enc)} encrypted ({len(files)} total)"
|
|
549
|
+
)
|
|
550
|
+
print(f"size: {total_size / 1024:.1f} KB")
|
|
551
|
+
print(f"range: {oldest} — {newest}")
|
|
552
|
+
print(f"streak: {streak} day(s)")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def cmd_export(dest_dir: str, encrypt: bool) -> None:
|
|
556
|
+
d = notes_dir()
|
|
557
|
+
if not os.path.isdir(d):
|
|
558
|
+
raise DurasNotFoundError(f"notes directory not found: {d}")
|
|
559
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
560
|
+
try:
|
|
561
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
562
|
+
except OSError as e:
|
|
563
|
+
raise DurasError(f"cannot create destination directory: {e}")
|
|
564
|
+
|
|
565
|
+
buf = io.BytesIO()
|
|
566
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
567
|
+
tar.add(d, arcname="notes")
|
|
568
|
+
archive_bytes = buf.getvalue()
|
|
569
|
+
buf.close()
|
|
570
|
+
|
|
571
|
+
if encrypt:
|
|
572
|
+
dest = os.path.join(dest_dir, f"duras-{stamp}.tar.gz.gpg")
|
|
573
|
+
dir_ = os.path.dirname(os.path.abspath(dest))
|
|
574
|
+
fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".duras-export-")
|
|
575
|
+
try:
|
|
576
|
+
os.close(fd)
|
|
577
|
+
key = gpg_key()
|
|
578
|
+
cmd = ["gpg", "--batch", "--yes", "--quiet", "--output", tmp]
|
|
579
|
+
if key:
|
|
580
|
+
cmd += ["--recipient", key, "--encrypt"]
|
|
581
|
+
else:
|
|
582
|
+
cmd += ["--default-recipient-self", "--encrypt"]
|
|
583
|
+
result = subprocess.run(
|
|
584
|
+
cmd, input=archive_bytes, capture_output=True
|
|
585
|
+
)
|
|
586
|
+
if result.returncode != 0:
|
|
587
|
+
raise DurasExternalError(
|
|
588
|
+
f"gpg encryption of archive failed: {result.stderr.decode(errors='replace').strip()}"
|
|
589
|
+
)
|
|
590
|
+
os.chmod(tmp, 0o600)
|
|
591
|
+
os.replace(tmp, dest)
|
|
592
|
+
except DurasError:
|
|
593
|
+
try:
|
|
594
|
+
os.unlink(tmp)
|
|
595
|
+
except OSError:
|
|
596
|
+
pass
|
|
597
|
+
raise
|
|
598
|
+
except Exception as e:
|
|
599
|
+
try:
|
|
600
|
+
os.unlink(tmp)
|
|
601
|
+
except OSError:
|
|
602
|
+
pass
|
|
603
|
+
raise DurasError(f"export failed: {e}")
|
|
604
|
+
else:
|
|
605
|
+
dest = os.path.join(dest_dir, f"duras-{stamp}.tar.gz")
|
|
606
|
+
dir_ = os.path.dirname(os.path.abspath(dest))
|
|
607
|
+
fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".duras-export-")
|
|
608
|
+
try:
|
|
609
|
+
with os.fdopen(fd, "wb") as f:
|
|
610
|
+
f.write(archive_bytes)
|
|
611
|
+
os.chmod(tmp, 0o600)
|
|
612
|
+
os.replace(tmp, dest)
|
|
613
|
+
except Exception as e:
|
|
614
|
+
try:
|
|
615
|
+
os.unlink(tmp)
|
|
616
|
+
except OSError:
|
|
617
|
+
pass
|
|
618
|
+
raise DurasError(f"export failed: {e}")
|
|
619
|
+
|
|
620
|
+
print(dest)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def cmd_path(date: datetime.date, confidential: bool) -> None:
|
|
624
|
+
print(note_path(date, confidential))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def cmd_dir() -> None:
|
|
628
|
+
print(notes_dir())
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def cmd_today() -> None:
|
|
632
|
+
print(today().strftime(DATE_FMT))
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cmd_audit() -> int:
|
|
636
|
+
d = notes_dir()
|
|
637
|
+
if not os.path.isdir(d):
|
|
638
|
+
print("notes directory does not exist; nothing to audit")
|
|
639
|
+
return 0
|
|
640
|
+
|
|
641
|
+
issues: list[str] = []
|
|
642
|
+
seen_plain: set[datetime.date] = set()
|
|
643
|
+
seen_enc: set[datetime.date] = set()
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
root_entries = sorted(os.scandir(d), key=lambda e: e.name)
|
|
647
|
+
except OSError as e:
|
|
648
|
+
raise DurasError(f"cannot read notes directory: {e}")
|
|
649
|
+
|
|
650
|
+
for year_entry in root_entries:
|
|
651
|
+
if _is_broken_symlink(year_entry):
|
|
652
|
+
issues.append(f"broken symlink: {year_entry.path}")
|
|
653
|
+
continue
|
|
654
|
+
if not year_entry.is_dir(follow_symlinks=False):
|
|
655
|
+
issues.append(f"unexpected file at notes root: {year_entry.path}")
|
|
656
|
+
continue
|
|
657
|
+
if not re.fullmatch(r"\d{4}", year_entry.name):
|
|
658
|
+
issues.append(
|
|
659
|
+
f"unexpected directory at notes root: {year_entry.path}"
|
|
660
|
+
)
|
|
661
|
+
continue
|
|
662
|
+
year = int(year_entry.name)
|
|
663
|
+
|
|
664
|
+
try:
|
|
665
|
+
month_entries = sorted(
|
|
666
|
+
os.scandir(year_entry.path), key=lambda e: e.name
|
|
667
|
+
)
|
|
668
|
+
except OSError as e:
|
|
669
|
+
raise DurasError(f"cannot read directory: {e}")
|
|
670
|
+
|
|
671
|
+
for month_entry in month_entries:
|
|
672
|
+
if _is_broken_symlink(month_entry):
|
|
673
|
+
issues.append(f"broken symlink: {month_entry.path}")
|
|
674
|
+
continue
|
|
675
|
+
if not month_entry.is_dir(follow_symlinks=False):
|
|
676
|
+
issues.append(
|
|
677
|
+
f"unexpected file in year directory: {month_entry.path}"
|
|
678
|
+
)
|
|
679
|
+
continue
|
|
680
|
+
if not re.fullmatch(r"\d{2}", month_entry.name):
|
|
681
|
+
issues.append(
|
|
682
|
+
f"unexpected directory in year directory: {month_entry.path}"
|
|
683
|
+
)
|
|
684
|
+
continue
|
|
685
|
+
month = int(month_entry.name)
|
|
686
|
+
if not 1 <= month <= 12:
|
|
687
|
+
issues.append(f"invalid month directory: {month_entry.path}")
|
|
688
|
+
continue
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
file_entries = sorted(
|
|
692
|
+
os.scandir(month_entry.path), key=lambda e: e.name
|
|
693
|
+
)
|
|
694
|
+
except OSError as e:
|
|
695
|
+
raise DurasError(f"cannot read directory: {e}")
|
|
696
|
+
|
|
697
|
+
for file_entry in file_entries:
|
|
698
|
+
if _is_broken_symlink(file_entry):
|
|
699
|
+
issues.append(f"broken symlink: {file_entry.path}")
|
|
700
|
+
continue
|
|
701
|
+
if file_entry.is_dir(follow_symlinks=False):
|
|
702
|
+
issues.append(
|
|
703
|
+
f"unexpected directory in month directory: {file_entry.path}"
|
|
704
|
+
)
|
|
705
|
+
continue
|
|
706
|
+
date, err = _parse_note_filename(file_entry.name)
|
|
707
|
+
if err:
|
|
708
|
+
issues.append(
|
|
709
|
+
f"unexpected file: {file_entry.path} ({err})"
|
|
710
|
+
)
|
|
711
|
+
continue
|
|
712
|
+
assert date is not None
|
|
713
|
+
if date.year != year or date.month != month:
|
|
714
|
+
issues.append(
|
|
715
|
+
f"path mismatch: {file_entry.path}"
|
|
716
|
+
f" (filename date {date} does not match"
|
|
717
|
+
f" path {year}/{month:02d})"
|
|
718
|
+
)
|
|
719
|
+
if date > today():
|
|
720
|
+
issues.append(f"future date: {file_entry.path}")
|
|
721
|
+
if file_entry.name.endswith(ENC_EXT):
|
|
722
|
+
seen_enc.add(date)
|
|
723
|
+
else:
|
|
724
|
+
seen_plain.add(date)
|
|
725
|
+
|
|
726
|
+
for conflict_date in sorted(seen_plain & seen_enc):
|
|
727
|
+
issues.append(
|
|
728
|
+
f"conflicting notes for {conflict_date.isoformat()}:"
|
|
729
|
+
f" both .dn and .dn.gpg exist"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
for issue in issues:
|
|
733
|
+
print(issue)
|
|
734
|
+
|
|
735
|
+
if issues:
|
|
736
|
+
print(f"\n{len(issues)} issue(s) found")
|
|
737
|
+
else:
|
|
738
|
+
print("collection is clean")
|
|
739
|
+
|
|
740
|
+
return len(issues)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def cmd_echo(date: datetime.date) -> None:
|
|
744
|
+
target_md = (date.month, date.day)
|
|
745
|
+
matches: list[str] = []
|
|
746
|
+
for path in all_note_files():
|
|
747
|
+
parsed, err = _parse_note_filename(os.path.basename(path))
|
|
748
|
+
if err or parsed is None:
|
|
749
|
+
continue
|
|
750
|
+
if (parsed.month, parsed.day) == target_md:
|
|
751
|
+
matches.append(path)
|
|
752
|
+
matches.sort(key=lambda p: os.path.basename(p), reverse=True)
|
|
753
|
+
if not matches:
|
|
754
|
+
print(f"no notes for {date.strftime('%m-%d')} in any year")
|
|
755
|
+
return
|
|
756
|
+
for path in matches:
|
|
757
|
+
print(path)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def cmd_near(date: datetime.date) -> None:
|
|
761
|
+
for offset in range(-3, 4):
|
|
762
|
+
candidate = date + datetime.timedelta(days=offset)
|
|
763
|
+
for confidential in (False, True):
|
|
764
|
+
path = note_path(candidate, confidential=confidential)
|
|
765
|
+
if os.path.exists(path):
|
|
766
|
+
print(path)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _parse_date_strict(s: str) -> datetime.date:
|
|
770
|
+
try:
|
|
771
|
+
return datetime.datetime.strptime(s, DATE_FMT).date()
|
|
772
|
+
except ValueError:
|
|
773
|
+
raise DurasInputError(
|
|
774
|
+
f"invalid date '{s}' — duras mv requires YYYY-MM-DD"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def cmd_mv(old_date: datetime.date, new_date: datetime.date) -> None:
|
|
779
|
+
if old_date == new_date:
|
|
780
|
+
raise DurasInputError("source and destination dates are the same")
|
|
781
|
+
|
|
782
|
+
old_plain = note_path(old_date)
|
|
783
|
+
old_enc = note_path(old_date, confidential=True)
|
|
784
|
+
new_plain = note_path(new_date)
|
|
785
|
+
new_enc = note_path(new_date, confidential=True)
|
|
786
|
+
|
|
787
|
+
has_plain = os.path.exists(old_plain)
|
|
788
|
+
has_enc = os.path.exists(old_enc)
|
|
789
|
+
|
|
790
|
+
if not has_plain and not has_enc:
|
|
791
|
+
raise DurasNotFoundError(f"no note for {old_date.strftime(DATE_FMT)}")
|
|
792
|
+
if os.path.exists(new_plain) or os.path.exists(new_enc):
|
|
793
|
+
raise DurasInputError(
|
|
794
|
+
f"target date {new_date.strftime(DATE_FMT)} already has a note"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
if has_plain:
|
|
798
|
+
ensure_dir(new_plain)
|
|
799
|
+
if has_enc:
|
|
800
|
+
ensure_dir(new_enc)
|
|
801
|
+
|
|
802
|
+
if has_plain:
|
|
803
|
+
os.replace(old_plain, new_plain)
|
|
804
|
+
if has_enc:
|
|
805
|
+
os.replace(old_enc, new_enc)
|
|
806
|
+
|
|
807
|
+
old_month_dir = os.path.dirname(old_plain)
|
|
808
|
+
old_year_dir = os.path.dirname(old_month_dir)
|
|
809
|
+
for dir_ in (old_month_dir, old_year_dir):
|
|
810
|
+
try:
|
|
811
|
+
os.rmdir(dir_)
|
|
812
|
+
except OSError:
|
|
813
|
+
pass
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: duras
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: plain-text daily notes with optional encryption
|
|
5
|
+
Author: Sergiy Duras
|
|
6
|
+
License-Expression: ISC
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# duras
|
|
13
|
+
|
|
14
|
+
[](https://opensource.org/licenses/ISC)
|
|
15
|
+
|
|
16
|
+
**Daily notes as plain text files, with search and optional encryption**.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Model
|
|
21
|
+
|
|
22
|
+
One note per day:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
YYYY/MM/YYYY-MM-DD.dn
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Properties:
|
|
31
|
+
|
|
32
|
+
- plain UTF-8 text
|
|
33
|
+
- filesystem is the index
|
|
34
|
+
- no database, no hidden state
|
|
35
|
+
- atomic writes
|
|
36
|
+
|
|
37
|
+
Encrypted note:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
YYYY/MM/YYYY-MM-DD.dn.gpg
|
|
42
|
+
|
|
43
|
+
````
|
|
44
|
+
|
|
45
|
+
via the [GNU Privacy Guard](https://gnupg.org/).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Scope
|
|
50
|
+
|
|
51
|
+
Fits:
|
|
52
|
+
|
|
53
|
+
- terminal-based workflows
|
|
54
|
+
- grep-based retrieval
|
|
55
|
+
- long-lived plain text notes
|
|
56
|
+
- optional encryption
|
|
57
|
+
|
|
58
|
+
Not a fit:
|
|
59
|
+
|
|
60
|
+
- sync system
|
|
61
|
+
- GUI / rich text editor
|
|
62
|
+
- query engine or database
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Variants
|
|
67
|
+
|
|
68
|
+
### duras
|
|
69
|
+
|
|
70
|
+
- system `gpg`
|
|
71
|
+
- no dependencies
|
|
72
|
+
- Unix-like systems
|
|
73
|
+
|
|
74
|
+
### duras_ashell
|
|
75
|
+
|
|
76
|
+
- iOS [a-Shell mini](https://holzschu.github.io/a-Shell_iOS/)
|
|
77
|
+
- [PGPy](https://github.com/SecurityInnovation/PGPy) (pure Python)
|
|
78
|
+
- no external binaries
|
|
79
|
+
- `.asc` key handling (no keyring)
|
|
80
|
+
- compatible encrypted format
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
duras
|
|
88
|
+
duras append "note"
|
|
89
|
+
duras search term
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
### open
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
duras open
|
|
100
|
+
duras open -1
|
|
101
|
+
duras open 2026-04-19
|
|
102
|
+
duras open -- +7
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### append
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
duras append "text"
|
|
109
|
+
duras append -d -1 "yesterday"
|
|
110
|
+
cat file | duras append -
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`-` = stdin
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Encryption
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
duras -c open
|
|
121
|
+
duras -c append "secret"
|
|
122
|
+
duras -c show
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Notes:
|
|
126
|
+
|
|
127
|
+
* uses system `gpg`
|
|
128
|
+
* append is memory-only
|
|
129
|
+
* editor uses temp file
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Search / tags
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
duras search error
|
|
137
|
+
duras search todo -i
|
|
138
|
+
duras tags
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
* literal match (not regex)
|
|
142
|
+
* encrypted notes excluded
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Listing
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
duras list
|
|
150
|
+
duras list -n 0
|
|
151
|
+
duras stats
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Order: filename (date), not mtime.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Export
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
duras export ~/backup
|
|
162
|
+
duras export ~/backup --encrypt
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Creates archive; optional encryption avoids plaintext export.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Dates
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
YYYY-MM-DD absolute
|
|
173
|
+
0 today
|
|
174
|
+
-1 yesterday
|
|
175
|
+
+7 future
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Future dates are rejected**.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Environment
|
|
183
|
+
|
|
184
|
+
| var | meaning |
|
|
185
|
+
| ------------ | -------------------------------------- |
|
|
186
|
+
| DURAS_DIR | notes dir (default: ~/Documents/Notes) |
|
|
187
|
+
| EDITOR | editor fallback: nano / vi / ed |
|
|
188
|
+
| DURAS_GPG_KEY | encryption recipient |
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Behavior
|
|
193
|
+
|
|
194
|
+
* one file per day
|
|
195
|
+
* plain + encrypted may coexist
|
|
196
|
+
* atomic writes
|
|
197
|
+
* no index layer
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Exit codes
|
|
202
|
+
|
|
203
|
+
| code | meaning |
|
|
204
|
+
| ---- | ---------------- |
|
|
205
|
+
| 0 | ok |
|
|
206
|
+
| 1 | error |
|
|
207
|
+
| 2 | not found |
|
|
208
|
+
| 3 | invalid input |
|
|
209
|
+
| 4 | external failure |
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Limits
|
|
214
|
+
|
|
215
|
+
* encrypted notes not searchable
|
|
216
|
+
* depends on `gpg`
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Docs
|
|
221
|
+
|
|
222
|
+
* **`man duras`**
|
|
223
|
+
* [https://codeberg.org/duras/duras](https://codeberg.org/duras/duras)
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
ISC
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
duras/__init__.py,sha256=88tpZ5w3ZPv0wFdReDNt4R-i555hcSpvt5pLaQoqabo,26
|
|
2
|
+
duras/cli.py,sha256=VMlta_LDziXCW_5UlWxnh6p0mIxN5ea955PURjHvcQg,9431
|
|
3
|
+
duras/core.py,sha256=GHDkS-AFtyQvf0ZkZyMVXxusB7XlxS9kRLiobxpvFL4,25136
|
|
4
|
+
duras-1.0.0.dist-info/licenses/LICENSE,sha256=zBb5HTyEgCA9vWB8PQdsUnUpimhV7Sbs9qHOQt36vVE,766
|
|
5
|
+
duras-1.0.0.dist-info/METADATA,sha256=3JFe7zreE-3KwaAc9bBAFbv8IQ2nNED3wmvvpjmIEdI,3042
|
|
6
|
+
duras-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
duras-1.0.0.dist-info/entry_points.txt,sha256=-ex0zTSoqW-nuzWlIv_BNbkBIL5JoJppECSVE1MKrqk,41
|
|
8
|
+
duras-1.0.0.dist-info/top_level.txt,sha256=ieZSfYSz-GTUFIyqzuoUJUunztvAFKGTibgRnSTD4ro,6
|
|
9
|
+
duras-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ISC License:
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 by Sergiy Duras https://codeberg.org/duras/duras
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for
|
|
6
|
+
any purpose with or without fee is hereby granted, provided that the
|
|
7
|
+
above copyright notice and this permission notice appear in all
|
|
8
|
+
copies.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
|
|
11
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
12
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY
|
|
13
|
+
SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
14
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
15
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
|
16
|
+
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
duras
|