Complete Web Sessions pagination and browser flow

This commit is contained in:
fy59 2026-09-18 05:10:50 +02:00
parent e0a0397d2a
commit bbcb487491
12 changed files with 573 additions and 212 deletions

View file

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Exercise Web Sessions through embedded production assets in real Firefox."""
import os
import signal
import socket
import sqlite3
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / os.environ.get("TRAINLOG_WEB_E2E_BUILD", "build") / "tui"
GECKO = Path(
os.environ.get(
"TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"
)
)
EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
class BrowserSessionsTest(unittest.TestCase):
def test_create_reload_detail_and_mobile_editor(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory:
root = Path(directory)
xdg = root / "data"
xdg.mkdir()
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
environment = os.environ.copy()
environment.update(
{
"XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(root / "config"),
"XDG_CACHE_HOME": str(root / "cache"),
}
)
server = subprocess.Popen(
[str(BUILD / "trainlog"), "--web", "--port", str(port)],
env=environment,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
driver = None
try:
for _ in range(200):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.025)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
wait = WebDriverWait(driver, 15)
driver.set_window_size(1440, 1000)
driver.get(f"http://127.0.0.1:{port}/seances")
wait.until(lambda current: "Aucun résultat" in current.page_source)
EVIDENCE.mkdir(parents=True, exist_ok=True)
driver.save_screenshot(str(EVIDENCE / "web-sessions-list-desktop.png"))
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
wait.until(lambda current: "Nouvelle séance" in current.page_source)
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
title.send_keys("Séance navigateur éphémère")
driver.set_window_size(390, 844)
driver.save_screenshot(str(EVIDENCE / "web-sessions-editor-mobile.png"))
save = driver.find_element(By.XPATH, "//button[normalize-space()='Enregistrer']")
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", save)
save.click()
wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source)
driver.refresh()
wait.until(lambda current: "Séance navigateur éphémère" in current.page_source)
driver.set_window_size(1440, 1000)
driver.save_screenshot(str(EVIDENCE / "web-sessions-detail-desktop.png"))
database_path = xdg / "trainlog" / "trainlog.db"
with sqlite3.connect(database_path) as database:
row = database.execute(
"SELECT r.title, p.editing_state "
"FROM session_preparations p "
"JOIN session_preparation_revisions r "
"ON r.revision_id = p.current_revision_id"
).fetchone()
entry_count = database.execute(
"SELECT COUNT(*) FROM session_preparation_entries"
).fetchone()[0]
self.assertEqual(row, ("Séance navigateur éphémère", "draft"))
self.assertEqual(entry_count, 0)
finally:
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
if __name__ == "__main__":
unittest.main()

View file

@ -1,7 +1,8 @@
/*
* Trainlog id interface.
*
* Declares the module boundary and ownership contract; implementation and persistence remain in their owning modules.
* Declares the module boundary and ownership contract; implementation and persistence remain in
* their owning modules.
*/
#ifndef TRAINLOG_ID_H
#define TRAINLOG_ID_H
@ -25,8 +26,7 @@
* CONTRACT: occurrence identities use the longer `sxe` prefix while the
* frozen v1 object identities retain their existing two-character prefixes.
*/
#define TRAINLOG_GENERATED_ID_CAPACITY \
(3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
#define TRAINLOG_GENERATED_ID_CAPACITY (3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
/**
* @brief Generate an official Trainlog identifier using UUID version 4.
@ -56,10 +56,6 @@
* Existing two-character prefixes still require 40 bytes; `sxe`
* requires TRAINLOG_GENERATED_ID_CAPACITY bytes.
*/
TrainlogStatus trainlog_id_generate(
const char *prefix,
char *output,
size_t output_size
);
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size);
#endif

View file

@ -11,30 +11,18 @@
#include <uuid/uuid.h>
static bool prefix_is_supported(const char *prefix)
{
static bool prefix_is_supported(const char *prefix) {
/*
* Keep official creator prefixes deliberately small and explicit.
* The exchange parser remains more permissive because imported v1 IDs are
* opaque values governed by the frozen wire-schema syntax.
*/
return strcmp(prefix, "ex") == 0 ||
strcmp(prefix, "se") == 0 ||
strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 ||
strcmp(prefix, "sxe") == 0 ||
strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 ||
strcmp(prefix, "spe") == 0 ||
strcmp(prefix, "spd") == 0;
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 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(
const char *prefix,
char *output,
size_t output_size
)
{
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
uuid_t value;
char uuid_text[TRAINLOG_UUID_TEXT_LENGTH + 1U];
int written;
@ -55,16 +43,9 @@ TrainlogStatus trainlog_id_generate(
uuid_generate_random(value);
uuid_unparse_lower(value, uuid_text);
written = snprintf(
output,
output_size,
"%s_%s",
prefix,
uuid_text
);
written = snprintf(output, output_size, "%s_%s", prefix, uuid_text);
if (written < 0 ||
(size_t)written >= output_size) {
if (written < 0 || (size_t)written >= output_size) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}

View file

@ -429,6 +429,12 @@ static bool is_ui_route(const char *url) {
return true;
}
}
/* CONTRACT: Sessions detail identities are client-side path segments. A
* direct load must receive the SPA, while the /api guard below remains
* authoritative for every adapter route. */
if (strncmp(url, "/seances/", strlen("/seances/")) == 0) {
return true;
}
return false;
}
@ -515,7 +521,8 @@ 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) {
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;
@ -792,21 +799,19 @@ static enum MHD_Result handle_request(void *closure,
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");
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 *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];
@ -825,10 +830,8 @@ static enum MHD_Result handle_request(void *closure,
"POST, PUT");
}
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
return queue_json(connection,
MHD_HTTP_FORBIDDEN,
"{\"error\":\"mutation_forbidden\"}\n",
NULL);
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) {
@ -869,8 +872,7 @@ static enum MHD_Result handle_request(void *closure,
NULL);
}
length = strlen(if_match);
if (length < 3U || length - 2U > TRAINLOG_ID_MAX ||
if_match[length - 1U] != '"') {
if (length < 3U || length - 2U > TRAINLOG_ID_MAX || if_match[length - 1U] != '"') {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_revision\"}\n",
@ -881,12 +883,8 @@ static enum MHD_Result handle_request(void *closure,
expected = expected_buffer;
}
if (delivery) {
status = trainlog_web_sessions_deliver_json(context->database,
identity,
expected,
request_id,
&json,
&json_size);
status = trainlog_web_sessions_deliver_json(
context->database, identity, expected, request_id, &json, &json_size);
} else {
status = trainlog_web_sessions_save_json(context->database,
identity,
@ -926,8 +924,7 @@ static enum MHD_Result handle_request(void *closure,
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) ||
!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);
@ -941,8 +938,8 @@ static enum MHD_Result handle_request(void *closure,
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);
TrainlogStatus status =
trainlog_web_sessions_list_json(context->database, &query, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
}
@ -958,11 +955,8 @@ static enum MHD_Result handle_request(void *closure,
(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);
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);
}
}

View file

@ -20,24 +20,41 @@ typedef struct 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 "
"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"},
"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 "
"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"},
"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"},
"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 "
"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)) "
"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"},
};
@ -56,9 +73,8 @@ static bool add_nullable_text(yyjson_mut_doc *document,
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) {
static TrainlogStatus
write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size) {
yyjson_write_err error;
char *json;
@ -100,8 +116,9 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
}
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_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;
@ -178,11 +195,31 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
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";
? "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;
@ -197,8 +234,14 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
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"};
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;
@ -218,8 +261,13 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
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",
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;
@ -236,17 +284,23 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
}
}
for (index = 8; index <= 12; ++index) {
static const char *const number_keys[] = {"target_sets", "target_reps",
"target_duration_seconds", "target_weight_kg",
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));
(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));
(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) ||
@ -265,12 +319,19 @@ 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 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";
"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";
"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;
@ -311,15 +372,25 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
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",
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"};
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))) {
!yyjson_mut_obj_add_sint(
document, entry, "position", sqlite3_column_int64(statement, 1))) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
@ -332,23 +403,25 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
}
for (column = 8; column <= 14; ++column) {
if (column == 13) {
(void)add_nullable_text(document, entry, numeric_keys[column - 8], statement, column);
(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));
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));
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_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) {
@ -359,7 +432,8 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
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))) {
!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;
@ -370,14 +444,14 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
(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));
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));
set,
keys[column - 1],
sqlite3_column_int64(sets_statement, column));
}
}
(void)yyjson_mut_arr_add_val(sets, set);
@ -404,9 +478,13 @@ static TrainlogStatus detail_for_draft(TrainlogDatabase *database,
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 ||
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) {
@ -568,9 +646,8 @@ static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
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) {
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;
@ -611,22 +688,37 @@ static bool bind_nullable_text(sqlite3_stmt *statement, int index, yyjson_val *v
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;
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 *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)";
"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;
@ -638,8 +730,8 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
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, "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)) {
@ -684,14 +776,16 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
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 ||
} 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 ||
} 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;
}
@ -725,9 +819,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
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",
"source_proposal_id", "source_payload_sha256"};
static const char *const ALLOWED[] = {"title",
"session_type",
"planned_for",
"notes",
"editing_state",
"occurrences",
"source_proposal_id",
"source_payload_sha256"};
yyjson_doc *input = NULL;
yyjson_val *root;
yyjson_val *occurrences;
@ -746,9 +845,9 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
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)) {
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;
@ -767,17 +866,16 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
}
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))) {
(strcmp(editing_state, "ready") == 0 &&
(title[0] == '\0' || yyjson_arr_size(occurrences) == 0U))) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (yyjson_obj_get(root, "source_proposal_id") != NULL &&
(!bounded_json_string(yyjson_obj_get(root, "source_proposal_id"),
TRAINLOG_ID_MAX + 1U,
&source_proposal) ||
!bounded_json_string(yyjson_obj_get(root, "source_payload_sha256"),
65U,
&source_fingerprint) ||
(!bounded_json_string(
yyjson_obj_get(root, "source_proposal_id"), TRAINLOG_ID_MAX + 1U, &source_proposal) ||
!bounded_json_string(
yyjson_obj_get(root, "source_payload_sha256"), 65U, &source_fingerprint) ||
strlen(source_fingerprint) != 64U)) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
@ -788,7 +886,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
}
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) {
trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) !=
TRAINLOG_STATUS_OK) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
@ -801,9 +900,12 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
}
/* 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 &&
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);
@ -819,12 +921,19 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
statement = NULL;
}
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) {
if (sqlite3_prepare_v2(database->connection,
"UPDATE session_preparations SET source_proposal_id=?1,source_payload_sha256=?2 WHERE preparation_id=?3 AND EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=?1 AND i.payload_sha256=?2)",
-1, &statement, NULL) != SQLITE_OK ||
if (sqlite3_prepare_v2(
database->connection,
"UPDATE session_preparations SET source_proposal_id=?1,source_payload_sha256=?2 "
"WHERE preparation_id=?3 AND EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE "
"i.draft_id=?1 AND i.payload_sha256=?2)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
@ -834,10 +943,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
}
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 ||
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;
@ -849,10 +962,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
}
(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 ||
} 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 ||
@ -864,13 +981,18 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
(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 ||
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 ||
(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")) ||
@ -883,20 +1005,26 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
(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);
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 ||
"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, 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;
@ -919,8 +1047,10 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
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 ||
"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 ||
@ -974,8 +1104,11 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_prepare_v2(database->connection,
"SELECT response_json FROM session_preparation_requests WHERE request_id=?1 AND command='deliver'",
-1, &statement, NULL) == SQLITE_OK &&
"SELECT response_json FROM session_preparation_requests WHERE "
"request_id=?1 AND command='deliver'",
-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);
@ -989,9 +1122,14 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND p.current_revision_id=?2 AND p.editing_state='ready' AND EXISTS(SELECT 1 FROM session_preparation_entries e WHERE e.revision_id=p.current_revision_id)",
-1, &statement, NULL) != SQLITE_OK ||
if (sqlite3_prepare_v2(
database->connection,
"SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND "
"p.current_revision_id=?2 AND p.editing_state='ready' AND EXISTS(SELECT 1 FROM "
"session_preparation_entries e WHERE e.revision_id=p.current_revision_id)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) {
@ -1000,9 +1138,12 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
}
(void)sqlite3_finalize(statement);
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparation_deliveries VALUES(?1,?2,?3,?4,'pending',?5,NULL,NULL)",
-1, &statement, NULL) != SQLITE_OK ||
if (sqlite3_prepare_v2(
database->connection,
"INSERT INTO session_preparation_deliveries VALUES(?1,?2,?3,?4,'pending',?5,NULL,NULL)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, delivery_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
@ -1027,9 +1168,12 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
status = write_document(response, output_json, output_size);
response = NULL;
if (status != TRAINLOG_STATUS_OK ||
sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparation_requests VALUES(?1,'deliver',?2,?3,?4,?5)",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_prepare_v2(
database->connection,
"INSERT INTO session_preparation_requests VALUES(?1,'deliver',?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, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
@ -1041,8 +1185,11 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
}
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"UPDATE session_preparations SET delivery_state='pending' WHERE preparation_id=?1 AND current_revision_id=?2",
-1, &statement, NULL) != SQLITE_OK ||
"UPDATE session_preparations SET delivery_state='pending' WHERE "
"preparation_id=?1 AND current_revision_id=?2",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 ||

View file

@ -382,6 +382,13 @@ static bool test_http_contract(TrainlogDatabase *database) {
exchange(
port, "GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, sizeof(response)) &&
strstr(response, "<title>Trainlog</title>") != NULL);
CHECK(exchange(port,
"GET /seances/preparation/sp_11111111-1111-4111-8111-111111111111 "
"HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "<title>Trainlog</title>") != NULL);
CHECK(exchange(port,
"GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,

View file

@ -9,12 +9,12 @@
#include "trainlog/database.h"
#include "trainlog/web_sessions.h"
#define CHECK(expression) \
do { \
if (!(expression)) { \
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return false; \
} \
return false; \
} \
} while (0)
static int scalar(TrainlogDatabase *database, const char *sql) {
@ -45,8 +45,7 @@ static bool creation_replay_revision_and_pagination(void) {
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
"\"occurrences\":[]}";
TrainlogDatabase *database = NULL;
TrainlogWebSessionsPageQuery query = {
TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
char *created = NULL;
char *replayed = NULL;
char *detail = NULL;

View file

@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { fetchAllSessionPages } from './sessions'
function item(identity: string) {
return {
identity,
title: identity,
date: '2026-09-17',
state: 'draft',
occurrence_count: 1,
sort_timestamp: '2026-09-17T12:00:00Z',
}
}
describe('Sessions pagination', () => {
it('loads every page without losing an item at the boundary', async () => {
const first = Array.from({ length: 24 }, (_, index) => item(`sp_${index}`))
const second = Array.from({ length: 17 }, (_, index) => item(`sp_${index + 24}`))
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({
api_version: 1, kind: 'preparations', offset: 0, more: true, next_offset: 24, items: first,
}) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({
api_version: 1, kind: 'preparations', offset: 24, more: false, next_offset: 41, items: second,
}) }))
const result = await fetchAllSessionPages('preparations')
expect(result).toHaveLength(41)
expect(result.at(-1)?.identity).toBe('sp_40')
expect(fetch).toHaveBeenCalledTimes(2)
})
it('rejects a stale or looping page instead of merging it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({
api_version: 1, kind: 'preparations', offset: 0, more: true, next_offset: 0, items: [item('sp_one')],
}) }))
await expect(fetchAllSessionPages('preparations')).rejects.toThrow('curseur')
})
})

View file

@ -100,6 +100,31 @@ export async function fetchSessionPage(
return value as unknown as SessionPage
}
export async function fetchAllSessionPages(
collection: SessionCollection,
search = '',
signal?: AbortSignal,
): Promise<SessionListItem[]> {
const items: SessionListItem[] = []
const identities = new Set<string>()
let offset = 0
for (;;) {
const page = await fetchSessionPage(collection, offset, search, signal)
if (page.offset !== offset) throw new TypeError('curseur de séances obsolète')
for (const item of page.items) {
if (identities.has(item.identity)) throw new TypeError('pagination de séances incohérente')
identities.add(item.identity)
items.push(item)
}
if (!page.more) return items
if (page.next_offset <= offset || page.items.length === 0) {
throw new TypeError('curseur de séances obsolète')
}
offset = page.next_offset
}
}
export async function fetchSessionDetail(
kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal,
): Promise<SessionDetail> {

View file

@ -0,0 +1,42 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SessionsPage } from './SessionsPage'
function page(kind: string, items: unknown[]) {
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
}
function response(value: unknown) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(value) })
}
describe('Sessions page', () => {
it('keeps proposals distinct and opens a stable deep link', async () => {
window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/preparations?')) return response(page('preparations', []))
if (url.includes('/proposals?')) return response(page('proposals', [{
identity: 'aid_target', title: 'Proposition cible', date: '2026-09-17', state: 'published',
occurrence_count: 6, sort_timestamp: '2026-09-17T12:00:00Z',
}]))
return response({ api_version: 1, kind: 'proposal', identity: 'aid_target', revision_id: 'rev',
title: 'Proposition cible', planned_for: null, session_type: 'training', notes: null,
source_fingerprint: 'abc', state: 'published', occurrences: [] })
}))
render(<SessionsPage />)
await waitFor(() => expect(screen.getByText('Proposition cible')).toBeInTheDocument())
expect(screen.getByText('Proposition IA')).toBeInTheDocument()
fireEvent.click(screen.getByText('Proposition cible'))
expect(window.location.pathname).toBe('/seances/proposal/aid_target')
await waitFor(() => expect(screen.getByRole('heading', { name: 'Proposition cible' })).toBeInTheDocument())
})
it('reports an API failure instead of presenting an empty list', async () => {
window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network')))
render(<SessionsPage />)
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles'))
expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument()
})
})

View file

@ -4,7 +4,7 @@ import {
fetchSessionDetail,
fetchReadonlySessionDetail,
prepareForAndroid,
fetchSessionPage,
fetchAllSessionPages,
savePreparation,
type CatalogChoice,
type PreparationInput,
@ -214,14 +214,17 @@ export function SessionsPage() {
const [view, setView] = useState<View>('preparation')
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => { const controller = new AbortController(); setPending(true); setFailed(false)
Promise.all(collections[view].map((collection) => fetchSessionPage(collection, 0, search, controller.signal)
.then((page) => page.items.map((item) => ({ ...item, collection })))))
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
.then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
.then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false))
return controller }
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
@ -234,6 +237,13 @@ export function SessionsPage() {
window.history.pushState(null, '', path); setDetailPath(path)
}
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
const visibleItems = useMemo(() => items.filter((item) => {
if (stateFilter && item.state !== stateFilter) return false
if (dateFrom && (!item.date || item.date < dateFrom)) return false
if (dateTo && (!item.date || item.date > dateTo)) return false
return true
}), [items, stateFilter, dateFrom, dateTo])
const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items])
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
@ -253,9 +263,16 @@ export function SessionsPage() {
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances">
<label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}>
<option value="">Tous</option>{states.map((state) => <option value={state} key={state}>{state}</option>)}
</select></label>
<label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label>
<label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label>
</div>
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
{!pending && !failed && items.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{items.map((item) => <button type="button" className="session-row"
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span>

View file

@ -65,6 +65,9 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
.quiet-action { border-color: var(--surface-2); }
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; }
.session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); }
.session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
.session-list { display: grid; gap: .6rem; }
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }