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 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
+ [![License](https://img.shields.io/badge/license-ISC-blue)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ duras = duras.cli:main
@@ -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