auditview 0.1.0__tar.gz

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.
Files changed (37) hide show
  1. auditview-0.1.0/PKG-INFO +11 -0
  2. auditview-0.1.0/README.md +0 -0
  3. auditview-0.1.0/auditview/__init__.py +0 -0
  4. auditview-0.1.0/auditview/__main__.py +29 -0
  5. auditview-0.1.0/auditview/api/__init__.py +0 -0
  6. auditview-0.1.0/auditview/api/checkpoints.py +59 -0
  7. auditview-0.1.0/auditview/api/config.py +11 -0
  8. auditview-0.1.0/auditview/api/coverage.py +48 -0
  9. auditview-0.1.0/auditview/api/events.py +34 -0
  10. auditview-0.1.0/auditview/api/files.py +207 -0
  11. auditview-0.1.0/auditview/api/lines.py +63 -0
  12. auditview-0.1.0/auditview/api/notes.py +185 -0
  13. auditview-0.1.0/auditview/api/sessions.py +57 -0
  14. auditview-0.1.0/auditview/api/util.py +10 -0
  15. auditview-0.1.0/auditview/app.py +52 -0
  16. auditview-0.1.0/auditview/core/__init__.py +0 -0
  17. auditview-0.1.0/auditview/core/coverage.py +32 -0
  18. auditview-0.1.0/auditview/core/hashing.py +10 -0
  19. auditview-0.1.0/auditview/core/reconciler.py +192 -0
  20. auditview-0.1.0/auditview/core/scanner.py +86 -0
  21. auditview-0.1.0/auditview/core/watcher.py +95 -0
  22. auditview-0.1.0/auditview/db/__init__.py +0 -0
  23. auditview-0.1.0/auditview/db/checkpoint.py +80 -0
  24. auditview-0.1.0/auditview/db/connection.py +92 -0
  25. auditview-0.1.0/auditview/db/schema.py +86 -0
  26. auditview-0.1.0/auditview/static/assets/index-BujY485r.js +13 -0
  27. auditview-0.1.0/auditview/static/assets/index-hyKrPdV5.css +1 -0
  28. auditview-0.1.0/auditview/static/index.html +14 -0
  29. auditview-0.1.0/auditview.egg-info/PKG-INFO +11 -0
  30. auditview-0.1.0/auditview.egg-info/SOURCES.txt +35 -0
  31. auditview-0.1.0/auditview.egg-info/dependency_links.txt +1 -0
  32. auditview-0.1.0/auditview.egg-info/entry_points.txt +2 -0
  33. auditview-0.1.0/auditview.egg-info/requires.txt +5 -0
  34. auditview-0.1.0/auditview.egg-info/top_level.txt +1 -0
  35. auditview-0.1.0/pyproject.toml +26 -0
  36. auditview-0.1.0/setup.cfg +4 -0
  37. auditview-0.1.0/setup.py +70 -0
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: auditview
3
+ Version: 0.1.0
4
+ Summary: Code review/audit tool
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: flask>=3.1
8
+ Requires-Dist: waitress>=3.0
9
+ Requires-Dist: watchdog>=6.0
10
+ Requires-Dist: pathspec>=0.12
11
+ Requires-Dist: apsw>=3.49
File without changes
File without changes
@@ -0,0 +1,29 @@
1
+ import argparse
2
+ import os
3
+
4
+ from waitress import serve
5
+ from auditview.app import create_app
6
+
7
+
8
+ def main():
9
+ p = argparse.ArgumentParser(description="auditview — line-level code review tool")
10
+ p.add_argument("path", nargs="?", default=".",
11
+ help="Root folder to audit (default: current directory)")
12
+ p.add_argument("--db", metavar="FILE",
13
+ help="SQLite database file path (default: <root>/.auditview.db)")
14
+ p.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
15
+ p.add_argument("--port", type=int, default=5000, help="Bind port (default: 5000)")
16
+ args = p.parse_args()
17
+
18
+ root = os.path.abspath(args.path)
19
+ db_path = os.path.abspath(args.db) if args.db else os.path.join(root, ".auditview.db")
20
+
21
+ app = create_app(db_path, root)
22
+ print(f"auditview root={root}")
23
+ print(f" db={db_path}")
24
+ print(f" http://{args.host}:{args.port}")
25
+ serve(app, host=args.host, port=args.port)
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
File without changes
@@ -0,0 +1,59 @@
1
+ from flask import Blueprint, jsonify, current_app
2
+ from auditview.db.connection import open_db
3
+ from auditview.db.checkpoint import CheckpointManager
4
+
5
+ bp = Blueprint("checkpoints", __name__)
6
+
7
+
8
+ @bp.route("/sessions/<int:session_id>/checkpoints", methods=["GET"])
9
+ def list_checkpoints(session_id):
10
+ conn = open_db(current_app.config["DB_PATH"])
11
+ is_apsw = getattr(conn, '_is_apsw', False)
12
+ cur = conn.cursor()
13
+
14
+ row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
15
+ if row is None:
16
+ return jsonify({"error": "Session not found"}), 404
17
+
18
+ rows = cur.execute(
19
+ "SELECT id, label, created_at FROM checkpoints WHERE session_id = ? ORDER BY id DESC",
20
+ (session_id,),
21
+ ).fetchall()
22
+
23
+ def to_dict(r):
24
+ if is_apsw:
25
+ return {"id": r[0], "label": r[1], "created_at": r[2]}
26
+ return {"id": r["id"], "label": r["label"], "created_at": r["created_at"]}
27
+
28
+ return jsonify([to_dict(r) for r in rows])
29
+
30
+
31
+ @bp.route("/sessions/<int:session_id>/checkpoints/<int:checkpoint_id>/revert", methods=["POST"])
32
+ def revert_checkpoint(session_id, checkpoint_id):
33
+ conn = open_db(current_app.config["DB_PATH"])
34
+ is_apsw = getattr(conn, '_is_apsw', False)
35
+ cur = conn.cursor()
36
+
37
+ row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
38
+ if row is None:
39
+ return jsonify({"error": "Session not found"}), 404
40
+
41
+ cm = CheckpointManager(conn)
42
+ if not cm.supports_checkpoints():
43
+ return jsonify({"error": "Checkpoints not supported: apsw not available"}), 409
44
+
45
+ cp_row = cur.execute(
46
+ "SELECT id FROM checkpoints WHERE id = ? AND session_id = ?",
47
+ (checkpoint_id, session_id),
48
+ ).fetchone()
49
+ if cp_row is None:
50
+ return jsonify({"error": "Checkpoint not found"}), 404
51
+
52
+ try:
53
+ cm.revert(checkpoint_id)
54
+ except KeyError as e:
55
+ return jsonify({"error": str(e)}), 404
56
+ except Exception as e:
57
+ return jsonify({"error": f"Revert failed: {e}"}), 500
58
+
59
+ return jsonify({"reverted": True})
@@ -0,0 +1,11 @@
1
+ from flask import Blueprint, jsonify, current_app
2
+
3
+ bp = Blueprint("config", __name__)
4
+
5
+
6
+ @bp.route("/config", methods=["GET"])
7
+ def get_config():
8
+ return jsonify({
9
+ "root_path": current_app.config["ROOT_PATH"],
10
+ "db_path": current_app.config["DB_PATH"],
11
+ })
@@ -0,0 +1,48 @@
1
+ from flask import Blueprint, jsonify, current_app
2
+ from auditview.db.connection import open_db
3
+ from auditview.db.checkpoint import CheckpointManager
4
+
5
+ bp = Blueprint("coverage", __name__)
6
+
7
+
8
+ @bp.route("/sessions/<int:session_id>/coverage", methods=["GET"])
9
+ def get_coverage(session_id):
10
+ conn = open_db(current_app.config["DB_PATH"])
11
+ is_apsw = getattr(conn, '_is_apsw', False)
12
+ cur = conn.cursor()
13
+
14
+ row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
15
+ if row is None:
16
+ return jsonify({"error": "Session not found"}), 404
17
+
18
+ # Aggregate using cached countable_lines and indexed reviewed_lines count
19
+ agg = cur.execute(
20
+ """
21
+ SELECT
22
+ COUNT(f.id) AS total_files,
23
+ COALESCE(SUM(f.countable_lines), 0) AS total_countable,
24
+ COUNT(DISTINCT rl.file_path || '|' || rl.line_hash || '|' || rl.context_hash)
25
+ AS total_reviewed
26
+ FROM files f
27
+ LEFT JOIN reviewed_lines rl
28
+ ON rl.session_id = f.session_id AND rl.file_path = f.rel_path
29
+ WHERE f.session_id = ? AND f.countable_lines IS NOT NULL
30
+ """,
31
+ (session_id,),
32
+ ).fetchone()
33
+
34
+ total_files = agg[0] if is_apsw else agg["total_files"]
35
+ total_countable = agg[1] if is_apsw else agg["total_countable"]
36
+ total_reviewed = agg[2] if is_apsw else agg["total_reviewed"]
37
+
38
+ coverage = total_reviewed / total_countable if total_countable > 0 else 0.0
39
+
40
+ cm = CheckpointManager(conn)
41
+
42
+ return jsonify({
43
+ "total_files": total_files,
44
+ "total_countable_lines": total_countable,
45
+ "total_reviewed_lines": total_reviewed,
46
+ "coverage": coverage,
47
+ "supports_checkpoints": cm.supports_checkpoints(),
48
+ })
@@ -0,0 +1,34 @@
1
+ import json
2
+ import queue
3
+
4
+ from flask import Blueprint, jsonify, current_app, Response
5
+
6
+ from auditview.db.connection import open_db
7
+
8
+ bp = Blueprint("events", __name__)
9
+
10
+
11
+ @bp.route("/sessions/<int:session_id>/events", methods=["GET"])
12
+ def event_stream(session_id):
13
+ conn = open_db(current_app.config["DB_PATH"])
14
+ cur = conn.cursor()
15
+ row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
16
+ conn.close()
17
+ if row is None:
18
+ return jsonify({"error": "Session not found"}), 404
19
+
20
+ watcher = current_app.watcher
21
+ q = watcher.register_client(session_id)
22
+
23
+ def generate():
24
+ try:
25
+ while True:
26
+ try:
27
+ event = q.get(timeout=15)
28
+ yield f"event: {event['type']}\ndata: {json.dumps({k: v for k, v in event.items() if k != 'type'})}\n\n"
29
+ except queue.Empty:
30
+ yield "event: heartbeat\ndata: {}\n\n"
31
+ finally:
32
+ watcher.unregister_client(session_id, q)
33
+
34
+ return Response(generate(), content_type="text/event-stream")
@@ -0,0 +1,207 @@
1
+ import os
2
+ from flask import Blueprint, jsonify, request, current_app
3
+ from auditview.db.connection import open_db
4
+ from auditview.core.hashing import line_hash, context_hash
5
+ from auditview.core.coverage import is_countable_line
6
+ from auditview.core.reconciler import reconcile_file
7
+ from auditview.core.scanner import scan_folder
8
+ from auditview.api.util import safe_path
9
+
10
+ bp = Blueprint("files", __name__)
11
+
12
+
13
+ def _get_session(cur, session_id, is_apsw):
14
+ row = cur.execute(
15
+ "SELECT id, root_path, exclusion_patterns FROM sessions WHERE id = ?",
16
+ (session_id,),
17
+ ).fetchone()
18
+ if row is None:
19
+ return None
20
+ if is_apsw:
21
+ return {"id": row[0], "root_path": row[1], "exclusion_patterns": row[2]}
22
+ return dict(row)
23
+
24
+
25
+ @bp.route("/sessions/<int:session_id>/files", methods=["GET"])
26
+ def list_files(session_id):
27
+ conn = open_db(current_app.config["DB_PATH"])
28
+ is_apsw = getattr(conn, '_is_apsw', False)
29
+ cur = conn.cursor()
30
+
31
+ session = _get_session(cur, session_id, is_apsw)
32
+ if session is None:
33
+ return jsonify({"error": "Session not found"}), 404
34
+
35
+ root_path = session["root_path"]
36
+ exclusion_patterns = session["exclusion_patterns"]
37
+
38
+ rel_paths = scan_folder(root_path, exclusion_patterns)
39
+ rel_path_set = set(rel_paths)
40
+
41
+ for rel_path in rel_paths:
42
+ cur.execute(
43
+ "INSERT INTO files (session_id, rel_path) VALUES (?, ?) ON CONFLICT DO NOTHING",
44
+ (session_id, rel_path),
45
+ )
46
+
47
+ # Fetch cached countable_lines per file
48
+ file_rows = cur.execute(
49
+ "SELECT rel_path, countable_lines FROM files WHERE session_id = ?",
50
+ (session_id,),
51
+ ).fetchall()
52
+ countable_map = {}
53
+ uncached = []
54
+ for r in file_rows:
55
+ rp = r[0] if is_apsw else r["rel_path"]
56
+ cl = r[1] if is_apsw else r["countable_lines"]
57
+ countable_map[rp] = cl
58
+ if cl is None and rp in rel_path_set:
59
+ uncached.append(rp)
60
+
61
+ # Compute and cache for files not yet indexed
62
+ for rp in uncached:
63
+ ext = os.path.splitext(rp)[1].lower()
64
+ full_path = os.path.join(root_path, rp)
65
+ countable = 0
66
+ if os.path.isfile(full_path):
67
+ try:
68
+ with open(full_path, "r", encoding="utf-8", errors="replace") as f:
69
+ countable = sum(1 for l in f.read().splitlines() if is_countable_line(l, ext))
70
+ except OSError:
71
+ pass
72
+ cur.execute(
73
+ "UPDATE files SET countable_lines = ? WHERE session_id = ? AND rel_path = ?",
74
+ (countable, session_id, rp),
75
+ )
76
+ countable_map[rp] = countable
77
+
78
+ # Batch: reviewed counts per file
79
+ reviewed_rows = cur.execute(
80
+ "SELECT file_path, COUNT(*) FROM reviewed_lines WHERE session_id = ? GROUP BY file_path",
81
+ (session_id,),
82
+ ).fetchall()
83
+ reviewed_map = {(r[0] if is_apsw else r["file_path"]): (r[1] if is_apsw else r[1])
84
+ for r in reviewed_rows}
85
+
86
+ # Batch: note/todo counts per file (live only)
87
+ note_rows = cur.execute(
88
+ "SELECT file_path, "
89
+ "SUM(CASE WHEN is_todo=0 THEN 1 ELSE 0 END), "
90
+ "SUM(CASE WHEN is_todo=1 THEN 1 ELSE 0 END) "
91
+ "FROM notes WHERE session_id = ? AND is_orphaned=0 GROUP BY file_path",
92
+ (session_id,),
93
+ ).fetchall()
94
+ notes_map = {(r[0] if is_apsw else r["file_path"]): (
95
+ (r[1] if is_apsw else r[1]) or 0,
96
+ (r[2] if is_apsw else r[2]) or 0,
97
+ ) for r in note_rows}
98
+
99
+ result = []
100
+ for rel_path in rel_paths:
101
+ countable = countable_map.get(rel_path) or 0
102
+ reviewed = reviewed_map.get(rel_path, 0)
103
+ coverage = reviewed / countable if countable > 0 else 0.0
104
+ notes_c, todos_c = notes_map.get(rel_path, (0, 0))
105
+
106
+ if countable == 0:
107
+ status = "empty"
108
+ elif reviewed == 0:
109
+ status = "not_viewed"
110
+ elif reviewed >= countable:
111
+ status = "reviewed"
112
+ else:
113
+ status = "partial"
114
+
115
+ result.append({
116
+ "rel_path": rel_path,
117
+ "countable_lines": countable,
118
+ "reviewed_lines": reviewed,
119
+ "coverage": coverage,
120
+ "status": status,
121
+ "notes_count": notes_c,
122
+ "todos_count": todos_c,
123
+ })
124
+
125
+ return jsonify(result)
126
+
127
+
128
+ @bp.route("/sessions/<int:session_id>/files/<path:fpath>", methods=["GET"])
129
+ def get_file(session_id, fpath):
130
+ conn = open_db(current_app.config["DB_PATH"])
131
+ is_apsw = getattr(conn, '_is_apsw', False)
132
+ cur = conn.cursor()
133
+
134
+ session = _get_session(cur, session_id, is_apsw)
135
+ if session is None:
136
+ return jsonify({"error": "Session not found"}), 404
137
+
138
+ root_path = session["root_path"]
139
+ full_path = safe_path(root_path, fpath)
140
+ if full_path is None:
141
+ return jsonify({"error": "Invalid path"}), 400
142
+
143
+ if not os.path.isfile(full_path):
144
+ return jsonify({"error": "File not found"}), 404
145
+
146
+ file_row = cur.execute(
147
+ "SELECT last_mtime FROM files WHERE session_id = ? AND rel_path = ?",
148
+ (session_id, fpath),
149
+ ).fetchone()
150
+ if file_row is None:
151
+ return jsonify({"error": "File not tracked in this session — call list_files first"}), 404
152
+
153
+ current_mtime = os.path.getmtime(full_path)
154
+ stored_mtime = file_row[0] if is_apsw else file_row["last_mtime"]
155
+ if stored_mtime is None or abs(stored_mtime - current_mtime) > 1e-6:
156
+ reconcile_file(conn, session_id, fpath, root_path)
157
+
158
+ skip_comments = request.args.get("skip_comments", "1") != "0"
159
+ ext = os.path.splitext(fpath)[1].lower()
160
+
161
+ with open(full_path, "r", encoding="utf-8", errors="replace") as f:
162
+ content = f.read()
163
+ lines = content.splitlines()
164
+
165
+ reviewed_rows = cur.execute(
166
+ "SELECT line_hash, context_hash FROM reviewed_lines "
167
+ "WHERE session_id = ? AND file_path = ?",
168
+ (session_id, fpath),
169
+ ).fetchall()
170
+
171
+ if is_apsw:
172
+ reviewed_set = {(r[0], r[1]) for r in reviewed_rows}
173
+ else:
174
+ reviewed_set = {(r["line_hash"], r["context_hash"]) for r in reviewed_rows}
175
+
176
+ result_lines = []
177
+ for i, line_content in enumerate(lines):
178
+ prev_content = lines[i - 1] if i > 0 else ""
179
+ next_content = lines[i + 1] if i < len(lines) - 1 else ""
180
+ lh = line_hash(line_content)
181
+ ch = context_hash(prev_content, line_content, next_content)
182
+ result_lines.append({
183
+ "line_no": i + 1,
184
+ "content": line_content,
185
+ "line_hash": lh,
186
+ "context_hash": ch,
187
+ "is_reviewed": (lh, ch) in reviewed_set,
188
+ "is_countable": is_countable_line(line_content, ext, skip_comments),
189
+ })
190
+
191
+ note_rows = cur.execute(
192
+ "SELECT id, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
193
+ "FROM notes WHERE session_id = ? AND file_path = ? ORDER BY start_line",
194
+ (session_id, fpath),
195
+ ).fetchall()
196
+
197
+ def note_to_dict(r):
198
+ if is_apsw:
199
+ return {"id": r[0], "start_line": r[1], "end_line": r[2], "content": r[3],
200
+ "is_todo": bool(r[4]), "is_orphaned": bool(r[5]),
201
+ "snapshot_text": r[6], "created_at": r[7]}
202
+ return {"id": r["id"], "start_line": r["start_line"], "end_line": r["end_line"],
203
+ "content": r["content"], "is_todo": bool(r["is_todo"]),
204
+ "is_orphaned": bool(r["is_orphaned"]),
205
+ "snapshot_text": r["snapshot_text"], "created_at": r["created_at"]}
206
+
207
+ return jsonify({"lines": result_lines, "notes": [note_to_dict(r) for r in note_rows]})
@@ -0,0 +1,63 @@
1
+ from flask import Blueprint, request, jsonify, current_app
2
+ from auditview.db.connection import open_db
3
+ from auditview.api.util import safe_path
4
+
5
+ bp = Blueprint("lines", __name__)
6
+
7
+
8
+ @bp.route("/sessions/<int:session_id>/lines/mark", methods=["POST"])
9
+ def mark_lines(session_id):
10
+ conn = open_db(current_app.config["DB_PATH"])
11
+ is_apsw = getattr(conn, '_is_apsw', False)
12
+ cur = conn.cursor()
13
+
14
+ row = cur.execute(
15
+ "SELECT id, root_path FROM sessions WHERE id = ?", (session_id,)
16
+ ).fetchone()
17
+ if row is None:
18
+ return jsonify({"error": "Session not found"}), 404
19
+ root_path = row[1] if is_apsw else row["root_path"]
20
+
21
+ data = request.get_json(force=True, silent=True) or {}
22
+ file_path = data.get("file_path")
23
+ lines = data.get("lines")
24
+ reviewed = data.get("reviewed")
25
+
26
+ if not file_path or lines is None or reviewed is None:
27
+ return jsonify({"error": "file_path, lines, and reviewed are required"}), 400
28
+
29
+ if not isinstance(lines, list):
30
+ return jsonify({"error": "lines must be an array"}), 400
31
+
32
+ if safe_path(root_path, file_path) is None:
33
+ return jsonify({"error": "Invalid path"}), 400
34
+
35
+ count = 0
36
+ conn.begin()
37
+ try:
38
+ for line in lines:
39
+ lh = line.get("line_hash")
40
+ ch = line.get("context_hash")
41
+ ln = line.get("line_no")
42
+ if not lh or not ch or ln is None:
43
+ continue
44
+ if reviewed:
45
+ cur.execute(
46
+ "INSERT OR REPLACE INTO reviewed_lines "
47
+ "(session_id, file_path, line_hash, context_hash, line_no) "
48
+ "VALUES (?, ?, ?, ?, ?)",
49
+ (session_id, file_path, lh, ch, ln),
50
+ )
51
+ else:
52
+ cur.execute(
53
+ "DELETE FROM reviewed_lines "
54
+ "WHERE session_id = ? AND file_path = ? AND line_hash = ? AND context_hash = ?",
55
+ (session_id, file_path, lh, ch),
56
+ )
57
+ count += 1
58
+ conn.commit()
59
+ except Exception:
60
+ conn.rollback()
61
+ raise
62
+
63
+ return jsonify({"updated": count})
@@ -0,0 +1,185 @@
1
+ import os
2
+ from flask import Blueprint, request, jsonify, current_app
3
+ from auditview.db.connection import open_db
4
+ from auditview.core.hashing import line_hash
5
+ from auditview.api.util import safe_path
6
+
7
+ bp = Blueprint("notes", __name__)
8
+
9
+
10
+ def _note_row(r, is_apsw):
11
+ if is_apsw:
12
+ return {
13
+ "id": r[0],
14
+ "file_path": r[1],
15
+ "start_line": r[2],
16
+ "end_line": r[3],
17
+ "content": r[4],
18
+ "is_todo": bool(r[5]),
19
+ "is_orphaned": bool(r[6]),
20
+ "snapshot_text": r[7],
21
+ "created_at": r[8],
22
+ }
23
+ return {
24
+ "id": r["id"],
25
+ "file_path": r["file_path"],
26
+ "start_line": r["start_line"],
27
+ "end_line": r["end_line"],
28
+ "content": r["content"],
29
+ "is_todo": bool(r["is_todo"]),
30
+ "is_orphaned": bool(r["is_orphaned"]),
31
+ "snapshot_text": r["snapshot_text"],
32
+ "created_at": r["created_at"],
33
+ }
34
+
35
+
36
+ @bp.route("/sessions/<int:session_id>/notes", methods=["GET"])
37
+ def list_notes(session_id):
38
+ conn = open_db(current_app.config["DB_PATH"])
39
+ is_apsw = getattr(conn, '_is_apsw', False)
40
+ cur = conn.cursor()
41
+
42
+ row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
43
+ if row is None:
44
+ return jsonify({"error": "Session not found"}), 404
45
+
46
+ rows = cur.execute(
47
+ "SELECT id, file_path, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
48
+ "FROM notes WHERE session_id = ? ORDER BY file_path, start_line",
49
+ (session_id,),
50
+ ).fetchall()
51
+
52
+ return jsonify([_note_row(r, is_apsw) for r in rows])
53
+
54
+
55
+ @bp.route("/sessions/<int:session_id>/notes", methods=["POST"])
56
+ def create_note(session_id):
57
+ conn = open_db(current_app.config["DB_PATH"])
58
+ is_apsw = getattr(conn, '_is_apsw', False)
59
+ cur = conn.cursor()
60
+
61
+ session_row = cur.execute(
62
+ "SELECT id, root_path FROM sessions WHERE id = ?", (session_id,)
63
+ ).fetchone()
64
+ if session_row is None:
65
+ return jsonify({"error": "Session not found"}), 404
66
+
67
+ root_path = session_row[1] if is_apsw else session_row["root_path"]
68
+
69
+ data = request.get_json(force=True, silent=True) or {}
70
+ file_path = data.get("file_path", "").strip()
71
+ start_line = data.get("start_line")
72
+ end_line = data.get("end_line")
73
+ content = data.get("content", "").strip()
74
+ is_todo = bool(data.get("is_todo", False))
75
+
76
+ if not file_path:
77
+ return jsonify({"error": "file_path is required"}), 400
78
+ if not content:
79
+ return jsonify({"error": "content is required"}), 400
80
+ if start_line is None or end_line is None:
81
+ return jsonify({"error": "start_line and end_line are required"}), 400
82
+ if not isinstance(start_line, int) or not isinstance(end_line, int):
83
+ return jsonify({"error": "start_line and end_line must be integers"}), 400
84
+ if start_line > end_line or start_line < 1:
85
+ return jsonify({"error": "invalid line range"}), 400
86
+
87
+ full_path = safe_path(root_path, file_path)
88
+ if full_path is None:
89
+ return jsonify({"error": "Invalid path"}), 400
90
+ if not os.path.isfile(full_path):
91
+ return jsonify({"error": "File not found"}), 404
92
+
93
+ with open(full_path, "r", encoding="utf-8", errors="replace") as f:
94
+ file_lines = f.read().splitlines()
95
+
96
+ if start_line > len(file_lines) or end_line > len(file_lines):
97
+ return jsonify({"error": "line range out of bounds"}), 400
98
+
99
+ start_idx = start_line - 1
100
+ end_idx = end_line - 1
101
+ start_hash = line_hash(file_lines[start_idx])
102
+ end_hash = line_hash(file_lines[end_idx])
103
+ snapshot_text = "\n".join(file_lines[start_idx:end_idx + 1])
104
+
105
+ cur.execute(
106
+ "INSERT INTO notes (session_id, file_path, start_line, end_line, start_hash, end_hash, snapshot_text, content, is_todo) "
107
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
108
+ (session_id, file_path, start_line, end_line, start_hash, end_hash, snapshot_text, content, int(is_todo)),
109
+ )
110
+ if is_apsw:
111
+ row_id = conn.last_insert_rowid()
112
+ else:
113
+ row_id = cur.lastrowid
114
+
115
+ row = cur.execute(
116
+ "SELECT id, file_path, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
117
+ "FROM notes WHERE id = ?",
118
+ (row_id,),
119
+ ).fetchone()
120
+
121
+ return jsonify(_note_row(row, is_apsw)), 201
122
+
123
+
124
+ @bp.route("/sessions/<int:session_id>/notes/<int:note_id>", methods=["PATCH"])
125
+ def update_note(session_id, note_id):
126
+ conn = open_db(current_app.config["DB_PATH"])
127
+ is_apsw = getattr(conn, '_is_apsw', False)
128
+ cur = conn.cursor()
129
+
130
+ row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
131
+ if row is None:
132
+ return jsonify({"error": "Session not found"}), 404
133
+
134
+ note_row = cur.execute(
135
+ "SELECT id FROM notes WHERE id = ? AND session_id = ?", (note_id, session_id)
136
+ ).fetchone()
137
+ if note_row is None:
138
+ return jsonify({"error": "Note not found"}), 404
139
+
140
+ data = request.get_json(force=True, silent=True) or {}
141
+ updates = {}
142
+ if "content" in data:
143
+ content = data["content"].strip()
144
+ if not content:
145
+ return jsonify({"error": "content cannot be empty"}), 400
146
+ updates["content"] = content
147
+ if "is_todo" in data:
148
+ updates["is_todo"] = int(bool(data["is_todo"]))
149
+
150
+ if not updates:
151
+ return jsonify({"error": "nothing to update"}), 400
152
+
153
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
154
+ cur.execute(
155
+ f"UPDATE notes SET {set_clause} WHERE id = ?",
156
+ (*updates.values(), note_id),
157
+ )
158
+
159
+ row = cur.execute(
160
+ "SELECT id, file_path, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
161
+ "FROM notes WHERE id = ?",
162
+ (note_id,),
163
+ ).fetchone()
164
+ return jsonify(_note_row(row, is_apsw))
165
+
166
+
167
+ @bp.route("/sessions/<int:session_id>/notes/<int:note_id>", methods=["DELETE"])
168
+ def delete_note(session_id, note_id):
169
+ conn = open_db(current_app.config["DB_PATH"])
170
+ is_apsw = getattr(conn, '_is_apsw', False)
171
+ cur = conn.cursor()
172
+
173
+ row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
174
+ if row is None:
175
+ return jsonify({"error": "Session not found"}), 404
176
+
177
+ note_row = cur.execute(
178
+ "SELECT id FROM notes WHERE id = ? AND session_id = ?", (note_id, session_id)
179
+ ).fetchone()
180
+ if note_row is None:
181
+ return jsonify({"error": "Note not found"}), 404
182
+
183
+ cur.execute("DELETE FROM notes WHERE id = ?", (note_id,))
184
+
185
+ return jsonify({"deleted": True})