arkiv 0.1.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.
- arkiv/__init__.py +29 -0
- arkiv/__main__.py +6 -0
- arkiv/cli.py +393 -0
- arkiv/database.py +569 -0
- arkiv/readme.py +86 -0
- arkiv/record.py +75 -0
- arkiv/render.py +89 -0
- arkiv/schema.py +215 -0
- arkiv/server.py +118 -0
- arkiv/timefilter.py +84 -0
- arkiv-0.1.0.dist-info/METADATA +92 -0
- arkiv-0.1.0.dist-info/RECORD +15 -0
- arkiv-0.1.0.dist-info/WHEEL +4 -0
- arkiv-0.1.0.dist-info/entry_points.txt +2 -0
- arkiv-0.1.0.dist-info/licenses/LICENSE +21 -0
arkiv/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""arkiv: Universal personal data format."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .record import Record, parse_record, parse_jsonl
|
|
6
|
+
from .schema import (
|
|
7
|
+
SchemaEntry,
|
|
8
|
+
CollectionSchema,
|
|
9
|
+
discover_schema,
|
|
10
|
+
load_schema_yaml,
|
|
11
|
+
save_schema_yaml,
|
|
12
|
+
)
|
|
13
|
+
from .readme import Readme, parse_readme, save_readme
|
|
14
|
+
from .database import Database
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Record",
|
|
18
|
+
"parse_record",
|
|
19
|
+
"parse_jsonl",
|
|
20
|
+
"SchemaEntry",
|
|
21
|
+
"CollectionSchema",
|
|
22
|
+
"discover_schema",
|
|
23
|
+
"load_schema_yaml",
|
|
24
|
+
"save_schema_yaml",
|
|
25
|
+
"Readme",
|
|
26
|
+
"parse_readme",
|
|
27
|
+
"save_readme",
|
|
28
|
+
"Database",
|
|
29
|
+
]
|
arkiv/__main__.py
ADDED
arkiv/cli.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""arkiv CLI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_import(args):
|
|
12
|
+
"""Import JSONL, README.md, or directory into a SQLite database."""
|
|
13
|
+
from .database import Database
|
|
14
|
+
|
|
15
|
+
input_path = Path(args.input)
|
|
16
|
+
|
|
17
|
+
if input_path.suffix == ".db":
|
|
18
|
+
print(f"Error: {input_path} is a database file, not a data file", file=sys.stderr)
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
|
|
21
|
+
db = Database(args.db)
|
|
22
|
+
try:
|
|
23
|
+
if input_path.is_dir():
|
|
24
|
+
# Directory: look for README.md
|
|
25
|
+
readme_path = input_path / "README.md"
|
|
26
|
+
if readme_path.exists():
|
|
27
|
+
count = db.import_readme(readme_path)
|
|
28
|
+
print(f"Imported {count} records from README.md")
|
|
29
|
+
else:
|
|
30
|
+
print(f"Error: No README.md found in {input_path}", file=sys.stderr)
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
elif input_path.suffix == ".md":
|
|
33
|
+
count = db.import_readme(input_path)
|
|
34
|
+
print(f"Imported {count} records from {input_path.name}")
|
|
35
|
+
else:
|
|
36
|
+
count = db.import_jsonl(input_path)
|
|
37
|
+
print(f"Imported {count} records from {input_path.name}")
|
|
38
|
+
finally:
|
|
39
|
+
db.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_db(path, command):
|
|
43
|
+
"""Check that path looks like a database, not JSONL."""
|
|
44
|
+
p = Path(path)
|
|
45
|
+
if p.suffix in (".jsonl", ".json"):
|
|
46
|
+
name = p.name
|
|
47
|
+
print(
|
|
48
|
+
f"Error: {name} is a JSONL file, not a SQLite database.\n"
|
|
49
|
+
f"Import it first:\n"
|
|
50
|
+
f" arkiv import {name} --db archive.db\n"
|
|
51
|
+
f" arkiv {command} archive.db ...",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_export(args):
|
|
58
|
+
"""Export SQLite database to JSONL files + README.md + schema.yaml."""
|
|
59
|
+
from .database import Database
|
|
60
|
+
|
|
61
|
+
_require_db(args.db, "export")
|
|
62
|
+
db = Database(args.db, read_only=True)
|
|
63
|
+
db.export(args.output, nested=args.nested, since=args.since, until=args.until)
|
|
64
|
+
db.close()
|
|
65
|
+
print(f"Exported to {args.output}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_schema(args):
|
|
69
|
+
"""Show schema from a JSONL file or SQLite database."""
|
|
70
|
+
input_path = Path(args.input)
|
|
71
|
+
|
|
72
|
+
if input_path.suffix == ".db":
|
|
73
|
+
from .database import Database
|
|
74
|
+
|
|
75
|
+
db = Database(input_path, read_only=True)
|
|
76
|
+
output = db.get_schema()
|
|
77
|
+
db.close()
|
|
78
|
+
else:
|
|
79
|
+
from .schema import discover_schema
|
|
80
|
+
|
|
81
|
+
schema = discover_schema(input_path)
|
|
82
|
+
output = {key: entry.to_dict() for key, entry in schema.items()}
|
|
83
|
+
|
|
84
|
+
print(json.dumps(output, indent=2))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_query(args):
|
|
88
|
+
"""Run a SQL query against the database."""
|
|
89
|
+
from .database import Database
|
|
90
|
+
|
|
91
|
+
_require_db(args.db, "query")
|
|
92
|
+
db = Database(args.db, read_only=True)
|
|
93
|
+
results = db.query(args.sql)
|
|
94
|
+
db.close()
|
|
95
|
+
print(json.dumps(results, indent=2, default=str))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_info(args):
|
|
99
|
+
"""Print info about a JSONL file or SQLite database."""
|
|
100
|
+
input_path = Path(args.input)
|
|
101
|
+
|
|
102
|
+
if input_path.suffix == ".db":
|
|
103
|
+
from .database import Database
|
|
104
|
+
|
|
105
|
+
db = Database(input_path, read_only=True)
|
|
106
|
+
info = db.get_info()
|
|
107
|
+
db.close()
|
|
108
|
+
else:
|
|
109
|
+
from .record import parse_jsonl
|
|
110
|
+
from .schema import discover_schema
|
|
111
|
+
|
|
112
|
+
collection = input_path.stem
|
|
113
|
+
records = list(parse_jsonl(input_path))
|
|
114
|
+
schema = discover_schema(input_path)
|
|
115
|
+
metadata_keys = {key: entry.to_dict() for key, entry in schema.items()}
|
|
116
|
+
|
|
117
|
+
coll_info = {"record_count": len(records)}
|
|
118
|
+
if metadata_keys:
|
|
119
|
+
coll_info["metadata_keys"] = metadata_keys
|
|
120
|
+
|
|
121
|
+
info = {
|
|
122
|
+
"total_records": len(records),
|
|
123
|
+
"collections": {collection: coll_info},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
print(json.dumps(info, indent=2))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _require_jsonl(path, suggestion):
|
|
130
|
+
"""Check that path is a JSONL file, not a database."""
|
|
131
|
+
p = Path(path)
|
|
132
|
+
if p.suffix == ".db":
|
|
133
|
+
print(
|
|
134
|
+
f"Error: {p.name} is a SQLite database, not a JSONL file.\n"
|
|
135
|
+
f"Try: arkiv {suggestion} {p.name}",
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_detect(args):
|
|
142
|
+
"""Check if a JSONL file is valid arkiv format."""
|
|
143
|
+
from .record import KNOWN_FIELDS
|
|
144
|
+
|
|
145
|
+
input_path = Path(args.input)
|
|
146
|
+
_require_jsonl(input_path, "info")
|
|
147
|
+
field_suggestions = {
|
|
148
|
+
"url": "uri", "link": "uri", "href": "uri",
|
|
149
|
+
"type": "mimetype", "mime": "mimetype",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
total = 0
|
|
153
|
+
errors = 0
|
|
154
|
+
fields_used = set()
|
|
155
|
+
unknown_fields = set()
|
|
156
|
+
metadata_keys = set()
|
|
157
|
+
warnings = []
|
|
158
|
+
|
|
159
|
+
with open(input_path, encoding="utf-8") as f:
|
|
160
|
+
for lineno, line in enumerate(f, 1):
|
|
161
|
+
line = line.strip()
|
|
162
|
+
if not line:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
obj = json.loads(line)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
warnings.append(f"Line {lineno}: invalid JSON")
|
|
168
|
+
errors += 1
|
|
169
|
+
continue
|
|
170
|
+
if not isinstance(obj, dict):
|
|
171
|
+
warnings.append(f"Line {lineno}: not a JSON object")
|
|
172
|
+
errors += 1
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
total += 1
|
|
176
|
+
for key in obj:
|
|
177
|
+
if key in KNOWN_FIELDS:
|
|
178
|
+
fields_used.add(key)
|
|
179
|
+
else:
|
|
180
|
+
unknown_fields.add(key)
|
|
181
|
+
if isinstance(obj.get("metadata"), dict):
|
|
182
|
+
metadata_keys.update(obj["metadata"].keys())
|
|
183
|
+
|
|
184
|
+
for field in sorted(unknown_fields):
|
|
185
|
+
suggestion = field_suggestions.get(field)
|
|
186
|
+
if suggestion:
|
|
187
|
+
warnings.append(
|
|
188
|
+
f"Unknown field '{field}' — did you mean '{suggestion}'?"
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
warnings.append(
|
|
192
|
+
f"Unknown field '{field}' (will be merged into metadata on import)"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Schema validation: check sibling schema.yaml if present
|
|
196
|
+
schema_checks = []
|
|
197
|
+
schema_yaml_path = input_path.parent / "schema.yaml"
|
|
198
|
+
if schema_yaml_path.exists():
|
|
199
|
+
from .schema import discover_schema, load_schema_yaml
|
|
200
|
+
|
|
201
|
+
curated = load_schema_yaml(schema_yaml_path)
|
|
202
|
+
collection = input_path.stem
|
|
203
|
+
if collection in curated:
|
|
204
|
+
coll_schema = curated[collection]
|
|
205
|
+
curated_key_names = set(coll_schema.metadata_keys.keys())
|
|
206
|
+
|
|
207
|
+
# Keys in schema.yaml but not in data
|
|
208
|
+
for key in sorted(curated_key_names - metadata_keys):
|
|
209
|
+
warnings.append(
|
|
210
|
+
f"Schema key '{key}' not found in data"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Keys in data but not in schema.yaml
|
|
214
|
+
for key in sorted(metadata_keys - curated_key_names):
|
|
215
|
+
schema_checks.append(
|
|
216
|
+
f"Undocumented key '{key}' (in data but not schema.yaml)"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Type and value mismatches
|
|
220
|
+
auto_schema = discover_schema(input_path)
|
|
221
|
+
for key in sorted(curated_key_names & metadata_keys):
|
|
222
|
+
curated_entry = coll_schema.metadata_keys[key]
|
|
223
|
+
if key in auto_schema and auto_schema[key].type != curated_entry.type:
|
|
224
|
+
warnings.append(
|
|
225
|
+
f"Type mismatch for '{key}': schema says {curated_entry.type}, data has {auto_schema[key].type}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if curated_entry.values and key in auto_schema and auto_schema[key].values:
|
|
229
|
+
missing_vals = set(str(v) for v in curated_entry.values) - set(
|
|
230
|
+
str(v) for v in auto_schema[key].values
|
|
231
|
+
)
|
|
232
|
+
if missing_vals:
|
|
233
|
+
schema_checks.append(
|
|
234
|
+
f"Curated values for '{key}' not in data: {sorted(missing_vals)}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
result = {
|
|
238
|
+
"valid_jsonl": errors == 0,
|
|
239
|
+
"total_records": total,
|
|
240
|
+
"collection": input_path.stem,
|
|
241
|
+
"fields_used": sorted(fields_used),
|
|
242
|
+
"unknown_fields": sorted(unknown_fields),
|
|
243
|
+
"metadata_keys": sorted(metadata_keys),
|
|
244
|
+
"warnings": warnings,
|
|
245
|
+
}
|
|
246
|
+
if schema_checks:
|
|
247
|
+
result["schema_info"] = schema_checks
|
|
248
|
+
print(json.dumps(result, indent=2))
|
|
249
|
+
|
|
250
|
+
if args.strict and warnings:
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_fix(args):
|
|
255
|
+
"""Fix known field misspellings in a JSONL file."""
|
|
256
|
+
input_path = Path(args.input)
|
|
257
|
+
_require_jsonl(input_path, "info")
|
|
258
|
+
fix_map = {"url": "uri", "link": "uri", "href": "uri"}
|
|
259
|
+
|
|
260
|
+
lines = input_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
261
|
+
fixed_count = 0
|
|
262
|
+
out_lines = []
|
|
263
|
+
|
|
264
|
+
for line in lines:
|
|
265
|
+
stripped = line.strip()
|
|
266
|
+
if not stripped:
|
|
267
|
+
out_lines.append(line)
|
|
268
|
+
continue
|
|
269
|
+
try:
|
|
270
|
+
obj = json.loads(stripped)
|
|
271
|
+
except json.JSONDecodeError:
|
|
272
|
+
out_lines.append(line)
|
|
273
|
+
continue
|
|
274
|
+
if not isinstance(obj, dict):
|
|
275
|
+
out_lines.append(line)
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
changed = False
|
|
279
|
+
for unknown_field, target_field in fix_map.items():
|
|
280
|
+
if unknown_field in obj and target_field not in obj:
|
|
281
|
+
obj[target_field] = obj[unknown_field]
|
|
282
|
+
changed = True
|
|
283
|
+
fixed_count += 1
|
|
284
|
+
|
|
285
|
+
if changed:
|
|
286
|
+
out_lines.append(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
287
|
+
else:
|
|
288
|
+
out_lines.append(line)
|
|
289
|
+
|
|
290
|
+
input_path.write_text("".join(out_lines), encoding="utf-8")
|
|
291
|
+
print(json.dumps({"fixed": fixed_count, "file": str(input_path)}, indent=2))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_mcp(args):
|
|
295
|
+
"""Start the MCP server."""
|
|
296
|
+
from .server import run_mcp_server
|
|
297
|
+
|
|
298
|
+
run_mcp_server(db_path=args.db, writable=getattr(args, 'writable', False))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main():
|
|
302
|
+
parser = argparse.ArgumentParser(
|
|
303
|
+
prog="arkiv",
|
|
304
|
+
description="Universal personal data format. JSONL in, SQL out, MCP to LLMs.",
|
|
305
|
+
)
|
|
306
|
+
parser.add_argument(
|
|
307
|
+
"--version", action="version", version=f"arkiv {__version__}"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
311
|
+
|
|
312
|
+
# import
|
|
313
|
+
p_import = subparsers.add_parser("import", help="Import JSONL, README.md, or directory")
|
|
314
|
+
p_import.add_argument("input", help="JSONL file, README.md, or directory")
|
|
315
|
+
p_import.add_argument(
|
|
316
|
+
"--db", default="archive.db", help="SQLite database path"
|
|
317
|
+
)
|
|
318
|
+
p_import.set_defaults(func=cmd_import)
|
|
319
|
+
|
|
320
|
+
# export
|
|
321
|
+
p_export = subparsers.add_parser(
|
|
322
|
+
"export", help="Export database to JSONL + README.md + schema.yaml"
|
|
323
|
+
)
|
|
324
|
+
p_export.add_argument("db", help="SQLite database path")
|
|
325
|
+
p_export.add_argument(
|
|
326
|
+
"--output", default="./exported", help="Output directory"
|
|
327
|
+
)
|
|
328
|
+
p_export.add_argument("--nested", action="store_true", help="Create subdirectory per collection")
|
|
329
|
+
p_export.add_argument("--since", help="Include records from this ISO 8601 date")
|
|
330
|
+
p_export.add_argument("--until", help="Include records through this ISO 8601 date")
|
|
331
|
+
p_export.set_defaults(func=cmd_export)
|
|
332
|
+
|
|
333
|
+
# schema
|
|
334
|
+
p_schema = subparsers.add_parser(
|
|
335
|
+
"schema", help="Show schema from JSONL file or database"
|
|
336
|
+
)
|
|
337
|
+
p_schema.add_argument("input", help="JSONL file or SQLite database")
|
|
338
|
+
p_schema.set_defaults(func=cmd_schema)
|
|
339
|
+
|
|
340
|
+
# query
|
|
341
|
+
p_query = subparsers.add_parser("query", help="Run SQL query")
|
|
342
|
+
p_query.add_argument("db", help="SQLite database path")
|
|
343
|
+
p_query.add_argument("sql", help="SQL query")
|
|
344
|
+
p_query.set_defaults(func=cmd_query)
|
|
345
|
+
|
|
346
|
+
# info
|
|
347
|
+
p_info = subparsers.add_parser(
|
|
348
|
+
"info", help="Show info about JSONL file or database"
|
|
349
|
+
)
|
|
350
|
+
p_info.add_argument("input", help="JSONL file or SQLite database")
|
|
351
|
+
p_info.set_defaults(func=cmd_info)
|
|
352
|
+
|
|
353
|
+
# detect
|
|
354
|
+
p_detect = subparsers.add_parser(
|
|
355
|
+
"detect", help="Check if a JSONL file is valid arkiv format"
|
|
356
|
+
)
|
|
357
|
+
p_detect.add_argument("input", help="JSONL file to check")
|
|
358
|
+
p_detect.add_argument(
|
|
359
|
+
"--strict", action="store_true", help="Exit 1 if any warnings"
|
|
360
|
+
)
|
|
361
|
+
p_detect.set_defaults(func=cmd_detect)
|
|
362
|
+
|
|
363
|
+
# fix
|
|
364
|
+
p_fix = subparsers.add_parser(
|
|
365
|
+
"fix", help="Fix known field misspellings in JSONL (e.g. url -> uri)"
|
|
366
|
+
)
|
|
367
|
+
p_fix.add_argument("input", help="JSONL file to fix")
|
|
368
|
+
p_fix.set_defaults(func=cmd_fix)
|
|
369
|
+
|
|
370
|
+
# mcp
|
|
371
|
+
p_mcp = subparsers.add_parser("mcp", help="Start MCP server")
|
|
372
|
+
p_mcp.add_argument("db", help="SQLite database path")
|
|
373
|
+
p_mcp.add_argument("--writable", action="store_true",
|
|
374
|
+
help="Enable write_record tool (default: read-only)")
|
|
375
|
+
p_mcp.set_defaults(func=cmd_mcp)
|
|
376
|
+
|
|
377
|
+
args = parser.parse_args()
|
|
378
|
+
if not args.command:
|
|
379
|
+
parser.print_help()
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
args.func(args)
|
|
384
|
+
except (FileNotFoundError, ValueError) as e:
|
|
385
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
except UnicodeDecodeError:
|
|
388
|
+
print("Error: File is not valid UTF-8 text (is it a binary file?)", file=sys.stderr)
|
|
389
|
+
sys.exit(1)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
main()
|