Implement Web Sessions Core API and interface
This commit is contained in:
parent
ec17fca45d
commit
47385c7a86
39 changed files with 1876 additions and 43 deletions
|
|
@ -23,7 +23,7 @@ CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEX
|
|||
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
|
||||
CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT);
|
||||
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
|
||||
PRAGMA user_version=22;
|
||||
PRAGMA user_version=23;
|
||||
"""
|
||||
|
||||
class GenerationTest(unittest.TestCase):
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21, 22):
|
||||
raise CausalError(f"desktop schema v20-v22 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23):
|
||||
raise CausalError(f"desktop schema v20-v23 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22):
|
||||
raise LifecycleError(f"desktop schema v19-v22 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23):
|
||||
raise LifecycleError(f"desktop schema v19-v23 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||
raise ValueError("schema desktop v18-v22 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
|
||||
raise ValueError("schema desktop v18-v23 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
raise ValueError("schema desktop v12-v16 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise ValueError("schema desktop v12-v23 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22) and not (args.version == 2 and schema_version == 10):
|
||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) and not (args.version == 2 and schema_version == 10):
|
||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22): raise ValueError("schema desktop v15-v22 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23): raise ValueError("schema desktop v15-v23 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||
raise ImportFailure("schema desktop v18-v22 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
|
||||
raise ImportFailure("schema desktop v18-v23 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
fail("causal equipment protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
fail("schema desktop v12-v21 requis")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22): raise ImportFailure("schema desktop v11-v22 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23): raise ImportFailure("schema desktop v11-v23 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ def validate(item):
|
|||
def apply_profile_state(con, root, allow_pending=False):
|
||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22): fail("schema desktop v17-v22 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23): fail("schema desktop v17-v23 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -846,9 +846,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v22 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v23 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
|
|||
require_supported_schema(connection)
|
||||
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v22")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v23")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22):fail("schema desktop v15-v22 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23):fail("schema desktop v15-v23 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||
seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.2",
|
||||
"desktop_schema": 22,
|
||||
"desktop_schema": 23,
|
||||
"android_schema": 21,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
|
|
|
|||
|
|
@ -199,8 +199,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 22:
|
||||
raise GenerationError("desktop schema v22 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 23:
|
||||
raise GenerationError("desktop schema v23 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@
|
|||
* - `se` for sessions;
|
||||
* - `bo` for body observations.
|
||||
* - `sy` for synchronization runs;
|
||||
* - `sxe` for persisted session-exercise occurrences.
|
||||
* - `sxe` for persisted session-exercise occurrences;
|
||||
* - `sp`, `spr`, `spe`, and `spd` for preparation, revision, occurrence, and
|
||||
* delivery identities.
|
||||
*
|
||||
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
||||
* this API defines creation policy, not import validation.
|
||||
|
|
|
|||
68
tui/include/trainlog/web_sessions.h
Normal file
68
tui/include/trainlog/web_sessions.h
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/* Trainlog Web Sessions V1 application service. */
|
||||
#ifndef TRAINLOG_WEB_SESSIONS_H
|
||||
#define TRAINLOG_WEB_SESSIONS_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_WEB_SESSIONS_PAGE_MAX 64U
|
||||
#define TRAINLOG_WEB_SESSIONS_JSON_MAX (512U * 1024U)
|
||||
|
||||
typedef enum TrainlogWebSessionCollection {
|
||||
TRAINLOG_WEB_SESSION_PREPARATIONS = 0,
|
||||
TRAINLOG_WEB_SESSION_PROPOSALS,
|
||||
TRAINLOG_WEB_SESSION_DRAFTS,
|
||||
TRAINLOG_WEB_SESSION_HISTORY
|
||||
} TrainlogWebSessionCollection;
|
||||
|
||||
typedef struct TrainlogWebSessionsPageQuery {
|
||||
TrainlogWebSessionCollection collection;
|
||||
size_t offset;
|
||||
size_t limit;
|
||||
const char *search;
|
||||
} TrainlogWebSessionsPageQuery;
|
||||
|
||||
/*
|
||||
* CONTRACT: database and query strings are borrowed for the call. On success,
|
||||
* output_json is heap-owned by the caller and must be released with free().
|
||||
* A page contains at most TRAINLOG_WEB_SESSIONS_PAGE_MAX rows plus a `more`
|
||||
* flag derived from one additional row observed in the same read snapshot.
|
||||
*/
|
||||
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
||||
const TrainlogWebSessionsPageQuery *query,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
|
||||
* returned detail is a factual snapshot and performs no lifecycle transition. */
|
||||
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
|
||||
const char *kind,
|
||||
const char *identity,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/* CONTRACT: the catalogue contains only currently selectable exercises. */
|
||||
TrainlogStatus trainlog_web_sessions_catalog_json(TrainlogDatabase *database,
|
||||
const char *search,
|
||||
size_t offset,
|
||||
size_t limit,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
/*
|
||||
* CONTRACT: save_body is a complete revision document and request_id is a
|
||||
* durable idempotency key. expected_revision is empty only for creation.
|
||||
* Validation and the head comparison occur in one immediate transaction.
|
||||
*/
|
||||
TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
||||
const char *preparation_id,
|
||||
const char *expected_revision,
|
||||
const char *request_id,
|
||||
const char *save_body,
|
||||
size_t save_body_size,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
#endif
|
||||
|
|
@ -194,6 +194,7 @@ trainlog_core_sources = files(
|
|||
'src/web_dashboard_json.c',
|
||||
'src/web_prepared_items.c',
|
||||
'src/web_prepared_items_json.c',
|
||||
'src/web_sessions.c',
|
||||
'src/duration.c',
|
||||
'src/id.c',
|
||||
'src/timeutil.c',
|
||||
|
|
@ -346,6 +347,15 @@ test_web_prepared_items = executable(
|
|||
)
|
||||
test('web_prepared_items', test_web_prepared_items)
|
||||
|
||||
test_web_sessions = executable(
|
||||
'test_web_sessions',
|
||||
'tests/test_web_sessions.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
include_directories: trainlog_private_include,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('web_sessions', test_web_sessions)
|
||||
|
||||
test_dashboard_header = executable(
|
||||
'test_dashboard_header',
|
||||
'tests/test_dashboard_header.c',
|
||||
|
|
|
|||
|
|
@ -1618,7 +1618,8 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
status = TRAINLOG_STATUS_OK;
|
||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||
version == 15 || version == 16 || version == 17 || version == 18 ||
|
||||
version == 19 || version == 20 || version == 21 || version == 22) {
|
||||
version == 19 || version == 20 || version == 21 || version == 22 ||
|
||||
version == 23) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ static bool prefix_is_supported(const char *prefix)
|
|||
strcmp(prefix, "se") == 0 ||
|
||||
strcmp(prefix, "bo") == 0 ||
|
||||
strcmp(prefix, "sy") == 0 ||
|
||||
strcmp(prefix, "sxe") == 0;
|
||||
strcmp(prefix, "sxe") == 0 ||
|
||||
strcmp(prefix, "sp") == 0 ||
|
||||
strcmp(prefix, "spr") == 0 ||
|
||||
strcmp(prefix, "spe") == 0 ||
|
||||
strcmp(prefix, "spd") == 0;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_id_generate(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
#include "trainlog/web_dashboard.h"
|
||||
#include "trainlog/web_prepared_items.h"
|
||||
#include "trainlog/web_sessions.h"
|
||||
#include "trainlog/dashboard_layout.h"
|
||||
#include "web_assets.h"
|
||||
|
||||
|
|
@ -29,7 +30,8 @@
|
|||
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
|
||||
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
|
||||
#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U)
|
||||
#define TRAINLOG_HTTP_BODY_LIMIT (4U * 1024U)
|
||||
#define TRAINLOG_HTTP_BODY_LIMIT (128U * 1024U)
|
||||
#define TRAINLOG_HTTP_LEGACY_BODY_LIMIT (4U * 1024U)
|
||||
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
|
||||
#define TRAINLOG_HTTP_BACKLOG 32U
|
||||
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
|
||||
|
|
@ -513,6 +515,46 @@ static bool parse_if_match(const char *value, uint64_t *revision) {
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool parse_page_number(const char *value, size_t default_value, size_t maximum, size_t *out) {
|
||||
char *end = NULL;
|
||||
unsigned long long parsed;
|
||||
|
||||
if (value == NULL) {
|
||||
*out = default_value;
|
||||
return true;
|
||||
}
|
||||
errno = 0;
|
||||
parsed = strtoull(value, &end, 10);
|
||||
if (errno != 0 || end == value || *end != '\0' || parsed > maximum) {
|
||||
return false;
|
||||
}
|
||||
*out = (size_t)parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
static enum MHD_Result queue_owned_sessions_json(struct MHD_Connection *connection,
|
||||
TrainlogStatus status,
|
||||
char *json,
|
||||
size_t json_size) {
|
||||
enum MHD_Result result;
|
||||
|
||||
if (status == TRAINLOG_STATUS_NOT_FOUND) {
|
||||
free(json);
|
||||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||
}
|
||||
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
|
||||
free(json);
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"sessions_unavailable\"}\n",
|
||||
NULL);
|
||||
}
|
||||
(void)json_size;
|
||||
result = queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||
free(json);
|
||||
return result;
|
||||
}
|
||||
|
||||
static enum MHD_Result handle_request(void *closure,
|
||||
struct MHD_Connection *connection,
|
||||
const char *url,
|
||||
|
|
@ -561,7 +603,9 @@ static enum MHD_Result handle_request(void *closure,
|
|||
"{\"error\":\"headers_too_large\"}\n",
|
||||
NULL);
|
||||
}
|
||||
if (state->body_too_large) {
|
||||
if (state->body_too_large ||
|
||||
(strncmp(url, "/api/v1/sessions/", strlen("/api/v1/sessions/")) != 0 &&
|
||||
state->body_size > TRAINLOG_HTTP_LEGACY_BODY_LIMIT)) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_CONTENT_TOO_LARGE, "{\"error\":\"body_too_large\"}\n", NULL);
|
||||
}
|
||||
|
|
@ -729,6 +773,167 @@ static enum MHD_Result handle_request(void *closure,
|
|||
}
|
||||
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||
}
|
||||
if (strncmp(url, "/api/v1/sessions", strlen("/api/v1/sessions")) == 0) {
|
||||
static const struct {
|
||||
const char *path;
|
||||
TrainlogWebSessionCollection collection;
|
||||
} collections[] = {
|
||||
{"/api/v1/sessions/preparations", TRAINLOG_WEB_SESSION_PREPARATIONS},
|
||||
{"/api/v1/sessions/proposals", TRAINLOG_WEB_SESSION_PROPOSALS},
|
||||
{"/api/v1/sessions/drafts", TRAINLOG_WEB_SESSION_DRAFTS},
|
||||
{"/api/v1/sessions/history", TRAINLOG_WEB_SESSION_HISTORY},
|
||||
};
|
||||
const char *offset_value;
|
||||
const char *limit_value;
|
||||
const char *search;
|
||||
char *json = NULL;
|
||||
size_t json_size = 0U;
|
||||
size_t offset;
|
||||
size_t limit;
|
||||
size_t index;
|
||||
|
||||
if (!is_get &&
|
||||
(strcmp(url, "/api/v1/sessions/preparations") == 0 ||
|
||||
strncmp(url,
|
||||
"/api/v1/sessions/preparation/",
|
||||
strlen("/api/v1/sessions/preparation/")) == 0)) {
|
||||
const char *origin =
|
||||
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
|
||||
const char *csrf = MHD_lookup_connection_value(
|
||||
connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
|
||||
const char *content_type = MHD_lookup_connection_value(
|
||||
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
|
||||
const char *request_id = MHD_lookup_connection_value(
|
||||
connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||
const char *if_match = MHD_lookup_connection_value(
|
||||
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
|
||||
const char *identity = NULL;
|
||||
const char *expected = "";
|
||||
char expected_buffer[TRAINLOG_ID_MAX + 1U];
|
||||
TrainlogStatus status;
|
||||
|
||||
if ((strcmp(url, "/api/v1/sessions/preparations") == 0 &&
|
||||
strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
|
||||
(strcmp(url, "/api/v1/sessions/preparations") != 0 &&
|
||||
strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"POST, PUT");
|
||||
}
|
||||
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_FORBIDDEN,
|
||||
"{\"error\":\"mutation_forbidden\"}\n",
|
||||
NULL);
|
||||
}
|
||||
if (content_type == NULL || strcmp(content_type, "application/json") != 0 ||
|
||||
request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_BAD_REQUEST,
|
||||
"{\"error\":\"invalid_mutation_headers\"}\n",
|
||||
NULL);
|
||||
}
|
||||
if (strcmp(url, "/api/v1/sessions/preparations") != 0) {
|
||||
size_t length;
|
||||
identity = url + strlen("/api/v1/sessions/preparation/");
|
||||
if (if_match == NULL || if_match[0] != '"') {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_PRECONDITION_REQUIRED,
|
||||
"{\"error\":\"precondition_required\"}\n",
|
||||
NULL);
|
||||
}
|
||||
length = strlen(if_match);
|
||||
if (length < 3U || length - 2U > TRAINLOG_ID_MAX ||
|
||||
if_match[length - 1U] != '"') {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_BAD_REQUEST,
|
||||
"{\"error\":\"invalid_revision\"}\n",
|
||||
NULL);
|
||||
}
|
||||
(void)memcpy(expected_buffer, if_match + 1, length - 2U);
|
||||
expected_buffer[length - 2U] = '\0';
|
||||
expected = expected_buffer;
|
||||
}
|
||||
status = trainlog_web_sessions_save_json(context->database,
|
||||
identity,
|
||||
expected,
|
||||
request_id,
|
||||
state->body,
|
||||
state->body_size,
|
||||
&json,
|
||||
&json_size);
|
||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_CONFLICT, "{\"error\":\"revision_conflict\"}\n", NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_NOT_FOUND) {
|
||||
free(json);
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||
free(json);
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_UNPROCESSABLE_ENTITY,
|
||||
"{\"error\":\"invalid_preparation\"}\n",
|
||||
NULL);
|
||||
}
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
if (!is_get) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"GET");
|
||||
}
|
||||
offset_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset");
|
||||
limit_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit");
|
||||
search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
|
||||
if (!parse_page_number(offset_value, 0U, 1000000U, &offset) ||
|
||||
!parse_page_number(
|
||||
limit_value, 24U, TRAINLOG_WEB_SESSIONS_PAGE_MAX, &limit) ||
|
||||
limit == 0U) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
|
||||
}
|
||||
if (strcmp(url, "/api/v1/sessions/catalog") == 0) {
|
||||
TrainlogStatus status = trainlog_web_sessions_catalog_json(
|
||||
context->database, search, offset, limit, &json, &json_size);
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
|
||||
if (strcmp(url, collections[index].path) == 0) {
|
||||
TrainlogWebSessionsPageQuery query = {
|
||||
collections[index].collection, offset, limit, search};
|
||||
TrainlogStatus status = trainlog_web_sessions_list_json(
|
||||
context->database, &query, &json, &json_size);
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
}
|
||||
{
|
||||
static const char DETAIL_PREFIX[] = "/api/v1/sessions/";
|
||||
const char *kind = url + strlen(DETAIL_PREFIX);
|
||||
const char *separator = strchr(kind, '/');
|
||||
char kind_buffer[32];
|
||||
|
||||
if (separator != NULL && separator[1] != '\0' &&
|
||||
(size_t)(separator - kind) < sizeof(kind_buffer)) {
|
||||
TrainlogStatus status;
|
||||
|
||||
(void)memcpy(kind_buffer, kind, (size_t)(separator - kind));
|
||||
kind_buffer[separator - kind] = '\0';
|
||||
status = trainlog_web_sessions_detail_json(context->database,
|
||||
kind_buffer,
|
||||
separator + 1,
|
||||
&json,
|
||||
&json_size);
|
||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||
}
|
||||
}
|
||||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||
}
|
||||
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
|
||||
TrainlogDashboardLayout layout;
|
||||
TrainlogDashboardLayout saved;
|
||||
|
|
|
|||
916
tui/src/web_sessions.c
Normal file
916
tui/src/web_sessions.c
Normal file
|
|
@ -0,0 +1,916 @@
|
|||
#include "trainlog/web_sessions.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
typedef struct ListDefinition {
|
||||
const char *kind;
|
||||
const char *sql;
|
||||
} ListDefinition;
|
||||
|
||||
static const ListDefinition LISTS[] = {
|
||||
{"preparation",
|
||||
"SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at "
|
||||
"FROM session_preparations p JOIN session_preparation_revisions r ON r.revision_id=p.current_revision_id "
|
||||
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
|
||||
"WHERE (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
|
||||
"GROUP BY p.preparation_id ORDER BY COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"},
|
||||
{"proposal",
|
||||
"SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN 'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at "
|
||||
"FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id "
|
||||
"WHERE (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM ai_session_draft_entries ae JOIN exercises x ON x.id=ae.exercise_row_id WHERE ae.draft_row_id=d.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
|
||||
"GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),d.created_at,d.draft_id LIMIT ?2 OFFSET ?3"},
|
||||
{"draft",
|
||||
"SELECT d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
|
||||
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE f.session_id=d.session_id) "
|
||||
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
|
||||
"AND (?1='' OR d.session_type LIKE '%'||?1||'%' COLLATE NOCASE) ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,COALESCE(d.started_at,''),d.session_id LIMIT ?2 OFFSET ?3"},
|
||||
{"history",
|
||||
"SELECT s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s.started_at "
|
||||
"FROM sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id "
|
||||
"WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE he.session_row_id=s.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
|
||||
"GROUP BY s.id ORDER BY s.started_at DESC,s.session_id LIMIT ?2 OFFSET ?3"},
|
||||
};
|
||||
|
||||
static bool add_nullable_text(yyjson_mut_doc *document,
|
||||
yyjson_mut_val *object,
|
||||
const char *key,
|
||||
sqlite3_stmt *statement,
|
||||
int column) {
|
||||
if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
|
||||
return yyjson_mut_obj_add_null(document, object, key);
|
||||
}
|
||||
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) {
|
||||
return false;
|
||||
}
|
||||
return yyjson_mut_obj_add_strcpy(
|
||||
document, object, key, (const char *)sqlite3_column_text(statement, column));
|
||||
}
|
||||
|
||||
static TrainlogStatus write_document(yyjson_mut_doc *document,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
yyjson_write_err error;
|
||||
char *json;
|
||||
|
||||
json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, output_size, &error);
|
||||
yyjson_mut_doc_free(document);
|
||||
if (json == NULL || *output_size == 0U || *output_size > TRAINLOG_WEB_SESSIONS_JSON_MAX) {
|
||||
free(json);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
*output_json = json;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
||||
const TrainlogWebSessionsPageQuery *query,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
yyjson_mut_doc *document = NULL;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *items;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogStatus status;
|
||||
TrainlogStatus end_status;
|
||||
size_t count = 0U;
|
||||
int step;
|
||||
const ListDefinition *definition;
|
||||
|
||||
if (database == NULL || query == NULL || output_json == NULL || output_size == NULL ||
|
||||
query->collection > TRAINLOG_WEB_SESSION_HISTORY || query->limit == 0U ||
|
||||
query->limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || query->offset > INT64_MAX) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
definition = &LISTS[(size_t)query->collection];
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection, definition->sql, -1, &statement, NULL) !=
|
||||
SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, query->search == NULL ? "" : query->search, -1,
|
||||
SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 2, (sqlite3_int64)(query->limit + 1U)) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)query->offset) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
document = yyjson_mut_doc_new(NULL);
|
||||
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
items = document == NULL ? NULL : yyjson_mut_arr(document);
|
||||
if (document == NULL || root == NULL || items == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||
!yyjson_mut_obj_add_strcpy(document, root, "kind", definition->kind) ||
|
||||
!yyjson_mut_obj_add_uint(document, root, "offset", query->offset) ||
|
||||
!yyjson_mut_obj_add_val(document, root, "items", items)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *item;
|
||||
sqlite3_int64 occurrence_count;
|
||||
|
||||
if (count == query->limit) {
|
||||
++count;
|
||||
break;
|
||||
}
|
||||
occurrence_count = sqlite3_column_int64(statement, 4);
|
||||
item = yyjson_mut_obj(document);
|
||||
if (item == NULL || occurrence_count < 0 ||
|
||||
!add_nullable_text(document, item, "identity", statement, 0) ||
|
||||
!add_nullable_text(document, item, "title", statement, 1) ||
|
||||
!add_nullable_text(document, item, "date", statement, 2) ||
|
||||
!add_nullable_text(document, item, "state", statement, 3) ||
|
||||
!yyjson_mut_obj_add_sint(document, item, "occurrence_count", occurrence_count) ||
|
||||
!add_nullable_text(document, item, "sort_timestamp", statement, 5) ||
|
||||
!yyjson_mut_arr_add_val(items, item)) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
if (step != SQLITE_DONE && count <= query->limit) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
if (!yyjson_mut_obj_add_bool(document, root, "more", count > query->limit) ||
|
||||
!yyjson_mut_obj_add_uint(document, root, "next_offset", query->offset + query->limit)) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finish;
|
||||
}
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
|
||||
finish:
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||
status == TRAINLOG_STATUS_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = end_status;
|
||||
}
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return status;
|
||||
}
|
||||
return write_document(document, output_json, output_size);
|
||||
}
|
||||
|
||||
static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
|
||||
const char *identity,
|
||||
bool proposal,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root) {
|
||||
const char *sql = proposal
|
||||
? "SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN 'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
|
||||
: "SELECT p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,COALESCE(p.source_payload_sha256,''),p.current_revision_id,p.delivery_state FROM session_preparations p JOIN session_preparation_revisions r ON r.revision_id=p.current_revision_id WHERE p.preparation_id=?1";
|
||||
const char *entry_sql = proposal
|
||||
? "SELECT e.entry_id,e.position,x.exercise_id,x.name,e.equipment_id,e.recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e.target_weight_kg,e.rest_seconds,NULL FROM ai_session_draft_entries e JOIN exercises x ON x.id=e.exercise_row_id JOIN ai_session_drafts d ON d.id=e.draft_row_id WHERE d.draft_id=?1 ORDER BY e.position,e.entry_id"
|
||||
: "SELECT e.entry_id,e.position,e.exercise_id,COALESCE(x.name,e.exercise_id),e.equipment_id,e.recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e.target_weight_kg,e.rest_seconds,e.notes FROM session_preparation_entries e LEFT JOIN exercises x ON x.exercise_id=e.exercise_id JOIN session_preparations p ON p.current_revision_id=e.revision_id WHERE p.preparation_id=?1 ORDER BY e.position,e.entry_id";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *entries;
|
||||
int step;
|
||||
int column;
|
||||
|
||||
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_ROW) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
for (column = 0; column < 8; ++column) {
|
||||
static const char *const keys[] = {"identity", "title", "planned_for", "session_type",
|
||||
"notes", "source_fingerprint", "revision_id", "state"};
|
||||
if (!add_nullable_text(document, root, keys[column], statement, column)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
entries = yyjson_mut_arr(document);
|
||||
if (entries == NULL || !yyjson_mut_obj_add_val(document, root, "occurrences", entries) ||
|
||||
sqlite3_prepare_v2(database->connection, entry_sql, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name",
|
||||
"equipment_id", "recording_mode", "tracking_mode",
|
||||
"load_mode"};
|
||||
yyjson_mut_val *entry = yyjson_mut_obj(document);
|
||||
int index;
|
||||
if (entry == NULL || !yyjson_mut_obj_add_sint(
|
||||
document, entry, "position", sqlite3_column_int64(statement, 1))) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
for (index = 0; index < 8; ++index) {
|
||||
if (text_keys[index] != NULL &&
|
||||
!add_nullable_text(document, entry, text_keys[index], statement, index)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
for (index = 8; index <= 12; ++index) {
|
||||
static const char *const number_keys[] = {"target_sets", "target_reps",
|
||||
"target_duration_seconds", "target_weight_kg",
|
||||
"rest_seconds"};
|
||||
if (sqlite3_column_type(statement, index) == SQLITE_NULL) {
|
||||
(void)yyjson_mut_obj_add_null(document, entry, number_keys[index - 8]);
|
||||
} else if (index == 11) {
|
||||
(void)yyjson_mut_obj_add_real(document, entry, number_keys[index - 8],
|
||||
sqlite3_column_double(statement, index));
|
||||
} else {
|
||||
(void)yyjson_mut_obj_add_sint(document, entry, number_keys[index - 8],
|
||||
sqlite3_column_int64(statement, index));
|
||||
}
|
||||
}
|
||||
if (!add_nullable_text(document, entry, "notes", statement, 13) ||
|
||||
!yyjson_mut_arr_add_val(entries, entry)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static TrainlogStatus detail_for_history(TrainlogDatabase *database,
|
||||
const char *identity,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root) {
|
||||
static const char SESSION_SQL[] =
|
||||
"SELECT session_id,session_type,started_at,ended_at,notes FROM sessions WHERE session_id=?1";
|
||||
static const char ENTRY_SQL[] =
|
||||
"SELECT se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se.tracking_mode,se.load_mode,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.rest_seconds,se.notes,se.max_weight_kg FROM session_exercises se JOIN exercises x ON x.id=se.exercise_row_id JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=?1 ORDER BY se.position,se.entry_id";
|
||||
static const char SET_SQL[] =
|
||||
"SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id=?1 ORDER BY ps.position";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
sqlite3_stmt *sets_statement = NULL;
|
||||
yyjson_mut_val *entries;
|
||||
int step;
|
||||
|
||||
if (sqlite3_prepare_v2(database->connection, SESSION_SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_ROW) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
if (!add_nullable_text(document, root, "identity", statement, 0) ||
|
||||
!add_nullable_text(document, root, "session_type", statement, 1) ||
|
||||
!add_nullable_text(document, root, "started_at", statement, 2) ||
|
||||
!add_nullable_text(document, root, "ended_at", statement, 3) ||
|
||||
!add_nullable_text(document, root, "notes", statement, 4)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
entries = yyjson_mut_arr(document);
|
||||
if (entries == NULL || !yyjson_mut_obj_add_val(document, root, "occurrences", entries) ||
|
||||
sqlite3_prepare_v2(database->connection, ENTRY_SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *entry = yyjson_mut_obj(document);
|
||||
yyjson_mut_val *sets = yyjson_mut_arr(document);
|
||||
const char *entry_id = (const char *)sqlite3_column_text(statement, 0);
|
||||
int set_step;
|
||||
int column;
|
||||
static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name",
|
||||
"equipment_id", "recording_mode", "tracking_mode",
|
||||
"load_mode"};
|
||||
static const char *const numeric_keys[] = {"target_sets", "target_reps",
|
||||
"target_duration_seconds", "target_weight_kg",
|
||||
"rest_seconds", "notes", "max_weight_kg"};
|
||||
|
||||
if (entry == NULL || sets == NULL ||
|
||||
!yyjson_mut_obj_add_sint(document, entry, "position", sqlite3_column_int64(statement, 1))) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
for (column = 0; column < 8; ++column) {
|
||||
if (text_keys[column] != NULL &&
|
||||
!add_nullable_text(document, entry, text_keys[column], statement, column)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
for (column = 8; column <= 14; ++column) {
|
||||
if (column == 13) {
|
||||
(void)add_nullable_text(document, entry, numeric_keys[column - 8], statement, column);
|
||||
} else if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
|
||||
(void)yyjson_mut_obj_add_null(document, entry, numeric_keys[column - 8]);
|
||||
} else if (column == 11 || column == 14) {
|
||||
(void)yyjson_mut_obj_add_real(document,
|
||||
entry,
|
||||
numeric_keys[column - 8],
|
||||
sqlite3_column_double(statement, column));
|
||||
} else {
|
||||
(void)yyjson_mut_obj_add_sint(document,
|
||||
entry,
|
||||
numeric_keys[column - 8],
|
||||
sqlite3_column_int64(statement, column));
|
||||
}
|
||||
}
|
||||
if (!yyjson_mut_obj_add_val(document, entry, "performed_sets", sets) ||
|
||||
sqlite3_prepare_v2(database->connection, SET_SQL, -1, &sets_statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(sets_statement, 1, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
if (sets_statement != NULL) {
|
||||
(void)sqlite3_finalize(sets_statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
while ((set_step = sqlite3_step(sets_statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *set = yyjson_mut_obj(document);
|
||||
if (set == NULL ||
|
||||
!yyjson_mut_obj_add_sint(document, set, "position", sqlite3_column_int64(sets_statement, 0))) {
|
||||
(void)sqlite3_finalize(sets_statement);
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
for (column = 1; column <= 3; ++column) {
|
||||
static const char *const keys[] = {"reps", "duration_seconds", "weight_kg"};
|
||||
if (sqlite3_column_type(sets_statement, column) == SQLITE_NULL) {
|
||||
(void)yyjson_mut_obj_add_null(document, set, keys[column - 1]);
|
||||
} else if (column == 3) {
|
||||
(void)yyjson_mut_obj_add_real(document,
|
||||
set,
|
||||
keys[column - 1],
|
||||
sqlite3_column_double(sets_statement, column));
|
||||
} else {
|
||||
(void)yyjson_mut_obj_add_sint(document,
|
||||
set,
|
||||
keys[column - 1],
|
||||
sqlite3_column_int64(sets_statement, column));
|
||||
}
|
||||
}
|
||||
(void)yyjson_mut_arr_add_val(sets, set);
|
||||
}
|
||||
if (set_step != SQLITE_DONE || sqlite3_finalize(sets_statement) != SQLITE_OK ||
|
||||
!yyjson_mut_arr_add_val(entries, entry)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
sets_statement = NULL;
|
||||
}
|
||||
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static TrainlogStatus detail_for_draft(TrainlogDatabase *database,
|
||||
const char *identity,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_doc *payload_document = NULL;
|
||||
yyjson_val *payload;
|
||||
yyjson_mut_val *copied_payload;
|
||||
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT session_id,session_type,started_at,state,revision_id,payload_json FROM execution_drafts WHERE session_id=?1",
|
||||
-1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_ROW) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
if (!add_nullable_text(document, root, "identity", statement, 0) ||
|
||||
!add_nullable_text(document, root, "session_type", statement, 1) ||
|
||||
!add_nullable_text(document, root, "started_at", statement, 2) ||
|
||||
!add_nullable_text(document, root, "state", statement, 3) ||
|
||||
!add_nullable_text(document, root, "revision_id", statement, 4)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
payload_document = yyjson_read((const char *)sqlite3_column_text(statement, 5),
|
||||
(size_t)sqlite3_column_bytes(statement, 5),
|
||||
YYJSON_READ_NOFLAG);
|
||||
payload = payload_document == NULL ? NULL : yyjson_doc_get_root(payload_document);
|
||||
copied_payload = payload == NULL ? NULL : yyjson_val_mut_copy(document, payload);
|
||||
if (copied_payload == NULL ||
|
||||
!yyjson_mut_obj_add_val(document, root, "payload", copied_payload) ||
|
||||
sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
yyjson_doc_free(payload_document);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
yyjson_doc_free(payload_document);
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
|
||||
const char *kind,
|
||||
const char *identity,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
yyjson_mut_doc *document;
|
||||
yyjson_mut_val *root;
|
||||
TrainlogStatus status;
|
||||
TrainlogStatus end_status;
|
||||
|
||||
if (database == NULL || kind == NULL || identity == NULL || identity[0] == '\0' ||
|
||||
output_json == NULL || output_size == NULL) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
if (strcmp(kind, "preparation") != 0 && strcmp(kind, "proposal") != 0 &&
|
||||
strcmp(kind, "history") != 0 && strcmp(kind, "draft") != 0) {
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
document = yyjson_mut_doc_new(NULL);
|
||||
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
if (document == NULL || root == NULL) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
|
||||
(void)yyjson_mut_obj_add_strcpy(document, root, "kind", kind);
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
if (strcmp(kind, "history") == 0) {
|
||||
status = detail_for_history(database, identity, document, root);
|
||||
} else if (strcmp(kind, "draft") == 0) {
|
||||
status = detail_for_draft(database, identity, document, root);
|
||||
} else {
|
||||
status = detail_for_revision(
|
||||
database, identity, strcmp(kind, "proposal") == 0, document, root);
|
||||
}
|
||||
}
|
||||
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = end_status;
|
||||
}
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return status;
|
||||
}
|
||||
return write_document(document, output_json, output_size);
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_catalog_json(TrainlogDatabase *database,
|
||||
const char *search,
|
||||
size_t offset,
|
||||
size_t limit,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
static const char SQL[] =
|
||||
"SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises "
|
||||
"WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='exercise' "
|
||||
"AND c.target_id=exercises.exercise_id AND c.deleted=1) "
|
||||
"AND (?1='' OR name LIKE '%'||?1||'%' COLLATE NOCASE) "
|
||||
"ORDER BY name COLLATE NOCASE,exercise_id LIMIT ?2 OFFSET ?3";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_doc *document;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *items;
|
||||
size_t count = 0U;
|
||||
int step;
|
||||
|
||||
if (database == NULL || output_json == NULL || output_size == NULL || limit == 0U ||
|
||||
limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || offset > INT64_MAX) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
document = yyjson_mut_doc_new(NULL);
|
||||
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
items = document == NULL ? NULL : yyjson_mut_arr(document);
|
||||
if (document == NULL || root == NULL || items == NULL ||
|
||||
sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, search == NULL ? "" : search, -1, SQLITE_TRANSIENT) !=
|
||||
SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 2, (sqlite3_int64)(limit + 1U)) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)offset) != SQLITE_OK) {
|
||||
yyjson_mut_doc_free(document);
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
|
||||
(void)yyjson_mut_obj_add_val(document, root, "items", items);
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *item;
|
||||
if (count == limit) {
|
||||
++count;
|
||||
break;
|
||||
}
|
||||
item = yyjson_mut_obj(document);
|
||||
if (item == NULL || !add_nullable_text(document, item, "exercise_id", statement, 0) ||
|
||||
!add_nullable_text(document, item, "name", statement, 1) ||
|
||||
!add_nullable_text(document, item, "recording_mode", statement, 2) ||
|
||||
!add_nullable_text(document, item, "tracking_mode", statement, 3) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, item, "data_fields", sqlite3_column_int64(statement, 4)) ||
|
||||
!yyjson_mut_arr_add_val(items, item)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
yyjson_mut_doc_free(document);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
if ((step != SQLITE_DONE && count <= limit) || sqlite3_finalize(statement) != SQLITE_OK ||
|
||||
!yyjson_mut_obj_add_bool(document, root, "more", count > limit) ||
|
||||
!yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
return write_document(document, output_json, output_size);
|
||||
}
|
||||
|
||||
static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
|
||||
time_t now = time(NULL);
|
||||
struct tm utc;
|
||||
|
||||
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
|
||||
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
|
||||
}
|
||||
|
||||
static bool json_object_has_only(yyjson_val *object,
|
||||
const char *const *allowed,
|
||||
size_t allowed_count) {
|
||||
yyjson_obj_iter iterator = yyjson_obj_iter_with(object);
|
||||
yyjson_val *key;
|
||||
|
||||
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
|
||||
size_t index;
|
||||
bool found = false;
|
||||
const char *name = yyjson_get_str(key);
|
||||
for (index = 0U; index < allowed_count; ++index) {
|
||||
if (name != NULL && strcmp(name, allowed[index]) == 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool bounded_json_string(yyjson_val *value, size_t capacity, const char **output) {
|
||||
const char *text;
|
||||
size_t length;
|
||||
|
||||
if (!yyjson_is_str(value)) {
|
||||
return false;
|
||||
}
|
||||
text = yyjson_get_str(value);
|
||||
length = yyjson_get_len(value);
|
||||
if (text == NULL || length >= capacity || memchr(text, '\0', length) != NULL) {
|
||||
return false;
|
||||
}
|
||||
*output = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool bind_nullable_text(sqlite3_stmt *statement, int index, yyjson_val *value) {
|
||||
if (value == NULL || yyjson_is_null(value)) {
|
||||
return sqlite3_bind_null(statement, index) == SQLITE_OK;
|
||||
}
|
||||
return yyjson_is_str(value) &&
|
||||
sqlite3_bind_text(statement, index, yyjson_get_str(value),
|
||||
(int)yyjson_get_len(value), SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
|
||||
const char *revision_id,
|
||||
yyjson_val *entry,
|
||||
size_t position) {
|
||||
static const char *const ALLOWED[] = {
|
||||
"entry_id", "exercise_id", "equipment_id", "load_mode", "rest_seconds",
|
||||
"target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "notes"};
|
||||
static const char INSERT_SQL[] =
|
||||
"INSERT INTO session_preparation_entries(revision_id,entry_id,position,exercise_id,equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_kg,notes) "
|
||||
"SELECT ?1,?2,?3,e.exercise_id,?5,e.recording_mode,e.tracking_mode,e.data_fields,?6,?7,?8,?9,?10,?11,?12 FROM exercises e "
|
||||
"WHERE e.exercise_id=?4 AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='exercise' AND c.target_id=e.exercise_id AND c.deleted=1)";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
const char *entry_id;
|
||||
const char *exercise_id;
|
||||
const char *load_mode;
|
||||
char generated_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
yyjson_val *value;
|
||||
int64_t rest_seconds;
|
||||
int rc;
|
||||
|
||||
if (!yyjson_is_obj(entry) ||
|
||||
!json_object_has_only(entry, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) ||
|
||||
!bounded_json_string(yyjson_obj_get(entry, "exercise_id"), TRAINLOG_ID_MAX + 1U,
|
||||
&exercise_id) ||
|
||||
!bounded_json_string(yyjson_obj_get(entry, "load_mode"), 16U, &load_mode) ||
|
||||
(strcmp(load_mode, "none") != 0 && strcmp(load_mode, "external") != 0 &&
|
||||
strcmp(load_mode, "assistance") != 0)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
value = yyjson_obj_get(entry, "entry_id");
|
||||
if (value == NULL) {
|
||||
if (trainlog_id_generate("spe", generated_id, sizeof(generated_id)) != TRAINLOG_STATUS_OK) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
entry_id = generated_id;
|
||||
} else if (!bounded_json_string(value, TRAINLOG_ID_MAX + 1U, &entry_id) ||
|
||||
strncmp(entry_id, "spe_", 4U) != 0) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
value = yyjson_obj_get(entry, "rest_seconds");
|
||||
if (!yyjson_is_int(value)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
rest_seconds = yyjson_get_sint(value);
|
||||
if (rest_seconds < 0 || rest_seconds > 86400 || position > INT64_MAX ||
|
||||
sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, revision_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)position) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
!bind_nullable_text(statement, 5, yyjson_obj_get(entry, "equipment_id")) ||
|
||||
sqlite3_bind_text(statement, 6, load_mode, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 7, rest_seconds) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
value = yyjson_obj_get(entry, "target_sets");
|
||||
if (value == NULL || yyjson_is_null(value)) {
|
||||
(void)sqlite3_bind_null(statement, 8);
|
||||
} else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 99 ||
|
||||
sqlite3_bind_int64(statement, 8, yyjson_get_sint(value)) != SQLITE_OK) {
|
||||
goto invalid;
|
||||
}
|
||||
value = yyjson_obj_get(entry, "target_reps");
|
||||
if (value == NULL || yyjson_is_null(value)) {
|
||||
(void)sqlite3_bind_null(statement, 9);
|
||||
} else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 10000 ||
|
||||
sqlite3_bind_int64(statement, 9, yyjson_get_sint(value)) != SQLITE_OK) {
|
||||
goto invalid;
|
||||
}
|
||||
value = yyjson_obj_get(entry, "target_duration_seconds");
|
||||
if (value == NULL || yyjson_is_null(value)) {
|
||||
(void)sqlite3_bind_null(statement, 10);
|
||||
} else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 604800 ||
|
||||
sqlite3_bind_int64(statement, 10, yyjson_get_sint(value)) != SQLITE_OK) {
|
||||
goto invalid;
|
||||
}
|
||||
value = yyjson_obj_get(entry, "target_weight_kg");
|
||||
if (value == NULL || yyjson_is_null(value)) {
|
||||
(void)sqlite3_bind_null(statement, 11);
|
||||
} else if (!yyjson_is_num(value) || !isfinite(yyjson_get_real(value)) ||
|
||||
yyjson_get_real(value) <= 0.0 || yyjson_get_real(value) > 2000.0 ||
|
||||
sqlite3_bind_double(statement, 11, yyjson_get_real(value)) != SQLITE_OK) {
|
||||
goto invalid;
|
||||
}
|
||||
if (!bind_nullable_text(statement, 12, yyjson_obj_get(entry, "notes"))) {
|
||||
goto invalid;
|
||||
}
|
||||
rc = sqlite3_step(statement);
|
||||
(void)sqlite3_finalize(statement);
|
||||
return rc == SQLITE_DONE && sqlite3_changes(database->connection) == 1
|
||||
? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_CONFLICT;
|
||||
|
||||
invalid:
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
||||
const char *preparation_id,
|
||||
const char *expected_revision,
|
||||
const char *request_id,
|
||||
const char *save_body,
|
||||
size_t save_body_size,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
static const char *const ALLOWED[] = {
|
||||
"title", "session_type", "planned_for", "notes", "editing_state", "occurrences"};
|
||||
yyjson_doc *input = NULL;
|
||||
yyjson_val *root;
|
||||
yyjson_val *occurrences;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char new_preparation[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
char revision[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
char now[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||
const char *actual_preparation = preparation_id;
|
||||
const char *title;
|
||||
const char *session_type;
|
||||
const char *editing_state;
|
||||
size_t index;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_OK;
|
||||
yyjson_mut_doc *response;
|
||||
yyjson_mut_val *response_root;
|
||||
|
||||
if (database == NULL || expected_revision == NULL || request_id == NULL || request_id[0] == '\0' ||
|
||||
save_body == NULL || save_body_size == 0U || output_json == NULL || output_size == NULL ||
|
||||
!timestamp_now(now)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
input = yyjson_read(save_body, save_body_size, YYJSON_READ_NOFLAG);
|
||||
root = input == NULL ? NULL : yyjson_doc_get_root(input);
|
||||
if (!yyjson_is_obj(root) ||
|
||||
!json_object_has_only(root, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) ||
|
||||
!bounded_json_string(yyjson_obj_get(root, "title"), TRAINLOG_NAME_MAX + 1U, &title) ||
|
||||
!bounded_json_string(yyjson_obj_get(root, "session_type"), 16U, &session_type) ||
|
||||
!bounded_json_string(yyjson_obj_get(root, "editing_state"), 16U, &editing_state) ||
|
||||
(strcmp(session_type, "training") != 0 && strcmp(session_type, "max_test") != 0) ||
|
||||
(strcmp(editing_state, "draft") != 0 && strcmp(editing_state, "ready") != 0)) {
|
||||
yyjson_doc_free(input);
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
occurrences = yyjson_obj_get(root, "occurrences");
|
||||
if (!yyjson_is_arr(occurrences) || yyjson_arr_size(occurrences) > 64U ||
|
||||
(strcmp(editing_state, "ready") == 0 && (title[0] == '\0' || yyjson_arr_size(occurrences) == 0U))) {
|
||||
yyjson_doc_free(input);
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
if (actual_preparation == NULL || actual_preparation[0] == '\0') {
|
||||
if (expected_revision[0] != '\0' ||
|
||||
trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) != TRAINLOG_STATUS_OK) {
|
||||
yyjson_doc_free(input);
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
actual_preparation = new_preparation;
|
||||
}
|
||||
if (trainlog_id_generate("spr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
yyjson_doc_free(input);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
/* INVARIANT: a replayed request returns its committed identity and does
|
||||
* not evaluate the now-stale expected revision again. */
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT response_json FROM session_preparation_requests WHERE request_id=?1",
|
||||
-1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
const char *saved = (const char *)sqlite3_column_text(statement, 0);
|
||||
*output_size = saved == NULL ? 0U : strlen(saved);
|
||||
*output_json = saved == NULL ? NULL : strdup(saved);
|
||||
(void)sqlite3_finalize(statement);
|
||||
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
|
||||
yyjson_doc_free(input);
|
||||
return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
}
|
||||
if (preparation_id != NULL && preparation_id[0] != '\0') {
|
||||
const char *current = NULL;
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1",
|
||||
-1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_ROW) {
|
||||
status = TRAINLOG_STATUS_NOT_FOUND;
|
||||
goto rollback;
|
||||
}
|
||||
current = (const char *)sqlite3_column_text(statement, 0);
|
||||
if (current == NULL || strcmp(current, expected_revision) != 0) {
|
||||
status = TRAINLOG_STATUS_CONFLICT;
|
||||
goto rollback;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
} else if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)",
|
||||
-1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto rollback;
|
||||
}
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
}
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO session_preparation_revisions VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
-1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
(expected_revision[0] == '\0' ? sqlite3_bind_null(statement, 3)
|
||||
: sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, title, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, session_type, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
!bind_nullable_text(statement, 6, yyjson_obj_get(root, "planned_for")) ||
|
||||
!bind_nullable_text(statement, 7, yyjson_obj_get(root, "notes")) ||
|
||||
sqlite3_bind_text(statement, 8, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
goto rollback;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
for (index = 0U; index < yyjson_arr_size(occurrences); ++index) {
|
||||
status = insert_preparation_entry(
|
||||
database, revision, yyjson_arr_get(occurrences, index), index);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
goto rollback;
|
||||
}
|
||||
}
|
||||
if (preparation_id != NULL && preparation_id[0] != '\0') {
|
||||
if (sqlite3_prepare_v2(database->connection,
|
||||
"UPDATE session_preparations SET current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' END WHERE preparation_id=?4 AND current_revision_id=?5",
|
||||
-1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
status = TRAINLOG_STATUS_CONFLICT;
|
||||
goto rollback;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
}
|
||||
response = yyjson_mut_doc_new(NULL);
|
||||
response_root = response == NULL ? NULL : yyjson_mut_obj(response);
|
||||
if (response == NULL || response_root == NULL) {
|
||||
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto rollback;
|
||||
}
|
||||
yyjson_mut_doc_set_root(response, response_root);
|
||||
(void)yyjson_mut_obj_add_uint(response, response_root, "api_version", 1U);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, response_root, "preparation_id", actual_preparation);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, response_root, "revision_id", revision);
|
||||
(void)yyjson_mut_obj_add_strcpy(response, response_root, "editing_state", editing_state);
|
||||
status = write_document(response, output_json, output_size);
|
||||
if (status != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO session_preparation_requests VALUES(?1,'save',?2,?3,?4,?5)",
|
||||
-1, &statement, NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK ||
|
||||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
statement = NULL;
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto rollback;
|
||||
}
|
||||
yyjson_doc_free(input);
|
||||
return TRAINLOG_STATUS_OK;
|
||||
|
||||
rollback:
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||
free(*output_json);
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
yyjson_doc_free(input);
|
||||
return status;
|
||||
}
|
||||
|
|
@ -27,13 +27,13 @@ int main(void)
|
|||
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(database);database=NULL;
|
||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw)==SQLITE_OK);
|
||||
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
|
||||
trainlog_database_close(database);database=NULL;
|
||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||
CHECK(scalar(raw,"PRAGMA user_version")==22);
|
||||
CHECK(scalar(raw,"PRAGMA user_version")==23);
|
||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
|
||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
|
||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
|
||||
|
|
|
|||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=23;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=24;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 23 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 24 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
149
tui/tests/test_web_sessions.c
Normal file
149
tui/tests/test_web_sessions.c
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/web_sessions.h"
|
||||
|
||||
#define CHECK(expression) \
|
||||
do { \
|
||||
if (!(expression)) { \
|
||||
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
|
||||
return false; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static int scalar(TrainlogDatabase *database, const char *sql) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int result = -1;
|
||||
|
||||
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
result = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool creation_replay_revision_and_pagination(void) {
|
||||
static const char CREATE_BODY[] =
|
||||
"{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\","
|
||||
"\"planned_for\":\"2026-09-20\",\"notes\":\"Dos & épaules\","
|
||||
"\"editing_state\":\"ready\",\"occurrences\":["
|
||||
"{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
|
||||
"\"load_mode\":\"external\",\"rest_seconds\":90,\"target_sets\":3,"
|
||||
"\"target_reps\":8,\"target_duration_seconds\":null,\"target_weight_kg\":42.5},"
|
||||
"{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
|
||||
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target_sets\":1,"
|
||||
"\"target_reps\":12,\"target_duration_seconds\":null,\"target_weight_kg\":null}]}";
|
||||
static const char UPDATE_BODY[] =
|
||||
"{\"title\":\"Réordonnée\",\"session_type\":\"training\","
|
||||
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
|
||||
"\"occurrences\":[]}";
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogWebSessionsPageQuery query = {
|
||||
TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
|
||||
char *created = NULL;
|
||||
char *replayed = NULL;
|
||||
char *detail = NULL;
|
||||
char *page = NULL;
|
||||
char *updated = NULL;
|
||||
size_t created_size = 0U;
|
||||
size_t replayed_size = 0U;
|
||||
size_t detail_size = 0U;
|
||||
size_t page_size = 0U;
|
||||
size_t updated_size = 0U;
|
||||
char preparation_id[128];
|
||||
char revision_id[128];
|
||||
const char *preparation_start;
|
||||
const char *revision_start;
|
||||
const char *end;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"Développé test",
|
||||
"developpe test",
|
||||
TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS,
|
||||
0U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_web_sessions_save_json(database,
|
||||
NULL,
|
||||
"",
|
||||
"request-create-1",
|
||||
CREATE_BODY,
|
||||
strlen(CREATE_BODY),
|
||||
&created,
|
||||
&created_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(created_size > 0U);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_entries") == 2);
|
||||
CHECK(trainlog_web_sessions_save_json(database,
|
||||
NULL,
|
||||
"",
|
||||
"request-create-1",
|
||||
CREATE_BODY,
|
||||
strlen(CREATE_BODY),
|
||||
&replayed,
|
||||
&replayed_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(created_size == replayed_size && strcmp(created, replayed) == 0);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
|
||||
preparation_start = strstr(created, "\"preparation_id\":\"");
|
||||
revision_start = strstr(created, "\"revision_id\":\"");
|
||||
CHECK(preparation_start != NULL && revision_start != NULL);
|
||||
preparation_start += strlen("\"preparation_id\":\"");
|
||||
end = strchr(preparation_start, '"');
|
||||
CHECK(end != NULL && (size_t)(end - preparation_start) < sizeof(preparation_id));
|
||||
(void)memcpy(preparation_id, preparation_start, (size_t)(end - preparation_start));
|
||||
preparation_id[end - preparation_start] = '\0';
|
||||
revision_start += strlen("\"revision_id\":\"");
|
||||
end = strchr(revision_start, '"');
|
||||
CHECK(end != NULL && (size_t)(end - revision_start) < sizeof(revision_id));
|
||||
(void)memcpy(revision_id, revision_start, (size_t)(end - revision_start));
|
||||
revision_id[end - revision_start] = '\0';
|
||||
CHECK(trainlog_web_sessions_detail_json(
|
||||
database, "preparation", preparation_id, &detail, &detail_size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(detail, "Séance échappée") != NULL);
|
||||
CHECK(strstr(detail, "Développé test") != NULL);
|
||||
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(page, preparation_id) != NULL);
|
||||
CHECK(trainlog_web_sessions_save_json(database,
|
||||
preparation_id,
|
||||
revision_id,
|
||||
"request-update-1",
|
||||
UPDATE_BODY,
|
||||
strlen(UPDATE_BODY),
|
||||
&updated,
|
||||
&updated_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(updated_size > 0U);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 2);
|
||||
free(detail);
|
||||
detail = NULL;
|
||||
detail_size = 0U;
|
||||
CHECK(trainlog_web_sessions_save_json(database,
|
||||
preparation_id,
|
||||
revision_id,
|
||||
"request-stale",
|
||||
UPDATE_BODY,
|
||||
strlen(UPDATE_BODY),
|
||||
&detail,
|
||||
&detail_size) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 2);
|
||||
free(created);
|
||||
free(replayed);
|
||||
free(detail);
|
||||
free(page);
|
||||
free(updated);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return creation_replay_revision_and_pagination() ? 0 : 1;
|
||||
}
|
||||
175
web/src/api/sessions.ts
Normal file
175
web/src/api/sessions.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { mutationCsrfToken } from './sync'
|
||||
|
||||
export type SessionCollection = 'preparations' | 'proposals' | 'drafts' | 'history'
|
||||
|
||||
export interface SessionListItem {
|
||||
identity: string
|
||||
title: string
|
||||
date: string | null
|
||||
state: string
|
||||
occurrence_count: number
|
||||
sort_timestamp: string
|
||||
}
|
||||
|
||||
export interface SessionPage {
|
||||
api_version: 1
|
||||
kind: string
|
||||
offset: number
|
||||
more: boolean
|
||||
next_offset: number
|
||||
items: SessionListItem[]
|
||||
}
|
||||
|
||||
export interface PreparationOccurrence {
|
||||
entry_id?: string
|
||||
exercise_id: string
|
||||
exercise_name?: string
|
||||
equipment_id: string | null
|
||||
recording_mode?: string
|
||||
tracking_mode?: string
|
||||
load_mode: 'none' | 'external' | 'assistance'
|
||||
rest_seconds: number
|
||||
target_sets: number | null
|
||||
target_reps: number | null
|
||||
target_duration_seconds: number | null
|
||||
target_weight_kg: number | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
api_version: 1
|
||||
kind: 'preparation' | 'proposal'
|
||||
identity: string
|
||||
revision_id: string
|
||||
title: string
|
||||
planned_for: string | null
|
||||
session_type: 'training' | 'max_test'
|
||||
notes: string | null
|
||||
source_fingerprint: string | null
|
||||
state: string
|
||||
occurrences: PreparationOccurrence[]
|
||||
}
|
||||
|
||||
export interface CatalogChoice {
|
||||
exercise_id: string
|
||||
name: string
|
||||
recording_mode: 'sets' | 'continuous'
|
||||
tracking_mode: 'reps' | 'duration'
|
||||
data_fields: number
|
||||
}
|
||||
|
||||
export interface PreparationInput {
|
||||
title: string
|
||||
session_type: 'training' | 'max_test'
|
||||
planned_for: string | null
|
||||
notes: string | null
|
||||
editing_state: 'draft' | 'ready'
|
||||
occurrences: PreparationOccurrence[]
|
||||
}
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function validListItem(value: unknown): value is SessionListItem {
|
||||
if (!object(value)) return false
|
||||
return typeof value.identity === 'string' && typeof value.title === 'string' &&
|
||||
(value.date === null || typeof value.date === 'string') && typeof value.state === 'string' &&
|
||||
Number.isSafeInteger(value.occurrence_count) && Number(value.occurrence_count) >= 0 &&
|
||||
typeof value.sort_timestamp === 'string'
|
||||
}
|
||||
|
||||
export async function fetchSessionPage(
|
||||
collection: SessionCollection,
|
||||
offset = 0,
|
||||
search = '',
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionPage> {
|
||||
const query = new URLSearchParams({ offset: String(offset), limit: '24' })
|
||||
if (search.trim()) query.set('search', search.trim())
|
||||
const response = await fetch(`/api/v1/sessions/${collection}?${query}`, {
|
||||
headers: { Accept: 'application/json' }, signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`liste HTTP ${response.status}`)
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || value.api_version !== 1 || !Array.isArray(value.items) ||
|
||||
!value.items.every(validListItem) || typeof value.more !== 'boolean' ||
|
||||
!Number.isSafeInteger(value.next_offset)) throw new TypeError('liste de séances invalide')
|
||||
return value as unknown as SessionPage
|
||||
}
|
||||
|
||||
export async function fetchSessionDetail(
|
||||
kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal,
|
||||
): Promise<SessionDetail> {
|
||||
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
|
||||
headers: { Accept: 'application/json' }, signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || value.api_version !== 1 || value.kind !== kind ||
|
||||
value.identity !== identity || typeof value.revision_id !== 'string' ||
|
||||
typeof value.title !== 'string' || !Array.isArray(value.occurrences)) {
|
||||
throw new TypeError('fiche de séance invalide')
|
||||
}
|
||||
return value as unknown as SessionDetail
|
||||
}
|
||||
|
||||
export async function fetchReadonlySessionDetail(
|
||||
kind: 'draft' | 'history', identity: string, signal?: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
|
||||
headers: { Accept: 'application/json' }, signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || value.api_version !== 1 || value.kind !== kind || value.identity !== identity) {
|
||||
throw new TypeError('fiche de séance invalide')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export async function fetchCatalog(search = '', signal?: AbortSignal): Promise<CatalogChoice[]> {
|
||||
const query = new URLSearchParams({ limit: '64', search })
|
||||
const response = await fetch(`/api/v1/sessions/catalog?${query}`, {
|
||||
headers: { Accept: 'application/json' }, signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`catalogue HTTP ${response.status}`)
|
||||
const value: unknown = await response.json()
|
||||
if (!object(value) || !Array.isArray(value.items)) throw new TypeError('catalogue invalide')
|
||||
return value.items as CatalogChoice[]
|
||||
}
|
||||
|
||||
function requestId(): string {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export async function savePreparation(
|
||||
input: PreparationInput,
|
||||
identity?: string,
|
||||
revision?: string,
|
||||
): Promise<{ preparation_id: string; revision_id: string }> {
|
||||
const csrf = await mutationCsrfToken()
|
||||
const response = await fetch(identity
|
||||
? `/api/v1/sessions/preparation/${encodeURIComponent(identity)}`
|
||||
: '/api/v1/sessions/preparations', {
|
||||
method: identity ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Trainlog-CSRF-Token': csrf,
|
||||
'X-Trainlog-Request-ID': requestId(),
|
||||
...(identity && revision ? { 'If-Match': `"${revision}"` } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
const value: unknown = await response.json()
|
||||
if (!response.ok) {
|
||||
const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
|
||||
throw new Error(reason)
|
||||
}
|
||||
if (!object(value) || typeof value.preparation_id !== 'string' ||
|
||||
typeof value.revision_id !== 'string') throw new TypeError('réponse de sauvegarde invalide')
|
||||
return { preparation_id: value.preparation_id, revision_id: value.revision_id }
|
||||
}
|
||||
|
|
@ -40,6 +40,12 @@ export interface SyncStatus {
|
|||
}
|
||||
|
||||
let csrfToken = ''
|
||||
|
||||
export async function mutationCsrfToken(): Promise<string> {
|
||||
if (csrfToken.length !== 64) await fetchSyncStatus()
|
||||
if (csrfToken.length !== 64) throw new Error('jeton CSRF absent')
|
||||
return csrfToken
|
||||
}
|
||||
const phases = new Set<SyncPhase>([
|
||||
'idle',
|
||||
'requested',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Footer } from '../components/Footer'
|
|||
import { Header } from '../components/Header'
|
||||
import { DashboardPage } from '../routes/DashboardPage'
|
||||
import { PlaceholderPage } from '../routes/PlaceholderPage'
|
||||
import { SessionsPage } from '../routes/SessionsPage'
|
||||
import type { RouteId } from './routes'
|
||||
import { useRoute } from './useRoute'
|
||||
|
||||
|
|
@ -72,7 +73,7 @@ export function App() {
|
|||
preparedItemsPending={preparedItemsPending}
|
||||
preparedItemsFailed={preparedItemsFailed}
|
||||
/>
|
||||
) :
|
||||
) : route.id === 'sessions' ? <SessionsPage /> :
|
||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||
eyebrow={placeholderContent[route.id][1]}
|
||||
description={placeholderContent[route.id][2]} />
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ export const routes: readonly AppRoute[] = [
|
|||
]
|
||||
|
||||
export function routeFromPath(pathname: string): AppRoute {
|
||||
if (pathname === '/seances' || pathname.startsWith('/seances/')) return routes[3]
|
||||
return routes.find((route) => route.path === pathname) ?? routes[0]
|
||||
}
|
||||
|
|
|
|||
254
web/src/routes/SessionsPage.tsx
Normal file
254
web/src/routes/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
fetchCatalog,
|
||||
fetchSessionDetail,
|
||||
fetchReadonlySessionDetail,
|
||||
fetchSessionPage,
|
||||
savePreparation,
|
||||
type CatalogChoice,
|
||||
type PreparationInput,
|
||||
type PreparationOccurrence,
|
||||
type SessionCollection,
|
||||
type SessionDetail,
|
||||
type SessionListItem,
|
||||
} from '../api/sessions'
|
||||
|
||||
type View = 'preparation' | 'resume' | 'history'
|
||||
const collections: Record<View, SessionCollection[]> = {
|
||||
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
|
||||
}
|
||||
|
||||
function parseDecimal(value: string): number | null {
|
||||
if (!value.trim()) return null
|
||||
const normalized = value.replace(',', '.')
|
||||
const number = Number(normalized)
|
||||
return Number.isFinite(number) ? number : null
|
||||
}
|
||||
|
||||
function emptyInput(): PreparationInput {
|
||||
return { title: '', session_type: 'training', planned_for: null, notes: null,
|
||||
editing_state: 'draft', occurrences: [] }
|
||||
}
|
||||
|
||||
function Editor({ initial, onSaved, onCancel }: {
|
||||
initial?: SessionDetail
|
||||
onSaved: (identity: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [input, setInput] = useState<PreparationInput>(() => initial ? {
|
||||
title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for,
|
||||
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences,
|
||||
} : emptyInput())
|
||||
const [catalog, setCatalog] = useState<CatalogChoice[]>([])
|
||||
const [catalogSearch, setCatalogSearch] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetchCatalog(catalogSearch, controller.signal).then(setCatalog).catch(() => setCatalog([]))
|
||||
return () => controller.abort()
|
||||
}, [catalogSearch])
|
||||
const updateOccurrence = (index: number, next: PreparationOccurrence) => {
|
||||
setInput((current) => ({ ...current,
|
||||
occurrences: current.occurrences.map((value, position) => position === index ? next : value) }))
|
||||
}
|
||||
const add = (choice: CatalogChoice) => setInput((current) => ({ ...current, occurrences: [
|
||||
...current.occurrences,
|
||||
{ exercise_id: choice.exercise_id, exercise_name: choice.name, equipment_id: null,
|
||||
recording_mode: choice.recording_mode, tracking_mode: choice.tracking_mode,
|
||||
load_mode: 'none', rest_seconds: 0, target_sets: choice.recording_mode === 'sets' ? 1 : null,
|
||||
target_reps: choice.tracking_mode === 'reps' ? 1 : null,
|
||||
target_duration_seconds: choice.tracking_mode === 'duration' ? 60 : null,
|
||||
target_weight_kg: null, notes: null },
|
||||
] }))
|
||||
const move = (index: number, delta: number) => setInput((current) => {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= current.occurrences.length) return current
|
||||
const occurrences = [...current.occurrences]
|
||||
;[occurrences[index], occurrences[target]] = [occurrences[target], occurrences[index]]
|
||||
return { ...current, occurrences }
|
||||
})
|
||||
const submit = async (ready: boolean) => {
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft' },
|
||||
initial?.identity, initial?.revision_id)
|
||||
onSaved(result.preparation_id)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Sauvegarde impossible')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
return <section className="sessions-editor" aria-labelledby="editor-title">
|
||||
<div className="sessions-toolbar"><div><p className="eyebrow">PRÉPARATION</p>
|
||||
<h2 id="editor-title">{initial ? 'Modifier la séance' : 'Nouvelle séance'}</h2></div>
|
||||
<button type="button" className="quiet-action" onClick={onCancel}>Annuler les modifications</button></div>
|
||||
{error && <p role="alert" className="form-error">{error}</p>}
|
||||
<div className="form-grid">
|
||||
<label>Titre<input value={input.title} maxLength={200}
|
||||
onChange={(event) => setInput({ ...input, title: event.target.value })} /></label>
|
||||
<label>Type<select value={input.session_type}
|
||||
onChange={(event) => setInput({ ...input, session_type: event.target.value as 'training' | 'max_test' })}>
|
||||
<option value="training">Entraînement</option><option value="max_test">Test MAX</option></select></label>
|
||||
<label>Date planifiée<input type="date" value={input.planned_for ?? ''}
|
||||
onChange={(event) => setInput({ ...input, planned_for: event.target.value || null })} /></label>
|
||||
<label className="wide-field">Note<textarea value={input.notes ?? ''} maxLength={4000}
|
||||
onChange={(event) => setInput({ ...input, notes: event.target.value || null })} /></label>
|
||||
</div>
|
||||
<div className="catalog-picker"><label>Rechercher un exercice<input value={catalogSearch}
|
||||
onChange={(event) => setCatalogSearch(event.target.value)} /></label>
|
||||
<div className="catalog-results">{catalog.map((choice) => <button type="button"
|
||||
key={choice.exercise_id} onClick={() => add(choice)}>+ {choice.name}</button>)}</div></div>
|
||||
<ol className="occurrence-editor-list">{input.occurrences.map((occurrence, index) =>
|
||||
<li key={occurrence.entry_id ?? `${occurrence.exercise_id}-${index}`}>
|
||||
<div className="occurrence-heading"><span className="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
|
||||
<span className="occurrence-actions"><button type="button" disabled={index === 0}
|
||||
aria-label="Monter" onClick={() => move(index, -1)}>↑</button>
|
||||
<button type="button" disabled={index + 1 === input.occurrences.length}
|
||||
aria-label="Descendre" onClick={() => move(index, 1)}>↓</button>
|
||||
<button type="button" onClick={() => setInput({ ...input,
|
||||
occurrences: input.occurrences.filter((_, position) => position !== index) })}>Retirer</button></span></div>
|
||||
<div className="occurrence-fields"><label>Charge<select value={occurrence.load_mode}
|
||||
onChange={(event) => updateOccurrence(index, { ...occurrence,
|
||||
load_mode: event.target.value as PreparationOccurrence['load_mode'],
|
||||
target_weight_kg: event.target.value === 'none' ? null : occurrence.target_weight_kg })}>
|
||||
<option value="none">Aucune</option><option value="external">Externe</option>
|
||||
<option value="assistance">Assistance</option></select></label>
|
||||
{occurrence.recording_mode === 'sets' && <label>Séries<input inputMode="numeric"
|
||||
value={occurrence.target_sets ?? ''} onChange={(event) => updateOccurrence(index,
|
||||
{ ...occurrence, target_sets: parseDecimal(event.target.value) })} /></label>}
|
||||
{occurrence.tracking_mode === 'reps' ? <label>Répétitions<input inputMode="numeric"
|
||||
value={occurrence.target_reps ?? ''} onChange={(event) => updateOccurrence(index,
|
||||
{ ...occurrence, target_reps: parseDecimal(event.target.value) })} /></label> :
|
||||
<label>Durée (s)<input inputMode="numeric" value={occurrence.target_duration_seconds ?? ''}
|
||||
onChange={(event) => updateOccurrence(index,
|
||||
{ ...occurrence, target_duration_seconds: parseDecimal(event.target.value) })} /></label>}
|
||||
{occurrence.load_mode !== 'none' && <label>Charge (kg)<input inputMode="decimal"
|
||||
value={occurrence.target_weight_kg ?? ''} onChange={(event) => updateOccurrence(index,
|
||||
{ ...occurrence, target_weight_kg: parseDecimal(event.target.value) })} /></label>}
|
||||
<label>Repos (s)<input inputMode="numeric" value={occurrence.rest_seconds}
|
||||
onChange={(event) => updateOccurrence(index,
|
||||
{ ...occurrence, rest_seconds: parseDecimal(event.target.value) ?? 0 })} /></label></div>
|
||||
</li>)}</ol>
|
||||
{input.occurrences.length === 0 && <p className="empty-inline">Ajoutez des exercices pour rendre la préparation exécutable.</p>}
|
||||
<div className="editor-actions"><button type="button" disabled={saving}
|
||||
onClick={() => void submit(false)}>Enregistrer</button>
|
||||
<button type="button" className="primary-action" disabled={saving || !input.title || input.occurrences.length === 0}
|
||||
onClick={() => void submit(true)}>Marquer prête / Préparer pour Android</button></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
|
||||
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
|
||||
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => { const controller = new AbortController(); setFailed(false)
|
||||
fetchSessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
|
||||
return () => controller.abort() }, [kind, identity])
|
||||
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
||||
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
||||
return <article className="session-detail"><div className="sessions-toolbar">
|
||||
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
|
||||
{kind === 'preparation' && <button type="button" className="primary-action" onClick={() => onEdit(detail)}>Modifier</button>}</div>
|
||||
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
|
||||
<h2>{detail.title || 'Sans titre'}</h2><p>{detail.planned_for ?? 'Aucune date planifiée'} · {detail.state}</p></header>
|
||||
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}
|
||||
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
|
||||
{detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
|
||||
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
|
||||
<span>Prévu : {occurrence.target_sets ?? '—'} séries · {occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—'}
|
||||
{occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span>
|
||||
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
|
||||
</li>)}</ol></section>
|
||||
{detail.source_fingerprint && <section className="detail-tile"><h3>Provenance</h3>
|
||||
<code>{detail.source_fingerprint}</code></section>}
|
||||
</article>
|
||||
}
|
||||
|
||||
function ReadonlyDetail({ kind, identity, onBack }: {
|
||||
kind: 'draft' | 'history'; identity: string; onBack: () => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => { const controller = new AbortController(); setFailed(false)
|
||||
fetchReadonlySessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
|
||||
return () => controller.abort() }, [kind, identity])
|
||||
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
||||
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
||||
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
|
||||
const payload = typeof detail.payload === 'object' && detail.payload !== null
|
||||
? detail.payload as Record<string, unknown> : null
|
||||
return <article className="session-detail"><div className="sessions-toolbar">
|
||||
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
|
||||
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
|
||||
<h2>{String(detail.session_type ?? 'Séance')}</h2>
|
||||
<p>{String(detail.started_at ?? 'Début inconnu')} · {kind === 'history'
|
||||
? detail.ended_at ? `fin ${String(detail.ended_at)}` : 'heure de fin inconnue'
|
||||
: `à poursuivre sur Android · ${String(detail.state ?? '')}`}</p></header>
|
||||
{typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>}
|
||||
{kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences">
|
||||
{occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}>
|
||||
<strong>{String(occurrence.exercise_name ?? occurrence.exercise_id)}</strong>
|
||||
<span>Objectifs : {String(occurrence.target_sets ?? '—')} séries · {String(occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—')}</span>
|
||||
<span>Réalisé : {Array.isArray(occurrence.performed_sets) && occurrence.performed_sets.length > 0
|
||||
? occurrence.performed_sets.map((set) => { const values = set as Record<string, unknown>
|
||||
return `${String(values.reps ?? values.duration_seconds ?? '—')}${values.weight_kg ? ` × ${String(values.weight_kg)} kg` : ''}` }).join(' · ')
|
||||
: occurrence.max_weight_kg ? `MAX mesuré ${String(occurrence.max_weight_kg)} kg` : 'aucune mesure persistée'}</span>
|
||||
</li>)}</ol></section>}
|
||||
{kind === 'draft' && payload && <section className="detail-tile"><h3>État confirmé</h3>
|
||||
<pre className="draft-payload">{JSON.stringify(payload, null, 2)}</pre></section>}
|
||||
</article>
|
||||
}
|
||||
|
||||
export function SessionsPage() {
|
||||
const [view, setView] = useState<View>('preparation')
|
||||
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [pending, setPending] = useState(true)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
|
||||
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
|
||||
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
|
||||
const load = () => { const controller = new AbortController(); setPending(true); setFailed(false)
|
||||
Promise.all(collections[view].map((collection) => fetchSessionPage(collection, 0, search, controller.signal)
|
||||
.then((page) => page.items.map((item) => ({ ...item, collection })))))
|
||||
.then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false))
|
||||
return controller }
|
||||
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
|
||||
useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
|
||||
window.addEventListener('popstate', pop); return () => window.removeEventListener('popstate', pop) }, [])
|
||||
const open = (collection: SessionCollection, identity: string) => {
|
||||
const kind = collection === 'preparations' ? 'preparation' : collection === 'proposals' ? 'proposal'
|
||||
: collection === 'drafts' ? 'draft' : 'history'
|
||||
const path = `/seances/${kind}/${encodeURIComponent(identity)}`
|
||||
window.history.pushState(null, '', path); setDetailPath(path)
|
||||
}
|
||||
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
|
||||
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
|
||||
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
|
||||
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
|
||||
return <section className="page"><Detail kind={detailMatch[1]}
|
||||
identity={decodeURIComponent(detailMatch[2])} onBack={back} onEdit={setEditor} /></section>
|
||||
}
|
||||
if (detailMatch) return <section className="page"><ReadonlyDetail
|
||||
kind={detailMatch[1] as 'draft' | 'history'} identity={decodeURIComponent(detailMatch[2])}
|
||||
onBack={back} /></section>
|
||||
return <section className="page sessions-page" aria-labelledby="page-title"><div className="page-heading">
|
||||
<div><p className="eyebrow">ORGANISER</p><h1 id="page-title">Séances</h1></div>
|
||||
<p className="page-intro">Préparez sans confondre proposition, brouillon d’exécution et séance réalisée.</p></div>
|
||||
<div className="sessions-toolbar"><div className="session-tabs" role="tablist">
|
||||
{(['preparation', 'resume', 'history'] as const).map((candidate) => <button type="button" role="tab"
|
||||
aria-selected={view === candidate} key={candidate} onClick={() => setView(candidate)}>
|
||||
{candidate === 'preparation' ? 'Préparation' : candidate === 'resume' ? 'À reprendre' : 'Historique'}</button>)}</div>
|
||||
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
|
||||
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
|
||||
onChange={(event) => setSearch(event.target.value)} /></label>
|
||||
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
|
||||
{!pending && !failed && items.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
|
||||
<div className="session-list">{items.map((item) => <button type="button" className="session-row"
|
||||
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
|
||||
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
|
||||
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span>
|
||||
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span><span>{item.state}</span></button>)}</div>
|
||||
</section>
|
||||
}
|
||||
|
|
@ -58,6 +58,46 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
.sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
|
||||
.session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; }
|
||||
.session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; }
|
||||
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
|
||||
.quiet-action { border-color: var(--surface-2); }
|
||||
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
|
||||
.session-list { display: grid; gap: .6rem; }
|
||||
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
|
||||
.session-row:hover { border-color: var(--lavender); background: var(--base); }
|
||||
.session-row span:first-child { display: grid; gap: .2rem; }
|
||||
.session-row small { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
|
||||
.sessions-editor, .session-detail { padding: clamp(1rem, 3vw, 2.25rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
|
||||
.form-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; }
|
||||
.form-grid label, .catalog-picker label, .occurrence-fields label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.wide-field { grid-column: 1 / -1; }
|
||||
.form-grid textarea { min-height: 7rem; resize: vertical; }
|
||||
.catalog-picker { margin: 1.5rem 0; }
|
||||
.catalog-results { margin-top: .6rem; display: flex; gap: .45rem; flex-wrap: wrap; }
|
||||
.catalog-results button, .occurrence-actions button { padding: .45rem .65rem; border: 1px solid var(--surface-2); border-radius: .45rem; color: var(--text); background: var(--base); cursor: pointer; }
|
||||
.occurrence-editor-list, .detail-occurrences { padding: 0; display: grid; gap: .75rem; list-style: none; }
|
||||
.occurrence-editor-list > li, .detail-occurrences > li { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--base); }
|
||||
.occurrence-heading { display: flex; align-items: center; gap: .65rem; }
|
||||
.drag-handle { color: var(--overlay-1); }
|
||||
.occurrence-actions { margin-left: auto; display: flex; gap: .35rem; }
|
||||
.occurrence-fields { margin-top: 1rem; display: grid; grid-template-columns: repeat(5, minmax(7rem, 1fr)); gap: .75rem; }
|
||||
.editor-actions { margin-top: 1.5rem; display: flex; justify-content: flex-end; gap: .75rem; }
|
||||
.editor-actions button { border-color: var(--surface-2); }
|
||||
.editor-actions button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); }
|
||||
.error-panel, .form-error { border: 1px solid var(--red); color: var(--red); }
|
||||
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
|
||||
.detail-occurrences li { display: grid; gap: .35rem; }
|
||||
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
||||
.detail-tile code { overflow-wrap: anywhere; color: var(--lavender); }
|
||||
.draft-payload { max-height: 32rem; overflow: auto; color: var(--subtext-0); font: var(--font-size-sm)/1.6 var(--font-mono); white-space: pre-wrap; }
|
||||
@media (max-width: 52rem) { .session-row { grid-template-columns: 1fr 1fr; } .form-grid { grid-template-columns: 1fr 1fr; } .occurrence-fields { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 30rem) { .session-row, .form-grid, .occurrence-fields { grid-template-columns: 1fr; } .session-tabs { width: 100%; overflow-x: auto; } .session-tabs button { white-space: nowrap; } .editor-actions { align-items: stretch; flex-direction: column; } }
|
||||
|
||||
.dashboard-layout { min-width: 0; }
|
||||
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
|
||||
.layout-action { min-height: 2.45rem; padding: .58rem .82rem; color: var(--subtext-0); background: var(--surface-0); border: 1px solid rgb(108 112 134 / 48%); border-radius: .55rem; font: 650 var(--font-size-xs) var(--font-mono); cursor: pointer; transition: color 140ms ease, border-color 140ms ease, background 140ms ease; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue