aqda 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aqda/__init__.py +3 -0
- aqda/__main__.py +59 -0
- aqda/app.py +120 -0
- aqda/db.py +447 -0
- aqda/frontend/dist/assets/index-DLie7KiK.css +1 -0
- aqda/frontend/dist/assets/index-Swo3jjN2.js +17 -0
- aqda/frontend/dist/index.html +13 -0
- aqda/routers/__init__.py +0 -0
- aqda/routers/ai.py +1064 -0
- aqda/routers/codes.py +296 -0
- aqda/routers/codings.py +156 -0
- aqda/routers/documents.py +607 -0
- aqda/routers/export.py +601 -0
- aqda/routers/memos.py +183 -0
- aqda/routers/projects.py +760 -0
- aqda/routers/settings.py +161 -0
- aqda/routers/shared.py +206 -0
- aqda/routers/system.py +20 -0
- aqda/services/__init__.py +0 -0
- aqda/services/offsets.py +145 -0
- aqda/services/shared_projects.py +1343 -0
- aqda-0.3.1.dist-info/METADATA +308 -0
- aqda-0.3.1.dist-info/RECORD +27 -0
- aqda-0.3.1.dist-info/WHEEL +5 -0
- aqda-0.3.1.dist-info/entry_points.txt +2 -0
- aqda-0.3.1.dist-info/licenses/LICENSE +21 -0
- aqda-0.3.1.dist-info/top_level.txt +1 -0
aqda/__init__.py
ADDED
aqda/__main__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Entry point for running AQDA as a module or via the console script."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import threading
|
|
5
|
+
import webbrowser
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from aqda import __version__
|
|
10
|
+
|
|
11
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
12
|
+
DEFAULT_PORT = 8765
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Build the argument parser for the `aqda` console script.
|
|
16
|
+
def build_parser():
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="aqda",
|
|
19
|
+
description="AQDA — Augmented Qualitative Data Analysis. "
|
|
20
|
+
"Starts the local web app and opens it in your browser.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"aqda {__version__}")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--host", default=DEFAULT_HOST, help=f"Interface to bind (default: {DEFAULT_HOST})"
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--port", type=int, default=DEFAULT_PORT, help=f"Port to bind (default: {DEFAULT_PORT})"
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--no-browser", action="store_true", help="Do not open a browser window on startup"
|
|
31
|
+
)
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main():
|
|
36
|
+
args = build_parser().parse_args()
|
|
37
|
+
|
|
38
|
+
# Open browser after a short delay to let the server start
|
|
39
|
+
def open_browser():
|
|
40
|
+
import time
|
|
41
|
+
|
|
42
|
+
time.sleep(1.5)
|
|
43
|
+
webbrowser.open(f"http://{args.host}:{args.port}")
|
|
44
|
+
|
|
45
|
+
if not args.no_browser:
|
|
46
|
+
threading.Thread(target=open_browser, daemon=True).start()
|
|
47
|
+
|
|
48
|
+
print(f"\n AQDA is running at http://{args.host}:{args.port}\n")
|
|
49
|
+
from aqda.app import app
|
|
50
|
+
|
|
51
|
+
config = uvicorn.Config(app, host=args.host, port=args.port, log_level="warning")
|
|
52
|
+
server = uvicorn.Server(config)
|
|
53
|
+
app.state.uvicorn_server = server
|
|
54
|
+
# uvicorn exits with status 3 itself if the port is already in use.
|
|
55
|
+
server.run()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|
aqda/app.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""FastAPI application — serves both API and the frontend SPA."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
9
|
+
|
|
10
|
+
from aqda import __version__
|
|
11
|
+
from aqda.db import init_db
|
|
12
|
+
from aqda.routers import projects, documents, codes, codings, memos, settings, ai, export, shared, system
|
|
13
|
+
from aqda.services.shared_projects import start_sync_service, stop_sync_service
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@asynccontextmanager
|
|
17
|
+
async def lifespan(app: FastAPI):
|
|
18
|
+
await init_db()
|
|
19
|
+
await start_sync_service()
|
|
20
|
+
try:
|
|
21
|
+
yield
|
|
22
|
+
finally:
|
|
23
|
+
await stop_sync_service()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
app = FastAPI(title="AQDA", version=__version__, lifespan=lifespan)
|
|
27
|
+
|
|
28
|
+
_LOCAL_BROWSER_ORIGINS = {
|
|
29
|
+
"http://127.0.0.1:8765",
|
|
30
|
+
"http://localhost:8765",
|
|
31
|
+
"http://[::1]:8765",
|
|
32
|
+
# The Vite development server proxies /api requests to the same local app.
|
|
33
|
+
"http://127.0.0.1:5173",
|
|
34
|
+
"http://localhost:5173",
|
|
35
|
+
"http://[::1]:5173",
|
|
36
|
+
}
|
|
37
|
+
_LOCAL_BROWSER_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hostname_from_host_header(value: str) -> str:
|
|
41
|
+
"""Return a normalized hostname while rejecting malformed port syntax."""
|
|
42
|
+
host = value.strip().lower()
|
|
43
|
+
if host.startswith("["):
|
|
44
|
+
closing = host.find("]")
|
|
45
|
+
suffix = host[closing + 1:] if closing >= 0 else ""
|
|
46
|
+
if closing < 0 or (
|
|
47
|
+
suffix and not (suffix.startswith(":") and suffix[1:].isdigit())
|
|
48
|
+
):
|
|
49
|
+
return ""
|
|
50
|
+
return host[1:closing]
|
|
51
|
+
if host.count(":") == 1:
|
|
52
|
+
hostname, port = host.rsplit(":", 1)
|
|
53
|
+
if not port.isdigit():
|
|
54
|
+
return ""
|
|
55
|
+
return hostname
|
|
56
|
+
return host if ":" not in host else ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.middleware("http")
|
|
60
|
+
async def require_local_http_context(request, call_next):
|
|
61
|
+
"""Reject DNS-rebinding reads and browser-driven cross-site writes.
|
|
62
|
+
|
|
63
|
+
Every request must retain a localhost Host header. Origin-less local requests
|
|
64
|
+
remain available to the command line and native folder picker. Browsers attach
|
|
65
|
+
an Origin to cross-site POST/PUT/PATCH/DELETE requests, which prevents an
|
|
66
|
+
arbitrary website from closing AQDA or changing its data.
|
|
67
|
+
"""
|
|
68
|
+
hostname = _hostname_from_host_header(request.headers.get("host", ""))
|
|
69
|
+
if hostname not in _LOCAL_BROWSER_HOSTS:
|
|
70
|
+
return JSONResponse(
|
|
71
|
+
{"detail": "AQDA only accepts requests addressed to localhost"},
|
|
72
|
+
status_code=400,
|
|
73
|
+
)
|
|
74
|
+
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
75
|
+
origin = request.headers.get("origin")
|
|
76
|
+
if origin and origin not in _LOCAL_BROWSER_ORIGINS:
|
|
77
|
+
return JSONResponse(
|
|
78
|
+
{"detail": "Cross-site requests to AQDA are not allowed"},
|
|
79
|
+
status_code=403,
|
|
80
|
+
)
|
|
81
|
+
return await call_next(request)
|
|
82
|
+
|
|
83
|
+
# API routes
|
|
84
|
+
app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
|
|
85
|
+
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
|
|
86
|
+
app.include_router(codes.router, prefix="/api/codes", tags=["codes"])
|
|
87
|
+
app.include_router(codings.router, prefix="/api/codings", tags=["codings"])
|
|
88
|
+
app.include_router(memos.router, prefix="/api/memos", tags=["memos"])
|
|
89
|
+
app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
|
|
90
|
+
app.include_router(ai.router, prefix="/api/ai", tags=["ai"])
|
|
91
|
+
app.include_router(export.router, prefix="/api/export", tags=["export"])
|
|
92
|
+
app.include_router(shared.router, prefix="/api/shared", tags=["shared"])
|
|
93
|
+
app.include_router(system.router, prefix="/api/system", tags=["system"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.api_route(
|
|
97
|
+
"/api/{path:path}",
|
|
98
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
|
99
|
+
include_in_schema=False,
|
|
100
|
+
)
|
|
101
|
+
async def api_not_found(path: str):
|
|
102
|
+
return JSONResponse({"detail": f"API route not found: /api/{path}"}, status_code=404)
|
|
103
|
+
|
|
104
|
+
# Serve frontend
|
|
105
|
+
FRONTEND_DIR = Path(__file__).parent / "frontend" / "dist"
|
|
106
|
+
|
|
107
|
+
if FRONTEND_DIR.exists():
|
|
108
|
+
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="static")
|
|
109
|
+
_FRONTEND_ROOT = FRONTEND_DIR.resolve()
|
|
110
|
+
_INDEX = _FRONTEND_ROOT / "index.html"
|
|
111
|
+
|
|
112
|
+
@app.get("/{path:path}")
|
|
113
|
+
async def serve_spa(path: str):
|
|
114
|
+
# Serve index.html for all non-API, non-asset routes (SPA routing).
|
|
115
|
+
# Resolve the target and confine it to the frontend dir so crafted
|
|
116
|
+
# paths like "../../db.py" can't escape and serve arbitrary files.
|
|
117
|
+
target = (_FRONTEND_ROOT / path).resolve()
|
|
118
|
+
if target.is_relative_to(_FRONTEND_ROOT) and target.is_file():
|
|
119
|
+
return FileResponse(target)
|
|
120
|
+
return FileResponse(_INDEX)
|
aqda/db.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Database setup and connection management using aiosqlite."""
|
|
2
|
+
|
|
3
|
+
import aiosqlite
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DATA_DIR = Path(os.environ.get("AQDA_DATA_DIR", Path.home() / ".aqda"))
|
|
11
|
+
|
|
12
|
+
SCHEMA = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS project (
|
|
14
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
description TEXT DEFAULT '',
|
|
17
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
18
|
+
modified_at TEXT DEFAULT (datetime('now')),
|
|
19
|
+
deleted_at TEXT DEFAULT NULL,
|
|
20
|
+
lineage_id TEXT DEFAULT NULL,
|
|
21
|
+
revision INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
head_snapshot_id TEXT DEFAULT NULL,
|
|
23
|
+
shared_folder TEXT DEFAULT NULL,
|
|
24
|
+
shared_previous_folder TEXT DEFAULT NULL,
|
|
25
|
+
shared_last_published_revision INTEGER DEFAULT NULL,
|
|
26
|
+
shared_last_snapshot_id TEXT DEFAULT NULL,
|
|
27
|
+
shared_last_sync_at TEXT DEFAULT NULL,
|
|
28
|
+
shared_sync_error TEXT DEFAULT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS document (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
34
|
+
name TEXT NOT NULL,
|
|
35
|
+
content TEXT NOT NULL,
|
|
36
|
+
source_type TEXT DEFAULT 'text',
|
|
37
|
+
transcript TEXT DEFAULT NULL,
|
|
38
|
+
label TEXT DEFAULT '',
|
|
39
|
+
exclude_from_ai INTEGER DEFAULT 0,
|
|
40
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
41
|
+
modified_at TEXT DEFAULT (datetime('now'))
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS code (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
47
|
+
parent_id INTEGER REFERENCES code(id) ON DELETE CASCADE,
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
description TEXT DEFAULT '',
|
|
50
|
+
color TEXT DEFAULT '#6366f1',
|
|
51
|
+
sort_order INTEGER DEFAULT 0,
|
|
52
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
53
|
+
deleted_at TEXT DEFAULT NULL,
|
|
54
|
+
deletion_batch_id TEXT DEFAULT NULL
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS coding (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
+
document_id INTEGER NOT NULL REFERENCES document(id) ON DELETE CASCADE,
|
|
60
|
+
code_id INTEGER NOT NULL REFERENCES code(id) ON DELETE CASCADE,
|
|
61
|
+
start_pos INTEGER NOT NULL,
|
|
62
|
+
end_pos INTEGER NOT NULL,
|
|
63
|
+
selected_text TEXT NOT NULL,
|
|
64
|
+
coder TEXT DEFAULT '',
|
|
65
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
66
|
+
deleted_at TEXT DEFAULT NULL,
|
|
67
|
+
deletion_batch_id TEXT DEFAULT NULL,
|
|
68
|
+
offset_unit TEXT NOT NULL DEFAULT 'codepoint',
|
|
69
|
+
repair_status TEXT DEFAULT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS memo (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
75
|
+
document_id INTEGER REFERENCES document(id) ON DELETE CASCADE,
|
|
76
|
+
code_id INTEGER REFERENCES code(id) ON DELETE SET NULL,
|
|
77
|
+
coding_id INTEGER REFERENCES coding(id) ON DELETE SET NULL,
|
|
78
|
+
start_pos INTEGER DEFAULT NULL,
|
|
79
|
+
end_pos INTEGER DEFAULT NULL,
|
|
80
|
+
title TEXT DEFAULT '',
|
|
81
|
+
content TEXT DEFAULT '',
|
|
82
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
83
|
+
modified_at TEXT DEFAULT (datetime('now')),
|
|
84
|
+
offset_unit TEXT NOT NULL DEFAULT 'codepoint',
|
|
85
|
+
repair_status TEXT DEFAULT NULL
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS document_variable (
|
|
89
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
90
|
+
document_id INTEGER NOT NULL REFERENCES document(id) ON DELETE CASCADE,
|
|
91
|
+
key TEXT NOT NULL,
|
|
92
|
+
value TEXT DEFAULT '',
|
|
93
|
+
UNIQUE(document_id, key)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE TABLE IF NOT EXISTS setting (
|
|
97
|
+
key TEXT PRIMARY KEY,
|
|
98
|
+
value TEXT NOT NULL
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
-- Default settings
|
|
102
|
+
INSERT OR IGNORE INTO setting (key, value) VALUES
|
|
103
|
+
('ollama_url', 'http://localhost:11434'),
|
|
104
|
+
('llm_model', ''),
|
|
105
|
+
('embedding_model', ''),
|
|
106
|
+
('chunk_size', '500'),
|
|
107
|
+
('chunk_overlap', '50'),
|
|
108
|
+
('filename_pattern', ''),
|
|
109
|
+
('filename_variables', ''),
|
|
110
|
+
('whisper_model', 'base'),
|
|
111
|
+
('coder_name', ''),
|
|
112
|
+
('shared_folder', ''),
|
|
113
|
+
('shared_folders', '[]'),
|
|
114
|
+
('device_id', ''),
|
|
115
|
+
('schema_version', '11');
|
|
116
|
+
|
|
117
|
+
CREATE TABLE IF NOT EXISTS project_snapshot (
|
|
118
|
+
snapshot_id TEXT PRIMARY KEY,
|
|
119
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
120
|
+
parent_snapshot_id TEXT DEFAULT NULL,
|
|
121
|
+
revision INTEGER NOT NULL,
|
|
122
|
+
created_at TEXT NOT NULL,
|
|
123
|
+
created_by TEXT DEFAULT ''
|
|
124
|
+
);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_snapshot_project ON project_snapshot(project_id, created_at);
|
|
126
|
+
|
|
127
|
+
CREATE TABLE IF NOT EXISTS code_deletion_batch (
|
|
128
|
+
id TEXT PRIMARY KEY,
|
|
129
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
130
|
+
root_code_id INTEGER NOT NULL,
|
|
131
|
+
deleted_at TEXT NOT NULL
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS shared_ignored_head (
|
|
135
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
136
|
+
snapshot_id TEXT NOT NULL,
|
|
137
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
138
|
+
PRIMARY KEY (project_id, snapshot_id)
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE IF NOT EXISTS shared_conflict_branch (
|
|
142
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
143
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
144
|
+
source_lineage_id TEXT NOT NULL,
|
|
145
|
+
anchor_snapshot_id TEXT NOT NULL,
|
|
146
|
+
latest_snapshot_id TEXT NOT NULL,
|
|
147
|
+
latest_snapshot_path TEXT NOT NULL,
|
|
148
|
+
conflict_project_id INTEGER REFERENCES project(id) ON DELETE SET NULL,
|
|
149
|
+
conflict_base_revision INTEGER DEFAULT NULL,
|
|
150
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
151
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
152
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
153
|
+
UNIQUE(project_id, anchor_snapshot_id)
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
CREATE TABLE IF NOT EXISTS embedding_cache (
|
|
157
|
+
id TEXT PRIMARY KEY,
|
|
158
|
+
document_id INTEGER NOT NULL REFERENCES document(id) ON DELETE CASCADE,
|
|
159
|
+
project_id INTEGER NOT NULL,
|
|
160
|
+
model TEXT NOT NULL,
|
|
161
|
+
start_pos INTEGER NOT NULL,
|
|
162
|
+
end_pos INTEGER NOT NULL,
|
|
163
|
+
chunk_text TEXT NOT NULL,
|
|
164
|
+
embedding BLOB NOT NULL
|
|
165
|
+
);
|
|
166
|
+
CREATE INDEX IF NOT EXISTS idx_embedding_project ON embedding_cache(project_id, model);
|
|
167
|
+
CREATE INDEX IF NOT EXISTS idx_embedding_document ON embedding_cache(document_id, model);
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
# Migrations keyed by target version. Each runs if schema_version < target.
|
|
171
|
+
MIGRATIONS = {
|
|
172
|
+
2: [
|
|
173
|
+
"ALTER TABLE code ADD COLUMN deleted_at TEXT DEFAULT NULL",
|
|
174
|
+
"ALTER TABLE coding ADD COLUMN deleted_at TEXT DEFAULT NULL",
|
|
175
|
+
],
|
|
176
|
+
3: [
|
|
177
|
+
"""CREATE TABLE IF NOT EXISTS embedding_cache (
|
|
178
|
+
id TEXT PRIMARY KEY,
|
|
179
|
+
document_id INTEGER NOT NULL REFERENCES document(id) ON DELETE CASCADE,
|
|
180
|
+
project_id INTEGER NOT NULL,
|
|
181
|
+
model TEXT NOT NULL,
|
|
182
|
+
start_pos INTEGER NOT NULL,
|
|
183
|
+
end_pos INTEGER NOT NULL,
|
|
184
|
+
chunk_text TEXT NOT NULL,
|
|
185
|
+
embedding BLOB NOT NULL
|
|
186
|
+
)""",
|
|
187
|
+
"CREATE INDEX IF NOT EXISTS idx_embedding_project ON embedding_cache(project_id, model)",
|
|
188
|
+
"CREATE INDEX IF NOT EXISTS idx_embedding_document ON embedding_cache(document_id, model)",
|
|
189
|
+
],
|
|
190
|
+
4: [
|
|
191
|
+
"ALTER TABLE project ADD COLUMN deleted_at TEXT DEFAULT NULL",
|
|
192
|
+
],
|
|
193
|
+
5: [
|
|
194
|
+
"ALTER TABLE document ADD COLUMN transcript TEXT DEFAULT NULL",
|
|
195
|
+
],
|
|
196
|
+
6: [
|
|
197
|
+
"ALTER TABLE document ADD COLUMN label TEXT DEFAULT ''",
|
|
198
|
+
"ALTER TABLE document ADD COLUMN exclude_from_ai INTEGER DEFAULT 0",
|
|
199
|
+
"ALTER TABLE memo ADD COLUMN start_pos INTEGER DEFAULT NULL",
|
|
200
|
+
"ALTER TABLE memo ADD COLUMN end_pos INTEGER DEFAULT NULL",
|
|
201
|
+
"INSERT OR IGNORE INTO setting (key, value) VALUES ('coder_name', '')",
|
|
202
|
+
],
|
|
203
|
+
7: [
|
|
204
|
+
"ALTER TABLE coding ADD COLUMN coder TEXT DEFAULT ''",
|
|
205
|
+
],
|
|
206
|
+
8: [
|
|
207
|
+
"ALTER TABLE project ADD COLUMN lineage_id TEXT DEFAULT NULL",
|
|
208
|
+
"ALTER TABLE project ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
|
|
209
|
+
"ALTER TABLE project ADD COLUMN head_snapshot_id TEXT DEFAULT NULL",
|
|
210
|
+
"""CREATE TABLE IF NOT EXISTS project_snapshot (
|
|
211
|
+
snapshot_id TEXT PRIMARY KEY,
|
|
212
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
213
|
+
parent_snapshot_id TEXT DEFAULT NULL,
|
|
214
|
+
revision INTEGER NOT NULL,
|
|
215
|
+
created_at TEXT NOT NULL,
|
|
216
|
+
created_by TEXT DEFAULT ''
|
|
217
|
+
)""",
|
|
218
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_project ON project_snapshot(project_id, created_at)",
|
|
219
|
+
],
|
|
220
|
+
9: [
|
|
221
|
+
"ALTER TABLE project ADD COLUMN shared_folder TEXT DEFAULT NULL",
|
|
222
|
+
"ALTER TABLE project ADD COLUMN shared_last_published_revision INTEGER DEFAULT NULL",
|
|
223
|
+
"ALTER TABLE project ADD COLUMN shared_last_snapshot_id TEXT DEFAULT NULL",
|
|
224
|
+
"ALTER TABLE project ADD COLUMN shared_last_sync_at TEXT DEFAULT NULL",
|
|
225
|
+
"ALTER TABLE project ADD COLUMN shared_sync_error TEXT DEFAULT NULL",
|
|
226
|
+
"ALTER TABLE code ADD COLUMN deletion_batch_id TEXT DEFAULT NULL",
|
|
227
|
+
"ALTER TABLE coding ADD COLUMN deletion_batch_id TEXT DEFAULT NULL",
|
|
228
|
+
"ALTER TABLE coding ADD COLUMN offset_unit TEXT NOT NULL DEFAULT 'legacy_utf16'",
|
|
229
|
+
"ALTER TABLE coding ADD COLUMN repair_status TEXT DEFAULT NULL",
|
|
230
|
+
"ALTER TABLE memo ADD COLUMN offset_unit TEXT NOT NULL DEFAULT 'legacy_utf16'",
|
|
231
|
+
"ALTER TABLE memo ADD COLUMN repair_status TEXT DEFAULT NULL",
|
|
232
|
+
"INSERT OR IGNORE INTO setting (key, value) VALUES ('shared_folder', '')",
|
|
233
|
+
"INSERT OR IGNORE INTO setting (key, value) VALUES ('device_id', '')",
|
|
234
|
+
"""CREATE TABLE IF NOT EXISTS code_deletion_batch (
|
|
235
|
+
id TEXT PRIMARY KEY,
|
|
236
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
237
|
+
root_code_id INTEGER NOT NULL,
|
|
238
|
+
deleted_at TEXT NOT NULL
|
|
239
|
+
)""",
|
|
240
|
+
"""CREATE TABLE IF NOT EXISTS shared_ignored_head (
|
|
241
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
242
|
+
snapshot_id TEXT NOT NULL,
|
|
243
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
244
|
+
PRIMARY KEY (project_id, snapshot_id)
|
|
245
|
+
)""",
|
|
246
|
+
],
|
|
247
|
+
10: [
|
|
248
|
+
"""CREATE TABLE IF NOT EXISTS shared_conflict_branch (
|
|
249
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
250
|
+
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
251
|
+
source_lineage_id TEXT NOT NULL,
|
|
252
|
+
anchor_snapshot_id TEXT NOT NULL,
|
|
253
|
+
latest_snapshot_id TEXT NOT NULL,
|
|
254
|
+
latest_snapshot_path TEXT NOT NULL,
|
|
255
|
+
conflict_project_id INTEGER REFERENCES project(id) ON DELETE SET NULL,
|
|
256
|
+
conflict_base_revision INTEGER DEFAULT NULL,
|
|
257
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
258
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
259
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
260
|
+
UNIQUE(project_id, anchor_snapshot_id)
|
|
261
|
+
)""",
|
|
262
|
+
],
|
|
263
|
+
11: [
|
|
264
|
+
"ALTER TABLE project ADD COLUMN shared_previous_folder TEXT DEFAULT NULL",
|
|
265
|
+
"INSERT OR IGNORE INTO setting (key, value) VALUES ('shared_folders', '[]')",
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
LATEST_SCHEMA_VERSION = max(MIGRATIONS)
|
|
270
|
+
|
|
271
|
+
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _db_path(project_id: int | None = None) -> Path:
|
|
275
|
+
"""Get path to the main database."""
|
|
276
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
return DATA_DIR / "aqda.db"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def get_db() -> aiosqlite.Connection:
|
|
281
|
+
"""Get a database connection."""
|
|
282
|
+
db = await aiosqlite.connect(_db_path(), timeout=15)
|
|
283
|
+
db.row_factory = aiosqlite.Row
|
|
284
|
+
await db.execute("PRAGMA busy_timeout=15000")
|
|
285
|
+
await db.execute("PRAGMA journal_mode=WAL")
|
|
286
|
+
await db.execute("PRAGMA foreign_keys=ON")
|
|
287
|
+
return db
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def _run_migrations(db: aiosqlite.Connection):
|
|
291
|
+
"""Apply pending schema migrations."""
|
|
292
|
+
cursor = await db.execute(
|
|
293
|
+
"SELECT value FROM setting WHERE key='schema_version'"
|
|
294
|
+
)
|
|
295
|
+
row = await cursor.fetchone()
|
|
296
|
+
current = int(row["value"]) if row else 1
|
|
297
|
+
|
|
298
|
+
for target in sorted(MIGRATIONS.keys()):
|
|
299
|
+
if current >= target:
|
|
300
|
+
continue
|
|
301
|
+
for sql in MIGRATIONS[target]:
|
|
302
|
+
try:
|
|
303
|
+
await db.execute(sql)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
# An ALTER that re-adds an existing column is expected (the fresh
|
|
306
|
+
# schema already has it); anything else is a real failure we must
|
|
307
|
+
# not hide, or we'd silently bump the version on a broken DB.
|
|
308
|
+
if "duplicate column name" not in str(e).lower():
|
|
309
|
+
raise
|
|
310
|
+
await db.execute(
|
|
311
|
+
"INSERT OR REPLACE INTO setting (key, value) VALUES ('schema_version', ?)",
|
|
312
|
+
(str(target),),
|
|
313
|
+
)
|
|
314
|
+
await db.commit()
|
|
315
|
+
|
|
316
|
+
# Lineage IDs are application-generated UUIDs. Populate legacy rows after
|
|
317
|
+
# migration 8 rather than relying on a SQLite expression as a column default.
|
|
318
|
+
cursor = await db.execute("SELECT id FROM project WHERE lineage_id IS NULL OR lineage_id='' ")
|
|
319
|
+
for row in await cursor.fetchall():
|
|
320
|
+
await db.execute(
|
|
321
|
+
"UPDATE project SET lineage_id=? WHERE id=?",
|
|
322
|
+
(str(uuid.uuid4()), row["id"]),
|
|
323
|
+
)
|
|
324
|
+
await db.execute(
|
|
325
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_project_lineage ON project(lineage_id)"
|
|
326
|
+
)
|
|
327
|
+
cursor = await db.execute("SELECT value FROM setting WHERE key='device_id'")
|
|
328
|
+
device = await cursor.fetchone()
|
|
329
|
+
if not device or not device["value"].strip():
|
|
330
|
+
await db.execute(
|
|
331
|
+
"INSERT OR REPLACE INTO setting (key, value) VALUES ('device_id', ?)",
|
|
332
|
+
(str(uuid.uuid4()),),
|
|
333
|
+
)
|
|
334
|
+
await db.commit()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def touch_project(db: aiosqlite.Connection, project_id: int | None):
|
|
338
|
+
"""Record a substantive project change for ordering and snapshot conflict checks."""
|
|
339
|
+
if project_id is None:
|
|
340
|
+
return
|
|
341
|
+
await db.execute(
|
|
342
|
+
"UPDATE project SET revision=revision+1, modified_at=datetime('now') WHERE id=?",
|
|
343
|
+
(project_id,),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def touch_projects(db: aiosqlite.Connection, project_ids):
|
|
348
|
+
"""Touch each distinct project ID once within the caller's transaction."""
|
|
349
|
+
for project_id in sorted({pid for pid in project_ids if pid is not None}):
|
|
350
|
+
await touch_project(db, project_id)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def create_backup(label: str = "backup") -> Path:
|
|
354
|
+
"""Create and verify a consistent backup of the live database.
|
|
355
|
+
|
|
356
|
+
The SQLite backup API is safe while other connections are open. Backups use
|
|
357
|
+
immutable timestamped names so cloud-sync tools never observe a half-replaced
|
|
358
|
+
database file.
|
|
359
|
+
"""
|
|
360
|
+
source_path = _db_path()
|
|
361
|
+
if not source_path.exists():
|
|
362
|
+
raise FileNotFoundError("AQDA database does not exist")
|
|
363
|
+
|
|
364
|
+
backup_dir = DATA_DIR / "backups"
|
|
365
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
safe_label = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in label)
|
|
367
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
368
|
+
backup_path = backup_dir / f"aqda-{safe_label}-{stamp}.db"
|
|
369
|
+
|
|
370
|
+
source = sqlite3.connect(source_path)
|
|
371
|
+
destination = sqlite3.connect(backup_path)
|
|
372
|
+
try:
|
|
373
|
+
source.backup(destination)
|
|
374
|
+
result = destination.execute("PRAGMA integrity_check").fetchone()
|
|
375
|
+
if not result or result[0] != "ok":
|
|
376
|
+
raise RuntimeError(f"Backup integrity check failed: {result}")
|
|
377
|
+
except Exception:
|
|
378
|
+
destination.close()
|
|
379
|
+
source.close()
|
|
380
|
+
backup_path.unlink(missing_ok=True)
|
|
381
|
+
raise
|
|
382
|
+
else:
|
|
383
|
+
destination.close()
|
|
384
|
+
source.close()
|
|
385
|
+
return backup_path
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _existing_schema_version(path: Path) -> int | None:
|
|
389
|
+
"""Read a database version without creating or migrating the database."""
|
|
390
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
391
|
+
return None
|
|
392
|
+
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
393
|
+
try:
|
|
394
|
+
row = connection.execute(
|
|
395
|
+
"SELECT value FROM setting WHERE key='schema_version'"
|
|
396
|
+
).fetchone()
|
|
397
|
+
return int(row[0]) if row else 1
|
|
398
|
+
except sqlite3.DatabaseError:
|
|
399
|
+
return 1
|
|
400
|
+
finally:
|
|
401
|
+
connection.close()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _prune_backups(prefix: str, keep: int) -> None:
|
|
405
|
+
backup_dir = DATA_DIR / "backups"
|
|
406
|
+
if not backup_dir.exists():
|
|
407
|
+
return
|
|
408
|
+
matches = sorted(backup_dir.glob(f"aqda-{prefix}-*.db"), reverse=True)
|
|
409
|
+
for stale in matches[keep:]:
|
|
410
|
+
stale.unlink(missing_ok=True)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def create_daily_backup() -> Path | None:
|
|
414
|
+
"""Create at most one verified rolling backup per UTC day."""
|
|
415
|
+
backup_dir = DATA_DIR / "backups"
|
|
416
|
+
day = datetime.now(timezone.utc).strftime("%Y%m%d")
|
|
417
|
+
already_created = (
|
|
418
|
+
any(backup_dir.glob(f"aqda-daily-{day}T*.db"))
|
|
419
|
+
if backup_dir.exists()
|
|
420
|
+
else False
|
|
421
|
+
)
|
|
422
|
+
if already_created:
|
|
423
|
+
return None
|
|
424
|
+
path = create_backup("daily")
|
|
425
|
+
_prune_backups("daily", keep=7)
|
|
426
|
+
return path
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def init_db():
|
|
430
|
+
"""Initialize the database schema and run migrations."""
|
|
431
|
+
path = _db_path()
|
|
432
|
+
existing_version = _existing_schema_version(path)
|
|
433
|
+
if existing_version is not None and existing_version < LATEST_SCHEMA_VERSION:
|
|
434
|
+
create_backup(f"before-migration-v{LATEST_SCHEMA_VERSION}")
|
|
435
|
+
db = await get_db()
|
|
436
|
+
try:
|
|
437
|
+
await db.executescript(SCHEMA)
|
|
438
|
+
await db.commit()
|
|
439
|
+
await _run_migrations(db)
|
|
440
|
+
from aqda.services.offsets import repair_legacy_offsets
|
|
441
|
+
|
|
442
|
+
await repair_legacy_offsets(db)
|
|
443
|
+
await db.commit()
|
|
444
|
+
finally:
|
|
445
|
+
await db.close()
|
|
446
|
+
if existing_version is not None:
|
|
447
|
+
create_daily_backup()
|