diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py index 2caa50f..8e98975 100644 --- a/tests/test_sync_generation_exchange.py +++ b/tests/test_sync_generation_exchange.py @@ -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): diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index ac817fa..3622ecc 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -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): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 497d7a3..dcd8599 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -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): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 4f82b37..79da5c5 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -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", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index 04a8157..54314ef 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -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: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index ac8d2b4..c240155 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -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( diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 630f19a..069b350 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -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") diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 009ce61..18aaedb 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -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" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 6a092d9..9654278 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -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") diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index ef52064..0a076d6 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -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, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index c1d39d8..ef0b24c 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -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}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 2e36164..94f15ab 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -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(): diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index f16c0d5..6ee1ad1 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -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() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 863260a..8cddd3c 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -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") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 614ee33..f98ed7d 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -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: diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index d9be1f3..5ed4248 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -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") diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index f672781..e2615ab 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -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() diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index bda9ed9..b066fd3 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -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 diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index a70db81..73a5162 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -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"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index e2efbb4..879645a 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -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")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index cacf45e..63939a3 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -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"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index 12bc4d5..94041b6 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -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", diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 33285da..c9625ff 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -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: diff --git a/tui/include/trainlog/id.h b/tui/include/trainlog/id.h index 71953d1..1ad9dd9 100644 --- a/tui/include/trainlog/id.h +++ b/tui/include/trainlog/id.h @@ -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. diff --git a/tui/include/trainlog/web_sessions.h b/tui/include/trainlog/web_sessions.h new file mode 100644 index 0000000..0126e76 --- /dev/null +++ b/tui/include/trainlog/web_sessions.h @@ -0,0 +1,68 @@ +/* Trainlog Web Sessions V1 application service. */ +#ifndef TRAINLOG_WEB_SESSIONS_H +#define TRAINLOG_WEB_SESSIONS_H + +#include + +#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 diff --git a/tui/meson.build b/tui/meson.build index cdcde4f..f6ae5f2 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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', diff --git a/tui/src/database.c b/tui/src/database.c index f093269..39c1135 100644 --- a/tui/src/database.c +++ b/tui/src/database.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) { diff --git a/tui/src/id.c b/tui/src/id.c index 7eb2c9a..230a097 100644 --- a/tui/src/id.c +++ b/tui/src/id.c @@ -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( diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 710a972..3dec20c 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -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; diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c new file mode 100644 index 0000000..ddc8b7e --- /dev/null +++ b/tui/src/web_sessions.c @@ -0,0 +1,916 @@ +#include "trainlog/web_sessions.h" + +#include +#include +#include +#include +#include + +#include +#include + +#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; +} diff --git a/tui/tests/test_schema_v21_migration.c b/tui/tests/test_schema_v21_migration.c index fb088a4..20174c5 100644 --- a/tui/tests/test_schema_v21_migration.c +++ b/tui/tests/test_schema_v21_migration.c @@ -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); diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 27d1bb4..3677776 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -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; diff --git a/tui/tests/test_web_sessions.c b/tui/tests/test_web_sessions.c new file mode 100644 index 0000000..5d43fa0 --- /dev/null +++ b/tui/tests/test_web_sessions.c @@ -0,0 +1,149 @@ +#include +#include +#include +#include + +#include + +#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; +} diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts new file mode 100644 index 0000000..44c4e53 --- /dev/null +++ b/web/src/api/sessions.ts @@ -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 { + 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 { + 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 { + 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> { + 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 { + 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 } +} diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts index 0e0e7ad..e8a0f12 100644 --- a/web/src/api/sync.ts +++ b/web/src/api/sync.ts @@ -40,6 +40,12 @@ export interface SyncStatus { } let csrfToken = '' + +export async function mutationCsrfToken(): Promise { + if (csrfToken.length !== 64) await fetchSyncStatus() + if (csrfToken.length !== 64) throw new Error('jeton CSRF absent') + return csrfToken +} const phases = new Set([ 'idle', 'requested', diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 9d14a13..1aa050f 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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' ? : diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts index 741ed9d..890b39c 100644 --- a/web/src/app/routes.ts +++ b/web/src/app/routes.ts @@ -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] } diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx new file mode 100644 index 0000000..02786b6 --- /dev/null +++ b/web/src/routes/SessionsPage.tsx @@ -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 = { + 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(() => 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([]) + 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
+

PRÉPARATION

+

{initial ? 'Modifier la séance' : 'Nouvelle séance'}

+
+ {error &&

{error}

} +
+ + + +