Complete Web Sessions pagination and browser flow
This commit is contained in:
parent
e0a0397d2a
commit
bbcb487491
12 changed files with 573 additions and 212 deletions
111
tests/test_web_sessions_browser.py
Normal file
111
tests/test_web_sessions_browser.py
Normal 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()
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
33
tui/src/id.c
33
tui/src/id.c
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ||
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
39
web/src/api/sessions.test.ts
Normal file
39
web/src/api/sessions.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
42
web/src/routes/SessionsPage.test.tsx
Normal file
42
web/src/routes/SessionsPage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue