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. * 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 #ifndef TRAINLOG_ID_H
#define TRAINLOG_ID_H #define TRAINLOG_ID_H
@ -25,8 +26,7 @@
* CONTRACT: occurrence identities use the longer `sxe` prefix while the * CONTRACT: occurrence identities use the longer `sxe` prefix while the
* frozen v1 object identities retain their existing two-character prefixes. * frozen v1 object identities retain their existing two-character prefixes.
*/ */
#define TRAINLOG_GENERATED_ID_CAPACITY \ #define TRAINLOG_GENERATED_ID_CAPACITY (3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
(3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
/** /**
* @brief Generate an official Trainlog identifier using UUID version 4. * @brief Generate an official Trainlog identifier using UUID version 4.
@ -56,10 +56,6 @@
* Existing two-character prefixes still require 40 bytes; `sxe` * Existing two-character prefixes still require 40 bytes; `sxe`
* requires TRAINLOG_GENERATED_ID_CAPACITY bytes. * requires TRAINLOG_GENERATED_ID_CAPACITY bytes.
*/ */
TrainlogStatus trainlog_id_generate( TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size);
const char *prefix,
char *output,
size_t output_size
);
#endif #endif

View file

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

View file

@ -429,6 +429,12 @@ static bool is_ui_route(const char *url) {
return true; 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; return false;
} }
@ -515,7 +521,8 @@ static bool parse_if_match(const char *value, uint64_t *revision) {
return true; 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; char *end = NULL;
unsigned long long parsed; unsigned long long parsed;
@ -792,21 +799,19 @@ static enum MHD_Result handle_request(void *closure,
size_t limit; size_t limit;
size_t index; size_t index;
if (!is_get && if (!is_get && (strcmp(url, "/api/v1/sessions/preparations") == 0 ||
(strcmp(url, "/api/v1/sessions/preparations") == 0 || strncmp(url,
strncmp(url, "/api/v1/sessions/preparation/",
"/api/v1/sessions/preparation/", strlen("/api/v1/sessions/preparation/")) == 0)) {
strlen("/api/v1/sessions/preparation/")) == 0)) { const char *origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
const char *origin = const char *csrf =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin"); MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
const char *csrf = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
const char *content_type = MHD_lookup_connection_value( const char *content_type = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE); connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
const char *request_id = MHD_lookup_connection_value( const char *request_id =
connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID"); MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *if_match = MHD_lookup_connection_value( const char *if_match =
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH); MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
const char *identity = NULL; const char *identity = NULL;
const char *expected = ""; const char *expected = "";
char expected_buffer[TRAINLOG_ID_MAX + 1U]; char expected_buffer[TRAINLOG_ID_MAX + 1U];
@ -825,10 +830,8 @@ static enum MHD_Result handle_request(void *closure,
"POST, PUT"); "POST, PUT");
} }
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) { if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
return queue_json(connection, return queue_json(
MHD_HTTP_FORBIDDEN, connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
"{\"error\":\"mutation_forbidden\"}\n",
NULL);
} }
if (content_type == NULL || strcmp(content_type, "application/json") != 0 || if (content_type == NULL || strcmp(content_type, "application/json") != 0 ||
request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) { request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
@ -869,8 +872,7 @@ static enum MHD_Result handle_request(void *closure,
NULL); NULL);
} }
length = strlen(if_match); length = strlen(if_match);
if (length < 3U || length - 2U > TRAINLOG_ID_MAX || if (length < 3U || length - 2U > TRAINLOG_ID_MAX || if_match[length - 1U] != '"') {
if_match[length - 1U] != '"') {
return queue_json(connection, return queue_json(connection,
MHD_HTTP_BAD_REQUEST, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_revision\"}\n", "{\"error\":\"invalid_revision\"}\n",
@ -881,12 +883,8 @@ static enum MHD_Result handle_request(void *closure,
expected = expected_buffer; expected = expected_buffer;
} }
if (delivery) { if (delivery) {
status = trainlog_web_sessions_deliver_json(context->database, status = trainlog_web_sessions_deliver_json(
identity, context->database, identity, expected, request_id, &json, &json_size);
expected,
request_id,
&json,
&json_size);
} else { } else {
status = trainlog_web_sessions_save_json(context->database, status = trainlog_web_sessions_save_json(context->database,
identity, 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"); limit_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit");
search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search"); search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
if (!parse_page_number(offset_value, 0U, 1000000U, &offset) || if (!parse_page_number(offset_value, 0U, 1000000U, &offset) ||
!parse_page_number( !parse_page_number(limit_value, 24U, TRAINLOG_WEB_SESSIONS_PAGE_MAX, &limit) ||
limit_value, 24U, TRAINLOG_WEB_SESSIONS_PAGE_MAX, &limit) ||
limit == 0U) { limit == 0U) {
return queue_json( return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL); 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) { if (strcmp(url, collections[index].path) == 0) {
TrainlogWebSessionsPageQuery query = { TrainlogWebSessionsPageQuery query = {
collections[index].collection, offset, limit, search}; collections[index].collection, offset, limit, search};
TrainlogStatus status = trainlog_web_sessions_list_json( TrainlogStatus status =
context->database, &query, &json, &json_size); trainlog_web_sessions_list_json(context->database, &query, &json, &json_size);
return queue_owned_sessions_json(connection, status, 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)); (void)memcpy(kind_buffer, kind, (size_t)(separator - kind));
kind_buffer[separator - kind] = '\0'; kind_buffer[separator - kind] = '\0';
status = trainlog_web_sessions_detail_json(context->database, status = trainlog_web_sessions_detail_json(
kind_buffer, context->database, kind_buffer, separator + 1, &json, &json_size);
separator + 1,
&json,
&json_size);
return queue_owned_sessions_json(connection, status, 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[] = { static const ListDefinition LISTS[] = {
{"preparation", {"preparation",
"SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at " "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 " "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)) " "WHERE (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"GROUP BY p.preparation_id ORDER BY COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"}, "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", {"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 " "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)) " "WHERE (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),d.created_at,d.draft_id LIMIT ?2 OFFSET ?3"}, "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", {"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,'') " "SELECT "
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE f.session_id=d.session_id) " "d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d."
"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) " "payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
"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"}, "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", {"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 " "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"}, "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)); document, object, key, (const char *)sqlite3_column_text(statement, column));
} }
static TrainlogStatus write_document(yyjson_mut_doc *document, static TrainlogStatus
char **output_json, write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size) {
size_t *output_size) {
yyjson_write_err error; yyjson_write_err error;
char *json; 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) != if (sqlite3_prepare_v2(database->connection, definition->sql, -1, &statement, NULL) !=
SQLITE_OK || SQLITE_OK ||
sqlite3_bind_text(statement, 1, query->search == NULL ? "" : query->search, -1, sqlite3_bind_text(
SQLITE_TRANSIENT) != SQLITE_OK || 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, 2, (sqlite3_int64)(query->limit + 1U)) != SQLITE_OK ||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)query->offset) != SQLITE_OK) { sqlite3_bind_int64(statement, 3, (sqlite3_int64)query->offset) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR; status = TRAINLOG_STATUS_DATABASE_ERROR;
@ -178,11 +195,31 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
yyjson_mut_doc *document, yyjson_mut_doc *document,
yyjson_mut_val *root) { yyjson_mut_val *root) {
const char *sql = proposal 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 "
: "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"; "d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,"
const char *entry_sql = proposal "i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN "
? "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" "'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN "
: "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"; "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; sqlite3_stmt *statement = NULL;
yyjson_mut_val *entries; yyjson_mut_val *entries;
int step; int step;
@ -197,8 +234,14 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
return TRAINLOG_STATUS_NOT_FOUND; return TRAINLOG_STATUS_NOT_FOUND;
} }
for (column = 0; column < 8; ++column) { for (column = 0; column < 8; ++column) {
static const char *const keys[] = {"identity", "title", "planned_for", "session_type", static const char *const keys[] = {"identity",
"notes", "source_fingerprint", "revision_id", "state"}; "title",
"planned_for",
"session_type",
"notes",
"source_fingerprint",
"revision_id",
"state"};
if (!add_nullable_text(document, root, keys[column], statement, column)) { if (!add_nullable_text(document, root, keys[column], statement, column)) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
@ -218,8 +261,13 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
while ((step = sqlite3_step(statement)) == SQLITE_ROW) { while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name", static const char *const text_keys[] = {"entry_id",
"equipment_id", "recording_mode", "tracking_mode", NULL,
"exercise_id",
"exercise_name",
"equipment_id",
"recording_mode",
"tracking_mode",
"load_mode"}; "load_mode"};
yyjson_mut_val *entry = yyjson_mut_obj(document); yyjson_mut_val *entry = yyjson_mut_obj(document);
int index; int index;
@ -236,17 +284,23 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
} }
} }
for (index = 8; index <= 12; ++index) { for (index = 8; index <= 12; ++index) {
static const char *const number_keys[] = {"target_sets", "target_reps", static const char *const number_keys[] = {"target_sets",
"target_duration_seconds", "target_weight_kg", "target_reps",
"target_duration_seconds",
"target_weight_kg",
"rest_seconds"}; "rest_seconds"};
if (sqlite3_column_type(statement, index) == SQLITE_NULL) { if (sqlite3_column_type(statement, index) == SQLITE_NULL) {
(void)yyjson_mut_obj_add_null(document, entry, number_keys[index - 8]); (void)yyjson_mut_obj_add_null(document, entry, number_keys[index - 8]);
} else if (index == 11) { } else if (index == 11) {
(void)yyjson_mut_obj_add_real(document, entry, number_keys[index - 8], (void)yyjson_mut_obj_add_real(document,
sqlite3_column_double(statement, index)); entry,
number_keys[index - 8],
sqlite3_column_double(statement, index));
} else { } else {
(void)yyjson_mut_obj_add_sint(document, entry, number_keys[index - 8], (void)yyjson_mut_obj_add_sint(document,
sqlite3_column_int64(statement, index)); entry,
number_keys[index - 8],
sqlite3_column_int64(statement, index));
} }
} }
if (!add_nullable_text(document, entry, "notes", statement, 13) || if (!add_nullable_text(document, entry, "notes", statement, 13) ||
@ -265,12 +319,19 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
const char *identity, const char *identity,
yyjson_mut_doc *document, yyjson_mut_doc *document,
yyjson_mut_val *root) { yyjson_mut_val *root) {
static const char SESSION_SQL[] = static const char SESSION_SQL[] = "SELECT session_id,session_type,started_at,ended_at,notes "
"SELECT session_id,session_type,started_at,ended_at,notes FROM sessions WHERE session_id=?1"; "FROM sessions WHERE session_id=?1";
static const char ENTRY_SQL[] = 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[] = 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 *statement = NULL;
sqlite3_stmt *sets_statement = NULL; sqlite3_stmt *sets_statement = NULL;
yyjson_mut_val *entries; 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); const char *entry_id = (const char *)sqlite3_column_text(statement, 0);
int set_step; int set_step;
int column; int column;
static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name", static const char *const text_keys[] = {"entry_id",
"equipment_id", "recording_mode", "tracking_mode", NULL,
"exercise_id",
"exercise_name",
"equipment_id",
"recording_mode",
"tracking_mode",
"load_mode"}; "load_mode"};
static const char *const numeric_keys[] = {"target_sets", "target_reps", static const char *const numeric_keys[] = {"target_sets",
"target_duration_seconds", "target_weight_kg", "target_reps",
"rest_seconds", "notes", "max_weight_kg"}; "target_duration_seconds",
"target_weight_kg",
"rest_seconds",
"notes",
"max_weight_kg"};
if (entry == NULL || sets == NULL || 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); (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_SYSTEM_ERROR; return TRAINLOG_STATUS_SYSTEM_ERROR;
} }
@ -332,23 +403,25 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
} }
for (column = 8; column <= 14; ++column) { for (column = 8; column <= 14; ++column) {
if (column == 13) { 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) { } else if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
(void)yyjson_mut_obj_add_null(document, entry, numeric_keys[column - 8]); (void)yyjson_mut_obj_add_null(document, entry, numeric_keys[column - 8]);
} else if (column == 11 || column == 14) { } else if (column == 11 || column == 14) {
(void)yyjson_mut_obj_add_real(document, (void)yyjson_mut_obj_add_real(document,
entry, entry,
numeric_keys[column - 8], numeric_keys[column - 8],
sqlite3_column_double(statement, column)); sqlite3_column_double(statement, column));
} else { } else {
(void)yyjson_mut_obj_add_sint(document, (void)yyjson_mut_obj_add_sint(document,
entry, entry,
numeric_keys[column - 8], numeric_keys[column - 8],
sqlite3_column_int64(statement, column)); sqlite3_column_int64(statement, column));
} }
} }
if (!yyjson_mut_obj_add_val(document, entry, "performed_sets", sets) || 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) { sqlite3_bind_text(sets_statement, 1, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
if (sets_statement != NULL) { if (sets_statement != NULL) {
@ -359,7 +432,8 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
while ((set_step = sqlite3_step(sets_statement)) == SQLITE_ROW) { while ((set_step = sqlite3_step(sets_statement)) == SQLITE_ROW) {
yyjson_mut_val *set = yyjson_mut_obj(document); yyjson_mut_val *set = yyjson_mut_obj(document);
if (set == NULL || 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(sets_statement);
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_SYSTEM_ERROR; 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]); (void)yyjson_mut_obj_add_null(document, set, keys[column - 1]);
} else if (column == 3) { } else if (column == 3) {
(void)yyjson_mut_obj_add_real(document, (void)yyjson_mut_obj_add_real(document,
set, set,
keys[column - 1], keys[column - 1],
sqlite3_column_double(sets_statement, column)); sqlite3_column_double(sets_statement, column));
} else { } else {
(void)yyjson_mut_obj_add_sint(document, (void)yyjson_mut_obj_add_sint(document,
set, set,
keys[column - 1], keys[column - 1],
sqlite3_column_int64(sets_statement, column)); sqlite3_column_int64(sets_statement, column));
} }
} }
(void)yyjson_mut_arr_add_val(sets, set); (void)yyjson_mut_arr_add_val(sets, set);
@ -404,9 +478,13 @@ static TrainlogStatus detail_for_draft(TrainlogDatabase *database,
yyjson_val *payload; yyjson_val *payload;
yyjson_mut_val *copied_payload; yyjson_mut_val *copied_payload;
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(
"SELECT session_id,session_type,started_at,state,revision_id,payload_json FROM execution_drafts WHERE session_id=?1", database->connection,
-1, &statement, NULL) != SQLITE_OK || "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_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) { sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) { 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; strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
} }
static bool json_object_has_only(yyjson_val *object, static bool
const char *const *allowed, json_object_has_only(yyjson_val *object, const char *const *allowed, size_t allowed_count) {
size_t allowed_count) {
yyjson_obj_iter iterator = yyjson_obj_iter_with(object); yyjson_obj_iter iterator = yyjson_obj_iter_with(object);
yyjson_val *key; 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)) { if (value == NULL || yyjson_is_null(value)) {
return sqlite3_bind_null(statement, index) == SQLITE_OK; return sqlite3_bind_null(statement, index) == SQLITE_OK;
} }
return yyjson_is_str(value) && return yyjson_is_str(value) && sqlite3_bind_text(statement,
sqlite3_bind_text(statement, index, yyjson_get_str(value), index,
(int)yyjson_get_len(value), SQLITE_TRANSIENT) == SQLITE_OK; yyjson_get_str(value),
(int)yyjson_get_len(value),
SQLITE_TRANSIENT) == SQLITE_OK;
} }
static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database, static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
const char *revision_id, const char *revision_id,
yyjson_val *entry, yyjson_val *entry,
size_t position) { size_t position) {
static const char *const ALLOWED[] = { static const char *const ALLOWED[] = {"entry_id",
"entry_id", "exercise_id", "equipment_id", "load_mode", "rest_seconds", "exercise_id",
"target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "notes"}; "equipment_id",
"load_mode",
"rest_seconds",
"target_sets",
"target_reps",
"target_duration_seconds",
"target_weight_kg",
"notes"};
static const char INSERT_SQL[] = 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) " "INSERT INTO "
"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 " "session_preparation_entries(revision_id,entry_id,position,exercise_id,equipment_id,"
"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)"; "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; sqlite3_stmt *statement = NULL;
const char *entry_id; const char *entry_id;
const char *exercise_id; const char *exercise_id;
@ -638,8 +730,8 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
if (!yyjson_is_obj(entry) || if (!yyjson_is_obj(entry) ||
!json_object_has_only(entry, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) || !json_object_has_only(entry, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) ||
!bounded_json_string(yyjson_obj_get(entry, "exercise_id"), TRAINLOG_ID_MAX + 1U, !bounded_json_string(
&exercise_id) || yyjson_obj_get(entry, "exercise_id"), TRAINLOG_ID_MAX + 1U, &exercise_id) ||
!bounded_json_string(yyjson_obj_get(entry, "load_mode"), 16U, &load_mode) || !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, "none") != 0 && strcmp(load_mode, "external") != 0 &&
strcmp(load_mode, "assistance") != 0)) { strcmp(load_mode, "assistance") != 0)) {
@ -684,14 +776,16 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
value = yyjson_obj_get(entry, "target_reps"); value = yyjson_obj_get(entry, "target_reps");
if (value == NULL || yyjson_is_null(value)) { if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 9); (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) { sqlite3_bind_int64(statement, 9, yyjson_get_sint(value)) != SQLITE_OK) {
goto invalid; goto invalid;
} }
value = yyjson_obj_get(entry, "target_duration_seconds"); value = yyjson_obj_get(entry, "target_duration_seconds");
if (value == NULL || yyjson_is_null(value)) { if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 10); (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) { sqlite3_bind_int64(statement, 10, yyjson_get_sint(value)) != SQLITE_OK) {
goto invalid; goto invalid;
} }
@ -725,9 +819,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
size_t save_body_size, size_t save_body_size,
char **output_json, char **output_json,
size_t *output_size) { size_t *output_size) {
static const char *const ALLOWED[] = { static const char *const ALLOWED[] = {"title",
"title", "session_type", "planned_for", "notes", "editing_state", "occurrences", "session_type",
"source_proposal_id", "source_payload_sha256"}; "planned_for",
"notes",
"editing_state",
"occurrences",
"source_proposal_id",
"source_payload_sha256"};
yyjson_doc *input = NULL; yyjson_doc *input = NULL;
yyjson_val *root; yyjson_val *root;
yyjson_val *occurrences; yyjson_val *occurrences;
@ -746,9 +845,9 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
yyjson_mut_doc *response; yyjson_mut_doc *response;
yyjson_mut_val *response_root; yyjson_mut_val *response_root;
if (database == NULL || expected_revision == NULL || request_id == NULL || request_id[0] == '\0' || if (database == NULL || expected_revision == NULL || request_id == NULL ||
save_body == NULL || save_body_size == 0U || output_json == NULL || output_size == NULL || request_id[0] == '\0' || save_body == NULL || save_body_size == 0U || output_json == NULL ||
!timestamp_now(now)) { output_size == NULL || !timestamp_now(now)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
*output_json = NULL; *output_json = NULL;
@ -767,17 +866,16 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
} }
occurrences = yyjson_obj_get(root, "occurrences"); occurrences = yyjson_obj_get(root, "occurrences");
if (!yyjson_is_arr(occurrences) || yyjson_arr_size(occurrences) > 64U || 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); yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
if (yyjson_obj_get(root, "source_proposal_id") != NULL && if (yyjson_obj_get(root, "source_proposal_id") != NULL &&
(!bounded_json_string(yyjson_obj_get(root, "source_proposal_id"), (!bounded_json_string(
TRAINLOG_ID_MAX + 1U, yyjson_obj_get(root, "source_proposal_id"), TRAINLOG_ID_MAX + 1U, &source_proposal) ||
&source_proposal) || !bounded_json_string(
!bounded_json_string(yyjson_obj_get(root, "source_payload_sha256"), yyjson_obj_get(root, "source_payload_sha256"), 65U, &source_fingerprint) ||
65U,
&source_fingerprint) ||
strlen(source_fingerprint) != 64U)) { strlen(source_fingerprint) != 64U)) {
yyjson_doc_free(input); yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT; 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 (actual_preparation == NULL || actual_preparation[0] == '\0') {
if (expected_revision[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); yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT; 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 /* INVARIANT: a replayed request returns its committed identity and does
* not evaluate the now-stale expected revision again. */ * not evaluate the now-stale expected revision again. */
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(
"SELECT response_json FROM session_preparation_requests WHERE request_id=?1", database->connection,
-1, &statement, NULL) == SQLITE_OK && "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_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) { sqlite3_step(statement) == SQLITE_ROW) {
const char *saved = (const char *)sqlite3_column_text(statement, 0); const char *saved = (const char *)sqlite3_column_text(statement, 0);
@ -819,12 +921,19 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
statement = NULL; statement = NULL;
} }
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) { if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) {
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(
"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)", database->connection,
-1, &statement, NULL) != SQLITE_OK || "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, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) !=
sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || SQLITE_OK ||
sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) { sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT; status = TRAINLOG_STATUS_CONFLICT;
goto rollback; goto rollback;
@ -834,10 +943,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
} }
if (preparation_id != NULL && preparation_id[0] != '\0') { if (preparation_id != NULL && preparation_id[0] != '\0') {
const char *current = NULL; const char *current = NULL;
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(
"SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1", database->connection,
-1, &statement, NULL) != SQLITE_OK || "SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1",
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || -1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) { sqlite3_step(statement) != SQLITE_ROW) {
status = TRAINLOG_STATUS_NOT_FOUND; status = TRAINLOG_STATUS_NOT_FOUND;
goto rollback; goto rollback;
@ -849,10 +962,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
} }
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
statement = NULL; statement = NULL;
} else if (sqlite3_prepare_v2(database->connection, } else if (sqlite3_prepare_v2(
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)", database->connection,
-1, &statement, NULL) != SQLITE_OK || "INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)",
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || -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, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, now, -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_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); (void)sqlite3_finalize(statement);
statement = NULL; statement = NULL;
} }
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(
"INSERT INTO session_preparation_revisions VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", database->connection,
-1, &statement, NULL) != SQLITE_OK || "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, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, actual_preparation, -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) (expected_revision[0] == '\0'
: sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT)) != SQLITE_OK || ? 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, 4, title, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, session_type, -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, 6, yyjson_obj_get(root, "planned_for")) ||
@ -883,20 +1005,26 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
statement = NULL; statement = NULL;
for (index = 0U; index < yyjson_arr_size(occurrences); ++index) { for (index = 0U; index < yyjson_arr_size(occurrences); ++index) {
status = insert_preparation_entry( status =
database, revision, yyjson_arr_get(occurrences, index), index); insert_preparation_entry(database, revision, yyjson_arr_get(occurrences, index), index);
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
goto rollback; goto rollback;
} }
} }
if (preparation_id != NULL && preparation_id[0] != '\0') { if (preparation_id != NULL && preparation_id[0] != '\0') {
if (sqlite3_prepare_v2(database->connection, 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", "UPDATE session_preparations SET "
-1, &statement, NULL) != SQLITE_OK || "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, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, now, -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, 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_bind_text(statement, 5, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) { sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT; status = TRAINLOG_STATUS_CONFLICT;
@ -919,8 +1047,10 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
status = write_document(response, output_json, output_size); status = write_document(response, output_json, output_size);
if (status != TRAINLOG_STATUS_OK || if (status != TRAINLOG_STATUS_OK ||
sqlite3_prepare_v2(database->connection, sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparation_requests VALUES(?1,'save',?2,?3,?4,?5)", "INSERT INTO session_preparation_requests VALUES(?1,'save',?2,?3,?4,?5)",
-1, &statement, NULL) != SQLITE_OK || -1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != 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, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, revision, -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; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(database->connection,
"SELECT response_json FROM session_preparation_requests WHERE request_id=?1 AND command='deliver'", "SELECT response_json FROM session_preparation_requests WHERE "
-1, &statement, NULL) == SQLITE_OK && "request_id=?1 AND command='deliver'",
-1,
&statement,
NULL) == SQLITE_OK &&
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) { sqlite3_step(statement) == SQLITE_ROW) {
const char *saved = (const char *)sqlite3_column_text(statement, 0); 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); (void)sqlite3_finalize(statement);
statement = NULL; statement = NULL;
} }
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(
"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)", database->connection,
-1, &statement, NULL) != SQLITE_OK || "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, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) { sqlite3_step(statement) != SQLITE_ROW) {
@ -1000,9 +1138,12 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
} }
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
statement = NULL; statement = NULL;
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(
"INSERT INTO session_preparation_deliveries VALUES(?1,?2,?3,?4,'pending',?5,NULL,NULL)", database->connection,
-1, &statement, NULL) != SQLITE_OK || "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, 1, delivery_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, preparation_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 || 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); status = write_document(response, output_json, output_size);
response = NULL; response = NULL;
if (status != TRAINLOG_STATUS_OK || if (status != TRAINLOG_STATUS_OK ||
sqlite3_prepare_v2(database->connection, sqlite3_prepare_v2(
"INSERT INTO session_preparation_requests VALUES(?1,'deliver',?2,?3,?4,?5)", database->connection,
-1, &statement, NULL) != SQLITE_OK || "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, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, preparation_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 || 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; statement = NULL;
if (sqlite3_prepare_v2(database->connection, if (sqlite3_prepare_v2(database->connection,
"UPDATE session_preparations SET delivery_state='pending' WHERE preparation_id=?1 AND current_revision_id=?2", "UPDATE session_preparations SET delivery_state='pending' WHERE "
-1, &statement, NULL) != SQLITE_OK || "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, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, expected_revision, -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 || sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 ||

View file

@ -382,6 +382,13 @@ static bool test_http_contract(TrainlogDatabase *database) {
exchange( exchange(
port, "GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, sizeof(response)) && 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); 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, CHECK(exchange(port,
"GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", "GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, response,

View file

@ -9,12 +9,12 @@
#include "trainlog/database.h" #include "trainlog/database.h"
#include "trainlog/web_sessions.h" #include "trainlog/web_sessions.h"
#define CHECK(expression) \ #define CHECK(expression) \
do { \ do { \
if (!(expression)) { \ if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \ (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return false; \ return false; \
} \ } \
} while (0) } while (0)
static int scalar(TrainlogDatabase *database, const char *sql) { 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\"," "\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
"\"occurrences\":[]}"; "\"occurrences\":[]}";
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
TrainlogWebSessionsPageQuery query = { TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
char *created = NULL; char *created = NULL;
char *replayed = NULL; char *replayed = NULL;
char *detail = 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 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( export async function fetchSessionDetail(
kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal, kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal,
): Promise<SessionDetail> { ): 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, fetchSessionDetail,
fetchReadonlySessionDetail, fetchReadonlySessionDetail,
prepareForAndroid, prepareForAndroid,
fetchSessionPage, fetchAllSessionPages,
savePreparation, savePreparation,
type CatalogChoice, type CatalogChoice,
type PreparationInput, type PreparationInput,
@ -214,14 +214,17 @@ export function SessionsPage() {
const [view, setView] = useState<View>('preparation') const [view, setView] = useState<View>('preparation')
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([]) const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [pending, setPending] = useState(true) const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null) const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname) const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath]) const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => { const controller = new AbortController(); setPending(true); setFailed(false) const load = () => { const controller = new AbortController(); setPending(true); setFailed(false)
Promise.all(collections[view].map((collection) => fetchSessionPage(collection, 0, search, controller.signal) Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
.then((page) => page.items.map((item) => ({ ...item, collection }))))) .then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
.then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false)) .then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false))
return controller } return controller }
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search]) useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
@ -234,6 +237,13 @@ export function SessionsPage() {
window.history.pushState(null, '', path); setDetailPath(path) window.history.pushState(null, '', path); setDetailPath(path)
} }
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) } 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} if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section> onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) { 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> {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} <label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
onChange={(event) => setSearch(event.target.value)} /></label> 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 && <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>} {!pending && !failed && visibleItems.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" <div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}> 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> <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> <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; } .primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
.quiet-action { border-color: var(--surface-2); } .quiet-action { border-color: var(--surface-2); }
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); } .session-search { 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-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-list { display: grid; gap: .6rem; }
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; } .session-row { 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; }