feat(web): complete session deletion and programs
This commit is contained in:
parent
c8a292326d
commit
4dffb05210
30 changed files with 2010 additions and 124 deletions
|
|
@ -69,7 +69,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
)
|
)
|
||||||
private val SUPPORTED =
|
private val SUPPORTED =
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) +
|
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
||||||
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false))
|
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false))
|
||||||
.map { it.format to it.version }
|
.map { it.format to it.version }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
|
||||||
|
|
@ -1721,15 +1721,26 @@ class TrainlogRepository(
|
||||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
|
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
|
||||||
}
|
}
|
||||||
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "drafts")) ||
|
val version = root.value("version")
|
||||||
|
val expectedKeys = if (version.isExactJsonInteger(2)) {
|
||||||
|
setOf("format", "version", "generated_at", "drafts", "withdrawals")
|
||||||
|
} else {
|
||||||
|
setOf("format", "version", "generated_at", "drafts")
|
||||||
|
}
|
||||||
|
if (!root.hasExactKeys(expectedKeys) ||
|
||||||
root.value("format") != "trainlog-ai-session-drafts" ||
|
root.value("format") != "trainlog-ai-session-drafts" ||
|
||||||
!root.value("version").isExactJsonInteger(1) ||
|
(!version.isExactJsonInteger(1) && !version.isExactJsonInteger(2)) ||
|
||||||
!root.value("generated_at").isNonemptyJsonString() || root.value("drafts") !is JSONArray ||
|
!root.value("generated_at").isNonemptyJsonString() || root.value("drafts") !is JSONArray ||
|
||||||
!isUtcTimestamp(root.optString("generated_at"))) {
|
!isUtcTimestamp(root.optString("generated_at"))) {
|
||||||
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA v1 non supporté.")
|
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA non supporté.")
|
||||||
}
|
}
|
||||||
val drafts = root.getJSONArray("drafts")
|
val drafts = root.getJSONArray("drafts")
|
||||||
if (drafts.length() > MAX_AI_DRAFTS)
|
val withdrawals = if (version.isExactJsonInteger(2)) {
|
||||||
|
root.getJSONArray("withdrawals")
|
||||||
|
} else {
|
||||||
|
JSONArray()
|
||||||
|
}
|
||||||
|
if (drafts.length() > MAX_AI_DRAFTS || withdrawals.length() > MAX_AI_DRAFTS)
|
||||||
return AiSessionDraftImportResult.Invalid("Trop de brouillons IA.")
|
return AiSessionDraftImportResult.Invalid("Trop de brouillons IA.")
|
||||||
|
|
||||||
val db = database.writableDatabase
|
val db = database.writableDatabase
|
||||||
|
|
@ -1847,6 +1858,31 @@ class TrainlogRepository(
|
||||||
skipped++
|
skipped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (withdrawalIndex in 0 until withdrawals.length()) {
|
||||||
|
val withdrawal = withdrawals.opt(withdrawalIndex) as? JSONObject
|
||||||
|
?: throw IllegalArgumentException("Retrait de proposition IA invalide.")
|
||||||
|
if (!withdrawal.hasExactKeys(setOf("draft_id", "withdrawn_at"))) {
|
||||||
|
throw IllegalArgumentException("Champs de retrait IA invalides.")
|
||||||
|
}
|
||||||
|
val draftId = withdrawal.value("draft_id") as? String
|
||||||
|
?: throw IllegalArgumentException("draft_id de retrait invalide.")
|
||||||
|
val withdrawnAt = withdrawal.value("withdrawn_at") as? String
|
||||||
|
?: throw IllegalArgumentException("withdrawn_at invalide.")
|
||||||
|
if (!draftId.matches(AI_DRAFT_ID_V4_PATTERN) || !isUtcTimestamp(withdrawnAt)) {
|
||||||
|
throw IllegalArgumentException("Retrait de proposition IA invalide.")
|
||||||
|
}
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT OR IGNORE INTO ai_session_drafts(" +
|
||||||
|
"draft_id,created_at,state,state_changed_at) VALUES(?,?,'deleted',?);",
|
||||||
|
arrayOf(draftId, withdrawnAt, withdrawnAt),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE ai_session_drafts SET state='deleted',state_changed_at=? " +
|
||||||
|
"WHERE draft_id=? AND state='pending';",
|
||||||
|
arrayOf(withdrawnAt, draftId),
|
||||||
|
)
|
||||||
|
db.delete("ai_session_draft_entries", "draft_id=?", arrayOf(draftId))
|
||||||
|
}
|
||||||
db.setTransactionSuccessful()
|
db.setTransactionSuccessful()
|
||||||
AiSessionDraftImportResult.Applied(imported, skipped)
|
AiSessionDraftImportResult.Applied(imported, skipped)
|
||||||
} catch (error: IllegalArgumentException) {
|
} catch (error: IllegalArgumentException) {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,41 @@ class AiSessionDraftRepositoryTest {
|
||||||
assertTrue(repository.listSessions().isEmpty())
|
assertTrue(repository.listSessions().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun desktopWithdrawalTombstonePreventsLegacyReplay() {
|
||||||
|
val exercise = exercise()
|
||||||
|
val legacyArtifact = artifact(exercise.exerciseId)
|
||||||
|
assertEquals(
|
||||||
|
AiSessionDraftImportResult.Applied(1, 0),
|
||||||
|
repository.applyAiSessionDraftsJson(legacyArtifact),
|
||||||
|
)
|
||||||
|
val withdrawalArtifact = JSONObject()
|
||||||
|
.put("format", "trainlog-ai-session-drafts")
|
||||||
|
.put("version", 2)
|
||||||
|
.put("generated_at", "2026-09-18T12:01:00Z")
|
||||||
|
.put("drafts", JSONArray())
|
||||||
|
.put(
|
||||||
|
"withdrawals",
|
||||||
|
JSONArray().put(
|
||||||
|
JSONObject()
|
||||||
|
.put("draft_id", DRAFT_ID)
|
||||||
|
.put("withdrawn_at", "2026-09-18T12:00:00Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.applyAiSessionDraftsJson(withdrawalArtifact) is
|
||||||
|
AiSessionDraftImportResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(repository.listAiSessionDrafts().isEmpty())
|
||||||
|
assertEquals(
|
||||||
|
AiSessionDraftImportResult.Applied(0, 1),
|
||||||
|
repository.applyAiSessionDraftsJson(legacyArtifact),
|
||||||
|
)
|
||||||
|
assertTrue(repository.listAiSessionDrafts().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun pendingDraftIsListed() {
|
fun pendingDraftIsListed() {
|
||||||
val exercise = exercise()
|
val exercise = exercise()
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ PRAGMA foreign_keys=ON;
|
||||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER NOT NULL DEFAULT 0);
|
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER NOT NULL DEFAULT 0);
|
||||||
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
|
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
|
||||||
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY);
|
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY);
|
||||||
CREATE TABLE ai_session_drafts(id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,planned_for TEXT,session_type TEXT NOT NULL,title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending',archived_at TEXT,archive_error TEXT,published_at TEXT);
|
CREATE TABLE ai_session_drafts(id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,planned_for TEXT,session_type TEXT NOT NULL,title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending',archived_at TEXT,archive_error TEXT,published_at TEXT,withdrawn_at TEXT);
|
||||||
CREATE TABLE ai_session_draft_entries(id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,equipment_id TEXT,load_mode TEXT NOT NULL,target_sets INTEGER NOT NULL,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,rest_seconds INTEGER NOT NULL,UNIQUE(draft_row_id,position));
|
CREATE TABLE ai_session_draft_entries(id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,equipment_id TEXT,load_mode TEXT NOT NULL,target_sets INTEGER NOT NULL,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,rest_seconds INTEGER NOT NULL,UNIQUE(draft_row_id,position));
|
||||||
CREATE TABLE ai_session_draft_imports(draft_row_id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);
|
CREATE TABLE ai_session_draft_imports(draft_row_id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);
|
||||||
CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;
|
CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;
|
||||||
|
|
@ -94,8 +94,13 @@ class DraftTests(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(ImportFailure, "conflit"):
|
with self.assertRaisesRegex(ImportFailure, "conflit"):
|
||||||
import_payload(con, changed)
|
import_payload(con, changed)
|
||||||
exported = build_export(con, "2026-09-14T09:02:00Z")
|
exported = build_export(con, "2026-09-14T09:02:00Z")
|
||||||
self.assertEqual({"format", "version", "generated_at", "drafts"}, set(exported))
|
self.assertEqual(
|
||||||
|
{"format", "version", "generated_at", "drafts", "withdrawals"},
|
||||||
|
set(exported),
|
||||||
|
)
|
||||||
self.assertEqual("trainlog-ai-session-drafts", exported["format"])
|
self.assertEqual("trainlog-ai-session-drafts", exported["format"])
|
||||||
|
self.assertEqual(2, exported["version"])
|
||||||
|
self.assertEqual([], exported["withdrawals"])
|
||||||
outbound = exported["drafts"][0]["entries"][0]
|
outbound = exported["drafts"][0]["entries"][0]
|
||||||
self.assertEqual(EX_REPS, outbound["exercise_id"])
|
self.assertEqual(EX_REPS, outbound["exercise_id"])
|
||||||
self.assertEqual(3, outbound["data_fields"])
|
self.assertEqual(3, outbound["data_fields"])
|
||||||
|
|
@ -112,6 +117,24 @@ class DraftTests(unittest.TestCase):
|
||||||
"PRAGMA table_info(ai_session_drafts)")})
|
"PRAGMA table_info(ai_session_drafts)")})
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
def test_withdrawn_proposal_is_exported_only_as_a_tombstone(self):
|
||||||
|
con = self.connect()
|
||||||
|
self.assertEqual("imported", import_payload(con, artifact()))
|
||||||
|
con.execute(
|
||||||
|
"UPDATE ai_session_drafts SET withdrawn_at=? WHERE draft_id=?",
|
||||||
|
("2026-09-18T12:00:00Z", DRAFT_ID),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
|
||||||
|
exported = build_export(con, "2026-09-18T12:01:00Z")
|
||||||
|
|
||||||
|
self.assertEqual([], exported["drafts"])
|
||||||
|
self.assertEqual(
|
||||||
|
[{"draft_id": DRAFT_ID, "withdrawn_at": "2026-09-18T12:00:00Z"}],
|
||||||
|
exported["withdrawals"],
|
||||||
|
)
|
||||||
|
con.close()
|
||||||
|
|
||||||
def test_target_sets_99_and_bounded_publication_lifecycle(self):
|
def test_target_sets_99_and_bounded_publication_lifecycle(self):
|
||||||
value = artifact()
|
value = artifact()
|
||||||
value["draft"]["entries"][0]["target_sets"] = 99
|
value["draft"]["entries"][0]["target_sets"] = 99
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,15 @@ class BrowserSessionsTest(unittest.TestCase):
|
||||||
str(EVIDENCE / "web-sessions-list-desktop.png")
|
str(EVIDENCE / "web-sessions-list-desktop.png")
|
||||||
))
|
))
|
||||||
|
|
||||||
|
driver.find_element(By.XPATH, "//button[normalize-space()='Programmes']").click()
|
||||||
|
wait.until(lambda current: "Importer un programme" in current.page_source)
|
||||||
|
set_viewport(390, 844)
|
||||||
|
self.assertTrue(driver.save_screenshot(
|
||||||
|
str(EVIDENCE / "web-programs-empty-mobile.png")
|
||||||
|
))
|
||||||
|
driver.find_element(By.XPATH, "//button[normalize-space()='Préparation']").click()
|
||||||
|
wait.until(lambda current: "Nouvelle séance" in current.page_source)
|
||||||
|
|
||||||
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
|
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
|
||||||
wait.until(lambda current: "Nouvelle séance" in current.page_source)
|
wait.until(lambda current: "Nouvelle séance" in current.page_source)
|
||||||
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
|
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Export the PC→Android trainlog-ai-session-drafts v1 companion."""
|
"""Export the PC→Android trainlog-ai-session-drafts v2 companion."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -28,6 +28,9 @@ def ensure_publication_state(connection):
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT")
|
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT")
|
||||||
|
if "withdrawn_at" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE ai_session_drafts ADD COLUMN withdrawn_at TEXT")
|
||||||
# INVARIANT: publication never weakens the permanent Drive replay ledger,
|
# INVARIANT: publication never weakens the permanent Drive replay ledger,
|
||||||
# including on an older v18 database whose FK was declared cascading.
|
# including on an older v18 database whose FK was declared cascading.
|
||||||
connection.execute(
|
connection.execute(
|
||||||
|
|
@ -45,7 +48,7 @@ def build_export(connection, generated_at):
|
||||||
drafts = []
|
drafts = []
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT id,draft_id,created_at,planned_for,session_type,title,notes "
|
"SELECT id,draft_id,created_at,planned_for,session_type,title,notes "
|
||||||
"FROM ai_session_drafts WHERE published_at IS NULL "
|
"FROM ai_session_drafts WHERE published_at IS NULL AND withdrawn_at IS NULL "
|
||||||
"ORDER BY created_at COLLATE BINARY,draft_id COLLATE BINARY LIMIT ?",
|
"ORDER BY created_at COLLATE BINARY,draft_id COLLATE BINARY LIMIT ?",
|
||||||
(MAX_DRAFTS,),
|
(MAX_DRAFTS,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
@ -72,8 +75,22 @@ def build_export(connection, generated_at):
|
||||||
})
|
})
|
||||||
# CONTRACT: this companion is separate from every mobile-export version.
|
# CONTRACT: this companion is separate from every mobile-export version.
|
||||||
# INVARIANT: archive bookkeeping is desktop-private and is never published.
|
# INVARIANT: archive bookkeeping is desktop-private and is never published.
|
||||||
return {"format": "trainlog-ai-session-drafts", "version": 1,
|
withdrawals = [
|
||||||
"generated_at": generated_at, "drafts": drafts}
|
{"draft_id": row[0], "withdrawn_at": row[1]}
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT draft_id,withdrawn_at FROM ai_session_drafts "
|
||||||
|
"WHERE withdrawn_at IS NOT NULL ORDER BY withdrawn_at,draft_id "
|
||||||
|
"LIMIT ?",
|
||||||
|
(MAX_DRAFTS,),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"format": "trainlog-ai-session-drafts",
|
||||||
|
"version": 2,
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"drafts": drafts,
|
||||||
|
"withdrawals": withdrawals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def mark_published(connection, payload, published_at):
|
def mark_published(connection, payload, published_at):
|
||||||
|
|
@ -84,9 +101,17 @@ def mark_published(connection, payload, published_at):
|
||||||
retry exports the same deterministic IDs and the import ledger is retained.
|
retry exports the same deterministic IDs and the import ledger is retained.
|
||||||
"""
|
"""
|
||||||
ensure_publication_state(connection)
|
ensure_publication_state(connection)
|
||||||
if (not isinstance(payload, dict) or set(payload) != {"format", "version", "generated_at", "drafts"}
|
expected_keys = {"format", "version", "generated_at", "drafts", "withdrawals"}
|
||||||
or payload["format"] != "trainlog-ai-session-drafts" or payload["version"] != 1
|
if (
|
||||||
or not isinstance(payload["drafts"], list) or len(payload["drafts"]) > MAX_DRAFTS):
|
not isinstance(payload, dict)
|
||||||
|
or set(payload) != expected_keys
|
||||||
|
or payload["format"] != "trainlog-ai-session-drafts"
|
||||||
|
or payload["version"] != 2
|
||||||
|
or not isinstance(payload["drafts"], list)
|
||||||
|
or len(payload["drafts"]) > MAX_DRAFTS
|
||||||
|
or not isinstance(payload["withdrawals"], list)
|
||||||
|
or len(payload["withdrawals"]) > MAX_DRAFTS
|
||||||
|
):
|
||||||
raise ValueError("lot de brouillons IA publié invalide")
|
raise ValueError("lot de brouillons IA publié invalide")
|
||||||
draft_ids = []
|
draft_ids = []
|
||||||
for draft in payload["drafts"]:
|
for draft in payload["drafts"]:
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ def main() -> int:
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.2",
|
"product_version": "0.1.2",
|
||||||
"desktop_schema": 24,
|
"desktop_schema": 25,
|
||||||
"android_schema": 23,
|
"android_schema": 23,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
|
|
@ -107,6 +107,7 @@ def main() -> int:
|
||||||
"generation-ack-v1",
|
"generation-ack-v1",
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
|
"ai-session-drafts-v2",
|
||||||
],
|
],
|
||||||
"entry_points": [
|
"entry_points": [
|
||||||
"bin/trainlog",
|
"bin/trainlog",
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ ARTIFACTS = (
|
||||||
"export_training_feedback.py", ()),
|
"export_training_feedback.py", ()),
|
||||||
("causal-deletions", "trainlog-causal-deletions", 1, "causal-deletions-v1.json", True,
|
("causal-deletions", "trainlog-causal-deletions", 1, "causal-deletions-v1.json", True,
|
||||||
"causal_delete_exchange.py", ("export",)),
|
"causal_delete_exchange.py", ("export",)),
|
||||||
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
|
("ai-proposals", "trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", False,
|
||||||
"export_ai_session_drafts.py", ()),
|
"export_ai_session_drafts.py", ()),
|
||||||
("session-preparations", "trainlog-session-preparations", 2,
|
("session-preparations", "trainlog-session-preparations", 2,
|
||||||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@
|
||||||
* - `sxe` for persisted session-exercise occurrences;
|
* - `sxe` for persisted session-exercise occurrences;
|
||||||
* - `sp`, `spr`, `spe`, `spd`, and `spw` for preparation, revision,
|
* - `sp`, `spr`, `spe`, `spd`, and `spw` for preparation, revision,
|
||||||
* occurrence, delivery, and withdrawal identities;
|
* occurrence, delivery, and withdrawal identities;
|
||||||
* - `pgr` for desktop Program V1 revision identities.
|
* - `pgr` for desktop Program V1 revision identities;
|
||||||
|
* - `del` for immutable causal deletion operations.
|
||||||
*
|
*
|
||||||
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
||||||
* this API defines creation policy, not import validation.
|
* this API defines creation policy, not import validation.
|
||||||
|
|
|
||||||
27
tui/include/trainlog/web_session_deletions.h
Normal file
27
tui/include/trainlog/web_session_deletions.h
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/* Trainlog Web Sessions deletion command service. */
|
||||||
|
#ifndef TRAINLOG_WEB_SESSION_DELETIONS_H
|
||||||
|
#define TRAINLOG_WEB_SESSION_DELETIONS_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CONTRACT: kind is proposal, draft, or history. Preparation withdrawal keeps
|
||||||
|
* its separate delivery-aware service. expected_revision and request_id are
|
||||||
|
* required bounded strings. Exact request replay returns the original JSON.
|
||||||
|
* No performed session is deleted by proposal or draft removal.
|
||||||
|
*
|
||||||
|
* On success output_json transfers one heap allocation to the caller, which
|
||||||
|
* must release it with free(). No allocation is returned on failure.
|
||||||
|
*/
|
||||||
|
TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database,
|
||||||
|
const char *kind,
|
||||||
|
const char *target_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
const char *request_id,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -57,6 +57,7 @@ if web_assets_enabled
|
||||||
'../web/src/app/useRoute.ts',
|
'../web/src/app/useRoute.ts',
|
||||||
'../web/src/components/EmptyState.tsx',
|
'../web/src/components/EmptyState.tsx',
|
||||||
'../web/src/components/DisplayPreferences.tsx',
|
'../web/src/components/DisplayPreferences.tsx',
|
||||||
|
'../web/src/components/DeleteConfirmationDialog.tsx',
|
||||||
'../web/src/components/Footer.tsx',
|
'../web/src/components/Footer.tsx',
|
||||||
'../web/src/components/Header.tsx',
|
'../web/src/components/Header.tsx',
|
||||||
'../web/src/components/SyncControl.tsx',
|
'../web/src/components/SyncControl.tsx',
|
||||||
|
|
@ -205,6 +206,7 @@ trainlog_core_sources = files(
|
||||||
'src/web_prepared_items.c',
|
'src/web_prepared_items.c',
|
||||||
'src/web_prepared_items_json.c',
|
'src/web_prepared_items_json.c',
|
||||||
'src/web_sessions.c',
|
'src/web_sessions.c',
|
||||||
|
'src/web_session_deletions.c',
|
||||||
'src/web_programs.c',
|
'src/web_programs.c',
|
||||||
'src/duration.c',
|
'src/duration.c',
|
||||||
'src/id.c',
|
'src/id.c',
|
||||||
|
|
@ -379,6 +381,15 @@ test_web_programs = executable(
|
||||||
)
|
)
|
||||||
test('web_programs', test_web_programs)
|
test('web_programs', test_web_programs)
|
||||||
|
|
||||||
|
test_web_session_deletions = executable(
|
||||||
|
'test_web_session_deletions',
|
||||||
|
'tests/test_web_session_deletions.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
include_directories: trainlog_private_include,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
test('web_session_deletions', test_web_session_deletions)
|
||||||
|
|
||||||
test_dashboard_header = executable(
|
test_dashboard_header = executable(
|
||||||
'test_dashboard_header',
|
'test_dashboard_header',
|
||||||
'tests/test_dashboard_header.c',
|
'tests/test_dashboard_header.c',
|
||||||
|
|
|
||||||
|
|
@ -1208,6 +1208,10 @@ static const char *const MIGRATE_V24_TO_V25_SQL =
|
||||||
"CREATE TABLE IF NOT EXISTS program_requests("
|
"CREATE TABLE IF NOT EXISTS program_requests("
|
||||||
"request_id TEXT PRIMARY KEY,command TEXT NOT NULL,program_id TEXT NOT NULL,"
|
"request_id TEXT PRIMARY KEY,command TEXT NOT NULL,program_id TEXT NOT NULL,"
|
||||||
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
||||||
|
"CREATE TABLE IF NOT EXISTS web_session_deletion_requests("
|
||||||
|
"request_id TEXT PRIMARY KEY,resource_kind TEXT NOT NULL,target_id TEXT NOT NULL,"
|
||||||
|
"expected_revision TEXT NOT NULL,response_json TEXT NOT NULL,created_at TEXT NOT NULL,"
|
||||||
|
"UNIQUE(resource_kind,target_id));"
|
||||||
"PRAGMA user_version=25;COMMIT;";
|
"PRAGMA user_version=25;COMMIT;";
|
||||||
|
|
||||||
/* CONTRACT: migration fixtures may retain additive v25 columns while
|
/* CONTRACT: migration fixtures may retain additive v25 columns while
|
||||||
|
|
@ -1251,6 +1255,43 @@ static TrainlogStatus ensure_v25_preparation_provenance(TrainlogDatabase *databa
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus ensure_v25_proposal_withdrawal(TrainlogDatabase *database) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
bool found = false;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection, "PRAGMA table_info(ai_session_drafts);", -1, &statement, NULL);
|
||||||
|
if (result != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
const char *name = (const char *)sqlite3_column_text(statement, 1);
|
||||||
|
|
||||||
|
if (name != NULL && strcmp(name, "withdrawn_at") == 0) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (!found &&
|
||||||
|
execute_sql(database, "ALTER TABLE ai_session_drafts ADD COLUMN withdrawn_at TEXT;") !=
|
||||||
|
TRAINLOG_STATUS_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus ensure_v25_web_deletion_requests(TrainlogDatabase *database) {
|
||||||
|
return execute_sql(database,
|
||||||
|
"CREATE TABLE IF NOT EXISTS web_session_deletion_requests("
|
||||||
|
"request_id TEXT PRIMARY KEY,resource_kind TEXT NOT NULL,"
|
||||||
|
"target_id TEXT NOT NULL,expected_revision TEXT NOT NULL,"
|
||||||
|
"response_json TEXT NOT NULL,created_at TEXT NOT NULL,"
|
||||||
|
"UNIQUE(resource_kind,target_id));");
|
||||||
|
}
|
||||||
|
|
||||||
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
||||||
* added. Keep its version number and add only the nullable lifecycle cursor;
|
* added. Keep its version number and add only the nullable lifecycle cursor;
|
||||||
* NULL deliberately means retryable/not yet published. */
|
* NULL deliberately means retryable/not yet published. */
|
||||||
|
|
@ -2088,6 +2129,12 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v25_preparation_provenance(database);
|
status = ensure_v25_preparation_provenance(database);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = ensure_v25_proposal_withdrawal(database);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = ensure_v25_web_deletion_requests(database);
|
||||||
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
status = ensure_v18_ai_draft_publication_state(database);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ static bool prefix_is_supported(const char *prefix) {
|
||||||
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
|
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
|
||||||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
|
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
|
||||||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
|
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
|
||||||
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0;
|
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
|
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,14 @@ static bool format_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 TrainlogStatus load_ai_proposals(
|
static TrainlogStatus load_ai_proposals(TrainlogDatabase *database,
|
||||||
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
TrainlogWebPreparedItems *output) {
|
||||||
static const char SQL[] =
|
static const char SQL[] =
|
||||||
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
|
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
|
||||||
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
|
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
|
||||||
"END,d.created_at FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
|
"END,d.created_at FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
|
||||||
"e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
|
"e.draft_row_id=d.id WHERE d.withdrawn_at IS NULL GROUP BY d.id ORDER BY "
|
||||||
|
"COALESCE(d.planned_for,'9999-12-31'),"
|
||||||
"d.created_at,d.draft_id LIMIT ?1;";
|
"d.created_at,d.draft_id LIMIT ?1;";
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
int step;
|
int step;
|
||||||
|
|
@ -99,8 +100,8 @@ fail:
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
static TrainlogStatus load_manual_preparations(
|
static TrainlogStatus load_manual_preparations(TrainlogDatabase *database,
|
||||||
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
TrainlogWebPreparedItems *output) {
|
||||||
static const char SQL[] =
|
static const char SQL[] =
|
||||||
"SELECT p.preparation_id,r.title,COALESCE(r.planned_for,''),COUNT(e.entry_id),"
|
"SELECT p.preparation_id,r.title,COALESCE(r.planned_for,''),COUNT(e.entry_id),"
|
||||||
"p.editing_state,p.updated_at FROM session_preparations p JOIN "
|
"p.editing_state,p.updated_at FROM session_preparations p JOIN "
|
||||||
|
|
@ -148,8 +149,8 @@ fail:
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
static TrainlogStatus load_execution_drafts(
|
static TrainlogStatus load_execution_drafts(TrainlogDatabase *database,
|
||||||
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
TrainlogWebPreparedItems *output) {
|
||||||
static const char SQL[] =
|
static const char SQL[] =
|
||||||
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
|
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
|
||||||
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
|
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
|
||||||
|
|
@ -201,8 +202,8 @@ fail:
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_web_prepared_items_load(
|
TrainlogStatus trainlog_web_prepared_items_load(TrainlogDatabase *database,
|
||||||
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
TrainlogWebPreparedItems *output) {
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
TrainlogStatus end_status;
|
TrainlogStatus end_status;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "trainlog/web_programs.h"
|
#include "trainlog/web_programs.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
@ -468,6 +469,44 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
|
||||||
return write_document(document, output_json, output_size);
|
return write_document(document, output_json, output_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool equipment_is_known(TrainlogDatabase *database, yyjson_val *equipment) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
if (yyjson_is_null(equipment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"SELECT 1 FROM custom_equipment e WHERE e.equipment_id=?1 AND NOT EXISTS("
|
||||||
|
"SELECT 1 FROM sync_causal_state c WHERE c.target_kind='custom_equipment' "
|
||||||
|
"AND c.target_id=e.equipment_id AND c.deleted=1)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, yyjson_get_str(equipment), -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return result == SQLITE_ROW;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool valid_target_weight(const char *load_mode, yyjson_val *weight) {
|
||||||
|
double target_weight;
|
||||||
|
|
||||||
|
if (strcmp(load_mode, "none") == 0) {
|
||||||
|
return yyjson_is_null(weight);
|
||||||
|
}
|
||||||
|
if (!yyjson_is_num(weight)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
target_weight = yyjson_get_num(weight);
|
||||||
|
return isfinite(target_weight) && target_weight > 0.0 && target_weight <= 2000.0;
|
||||||
|
}
|
||||||
|
|
||||||
static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence) {
|
static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence) {
|
||||||
static const char *const KEYS[] = {"entry_id",
|
static const char *const KEYS[] = {"entry_id",
|
||||||
"exercise_id",
|
"exercise_id",
|
||||||
|
|
@ -487,6 +526,8 @@ static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence)
|
||||||
int prepare_result;
|
int prepare_result;
|
||||||
int bind_result;
|
int bind_result;
|
||||||
int step_result;
|
int step_result;
|
||||||
|
yyjson_val *equipment;
|
||||||
|
yyjson_val *weight;
|
||||||
|
|
||||||
if (!object_has_exact_keys(occurrence, KEYS, sizeof(KEYS) / sizeof(KEYS[0])) ||
|
if (!object_has_exact_keys(occurrence, KEYS, sizeof(KEYS) / sizeof(KEYS[0])) ||
|
||||||
!bounded_string(yyjson_obj_get(occurrence, "entry_id"), 128U, false) ||
|
!bounded_string(yyjson_obj_get(occurrence, "entry_id"), 128U, false) ||
|
||||||
|
|
@ -507,6 +548,11 @@ static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence)
|
||||||
strcmp(load_mode, "assistance") != 0) {
|
strcmp(load_mode, "assistance") != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
equipment = yyjson_obj_get(occurrence, "equipment_id");
|
||||||
|
weight = yyjson_obj_get(occurrence, "target_weight_kg");
|
||||||
|
if (!equipment_is_known(database, equipment) || !valid_target_weight(load_mode, weight)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
prepare_result =
|
prepare_result =
|
||||||
sqlite3_prepare_v2(database->connection,
|
sqlite3_prepare_v2(database->connection,
|
||||||
"SELECT recording_mode,tracking_mode "
|
"SELECT recording_mode,tracking_mode "
|
||||||
|
|
@ -552,10 +598,12 @@ static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence)
|
||||||
if (strcmp(tracking_mode, "reps") == 0) {
|
if (strcmp(tracking_mode, "reps") == 0) {
|
||||||
yyjson_val *target_reps = yyjson_obj_get(occurrence, "target_reps");
|
yyjson_val *target_reps = yyjson_obj_get(occurrence, "target_reps");
|
||||||
return yyjson_is_int(target_reps) && yyjson_get_sint(target_reps) >= 1 &&
|
return yyjson_is_int(target_reps) && yyjson_get_sint(target_reps) >= 1 &&
|
||||||
|
yyjson_get_sint(target_reps) <= 10000 &&
|
||||||
yyjson_is_null(yyjson_obj_get(occurrence, "target_duration_seconds"));
|
yyjson_is_null(yyjson_obj_get(occurrence, "target_duration_seconds"));
|
||||||
}
|
}
|
||||||
return yyjson_is_int(yyjson_obj_get(occurrence, "target_duration_seconds")) &&
|
return yyjson_is_int(yyjson_obj_get(occurrence, "target_duration_seconds")) &&
|
||||||
yyjson_get_sint(yyjson_obj_get(occurrence, "target_duration_seconds")) >= 1 &&
|
yyjson_get_sint(yyjson_obj_get(occurrence, "target_duration_seconds")) >= 1 &&
|
||||||
|
yyjson_get_sint(yyjson_obj_get(occurrence, "target_duration_seconds")) <= 604800 &&
|
||||||
yyjson_is_null(yyjson_obj_get(occurrence, "target_reps"));
|
yyjson_is_null(yyjson_obj_get(occurrence, "target_reps"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -955,24 +1003,42 @@ static TrainlogStatus program_persist_import(TrainlogDatabase *database,
|
||||||
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||||
}
|
}
|
||||||
|
|
||||||
static TrainlogStatus program_build_import_response(const char *program_id,
|
static TrainlogStatus program_build_import_response(yyjson_val *program,
|
||||||
const char *digest,
|
const char *digest,
|
||||||
size_t session_count,
|
|
||||||
bool imported,
|
bool imported,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
size_t *output_size) {
|
size_t *output_size) {
|
||||||
yyjson_mut_doc *response = yyjson_mut_doc_new(NULL);
|
yyjson_mut_doc *response = yyjson_mut_doc_new(NULL);
|
||||||
yyjson_mut_val *root = response == NULL ? NULL : yyjson_mut_obj(response);
|
yyjson_mut_val *root = response == NULL ? NULL : yyjson_mut_obj(response);
|
||||||
|
yyjson_mut_val *warnings = response == NULL ? NULL : yyjson_mut_arr(response);
|
||||||
|
yyjson_val *start_date = yyjson_obj_get(program, "start_date");
|
||||||
|
yyjson_val *end_date = yyjson_obj_get(program, "end_date");
|
||||||
|
|
||||||
if (response == NULL || root == NULL) {
|
if (response == NULL || root == NULL || warnings == NULL) {
|
||||||
yyjson_mut_doc_free(response);
|
yyjson_mut_doc_free(response);
|
||||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
}
|
}
|
||||||
yyjson_mut_doc_set_root(response, root);
|
yyjson_mut_doc_set_root(response, root);
|
||||||
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
||||||
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
|
(void)yyjson_mut_obj_add_strcpy(
|
||||||
|
response, root, "program_id", yyjson_get_str(yyjson_obj_get(program, "program_id")));
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(
|
||||||
|
response, root, "title", yyjson_get_str(yyjson_obj_get(program, "title")));
|
||||||
|
if (yyjson_is_null(start_date)) {
|
||||||
|
(void)yyjson_mut_obj_add_null(response, root, "start_date");
|
||||||
|
} else {
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(response, root, "start_date", yyjson_get_str(start_date));
|
||||||
|
}
|
||||||
|
if (yyjson_is_null(end_date)) {
|
||||||
|
(void)yyjson_mut_obj_add_null(response, root, "end_date");
|
||||||
|
} else {
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(response, root, "end_date", yyjson_get_str(end_date));
|
||||||
|
}
|
||||||
(void)yyjson_mut_obj_add_strcpy(response, root, "payload_sha256", digest);
|
(void)yyjson_mut_obj_add_strcpy(response, root, "payload_sha256", digest);
|
||||||
(void)yyjson_mut_obj_add_uint(response, root, "session_count", session_count);
|
(void)yyjson_mut_obj_add_uint(
|
||||||
|
response, root, "session_count", yyjson_arr_size(yyjson_obj_get(program, "sessions")));
|
||||||
|
(void)yyjson_mut_obj_add_uint(response, root, "unknown_exercise_count", 0U);
|
||||||
|
(void)yyjson_mut_obj_add_val(response, root, "warnings", warnings);
|
||||||
(void)yyjson_mut_obj_add_bool(response, root, "imported", imported);
|
(void)yyjson_mut_obj_add_bool(response, root, "imported", imported);
|
||||||
return write_document(response, output_json, output_size);
|
return write_document(response, output_json, output_size);
|
||||||
}
|
}
|
||||||
|
|
@ -1018,12 +1084,7 @@ TrainlogStatus trainlog_web_programs_import_json(TrainlogDatabase *database,
|
||||||
imported = status == TRAINLOG_STATUS_OK;
|
imported = status == TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = program_build_import_response(program_id,
|
status = program_build_import_response(program, digest, imported, output_json, output_size);
|
||||||
digest,
|
|
||||||
yyjson_arr_size(yyjson_obj_get(program, "sessions")),
|
|
||||||
imported,
|
|
||||||
output_json,
|
|
||||||
output_size);
|
|
||||||
}
|
}
|
||||||
yyjson_doc_free(input);
|
yyjson_doc_free(input);
|
||||||
return status;
|
return status;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
#include "trainlog/web_dashboard.h"
|
#include "trainlog/web_dashboard.h"
|
||||||
#include "trainlog/web_prepared_items.h"
|
#include "trainlog/web_prepared_items.h"
|
||||||
#include "trainlog/web_programs.h"
|
#include "trainlog/web_programs.h"
|
||||||
|
#include "trainlog/web_session_deletions.h"
|
||||||
#include "trainlog/web_sessions.h"
|
#include "trainlog/web_sessions.h"
|
||||||
#include "trainlog/dashboard_layout.h"
|
#include "trainlog/dashboard_layout.h"
|
||||||
#include "trainlog/web_preferences.h"
|
#include "trainlog/web_preferences.h"
|
||||||
|
|
@ -793,6 +794,77 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
||||||
return queue_owned_programs_json(connection, status, json, json_size);
|
return queue_owned_programs_json(connection, status, json, json_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static enum MHD_Result queue_session_deletion_result(struct MHD_Connection *connection,
|
||||||
|
TrainlogStatus status,
|
||||||
|
char *json,
|
||||||
|
size_t json_size) {
|
||||||
|
if (status == TRAINLOG_STATUS_CONFLICT) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_CONFLICT, "{\"error\":\"deletion_conflict\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_NOT_FOUND) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
||||||
|
free(json);
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_deletion\"}\n", NULL);
|
||||||
|
}
|
||||||
|
return queue_owned_sessions_json(connection, status, json, json_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
static enum MHD_Result handle_session_deletion(TrainlogWebContext *context,
|
||||||
|
struct MHD_Connection *connection,
|
||||||
|
const char *url,
|
||||||
|
const char *method) {
|
||||||
|
static const char PREFIX[] = "/api/v1/sessions/";
|
||||||
|
const char *request_id =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
|
||||||
|
const char *if_match =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
|
||||||
|
const char *kind = url + strlen(PREFIX);
|
||||||
|
const char *separator = strchr(kind, '/');
|
||||||
|
char kind_buffer[32];
|
||||||
|
char identity[TRAINLOG_ID_MAX + 1U];
|
||||||
|
char revision[TRAINLOG_ID_MAX + 1U];
|
||||||
|
char *json = NULL;
|
||||||
|
size_t json_size = 0U;
|
||||||
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
if (strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
|
"DELETE");
|
||||||
|
}
|
||||||
|
if (separator == NULL || separator[1] == '\0' ||
|
||||||
|
!copy_path_component(kind_buffer, sizeof(kind_buffer), kind, (size_t)(separator - kind)) ||
|
||||||
|
!copy_path_component(identity, sizeof(identity), separator + 1, strlen(separator + 1))) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_identity\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (strcmp(kind_buffer, "proposal") != 0 && strcmp(kind_buffer, "draft") != 0 &&
|
||||||
|
strcmp(kind_buffer, "history") != 0) {
|
||||||
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (!program_mutation_allowed(context, connection) || request_id == NULL ||
|
||||||
|
request_id[0] == '\0' || strlen(request_id) > 128U) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if (!parse_quoted_revision(if_match, revision, sizeof(revision))) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_PRECONDITION_REQUIRED,
|
||||||
|
"{\"error\":\"precondition_required\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
status = trainlog_web_session_delete_json(
|
||||||
|
context->database, kind_buffer, identity, revision, request_id, &json, &json_size);
|
||||||
|
return queue_session_deletion_result(connection, status, json, json_size);
|
||||||
|
}
|
||||||
|
|
||||||
static enum MHD_Result handle_request(void *closure,
|
static enum MHD_Result handle_request(void *closure,
|
||||||
struct MHD_Connection *connection,
|
struct MHD_Connection *connection,
|
||||||
const char *url,
|
const char *url,
|
||||||
|
|
@ -1033,6 +1105,13 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
size_t limit;
|
size_t limit;
|
||||||
size_t index;
|
size_t index;
|
||||||
|
|
||||||
|
if (strcmp(method, MHD_HTTP_METHOD_DELETE) == 0 &&
|
||||||
|
strncmp(url,
|
||||||
|
"/api/v1/sessions/preparation/",
|
||||||
|
strlen("/api/v1/sessions/preparation/")) != 0) {
|
||||||
|
return handle_session_deletion(context, connection, url, method);
|
||||||
|
}
|
||||||
|
|
||||||
if (!is_get && (strcmp(url, "/api/v1/sessions/preparations") == 0 ||
|
if (!is_get && (strcmp(url, "/api/v1/sessions/preparations") == 0 ||
|
||||||
strncmp(url,
|
strncmp(url,
|
||||||
"/api/v1/sessions/preparation/",
|
"/api/v1/sessions/preparation/",
|
||||||
|
|
|
||||||
600
tui/src/web_session_deletions.c
Normal file
600
tui/src/web_session_deletions.c
Normal file
|
|
@ -0,0 +1,600 @@
|
||||||
|
#include "trainlog/web_session_deletions.h"
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
#include <yyjson.h>
|
||||||
|
|
||||||
|
#include "database_internal.h"
|
||||||
|
#include "trainlog/id.h"
|
||||||
|
#include "trainlog/model.h"
|
||||||
|
|
||||||
|
#define CAUSAL_OPERATION_MAX 4096
|
||||||
|
#define CAUSAL_TEXT_BUDGET (3 * 1024 * 1024)
|
||||||
|
#define PROPOSAL_WITHDRAWAL_MAX 256
|
||||||
|
|
||||||
|
typedef struct DeleteContext {
|
||||||
|
const char *kind;
|
||||||
|
const char *target_id;
|
||||||
|
const char *expected_revision;
|
||||||
|
const char *request_id;
|
||||||
|
char created_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
} DeleteContext;
|
||||||
|
|
||||||
|
static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
|
||||||
|
time_t now = time(NULL);
|
||||||
|
struct tm utc;
|
||||||
|
|
||||||
|
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
|
||||||
|
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool copy_column(sqlite3_stmt *statement, int column, char **output, size_t *output_size) {
|
||||||
|
const char *value = (const char *)sqlite3_column_text(statement, column);
|
||||||
|
int bytes = sqlite3_column_bytes(statement, column);
|
||||||
|
|
||||||
|
if (value == NULL || bytes <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*output = malloc((size_t)bytes + 1U);
|
||||||
|
if (*output == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(void)memcpy(*output, value, (size_t)bytes);
|
||||||
|
(*output)[bytes] = '\0';
|
||||||
|
*output_size = (size_t)bytes;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus replay_request(TrainlogDatabase *database,
|
||||||
|
const DeleteContext *context,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size,
|
||||||
|
bool *replayed) {
|
||||||
|
static const char SQL[] =
|
||||||
|
"SELECT resource_kind,target_id,expected_revision,response_json "
|
||||||
|
"FROM web_session_deletion_requests WHERE request_id=?1 OR "
|
||||||
|
"(resource_kind=?2 AND target_id=?3) ORDER BY request_id=?1 DESC LIMIT 1";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
*replayed = false;
|
||||||
|
result = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, context->request_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, context->kind, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_DONE) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (strcmp((const char *)sqlite3_column_text(statement, 0), context->kind) != 0 ||
|
||||||
|
strcmp((const char *)sqlite3_column_text(statement, 1), context->target_id) != 0 ||
|
||||||
|
strcmp((const char *)sqlite3_column_text(statement, 2), context->expected_revision) != 0) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
if (!copy_column(statement, 3, output_json, output_size)) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
free(*output_json);
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
*replayed = true;
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus load_current_revision(TrainlogDatabase *database,
|
||||||
|
const char *kind,
|
||||||
|
const char *target_id,
|
||||||
|
char revision[TRAINLOG_ID_MAX + 1U],
|
||||||
|
bool *active_draft) {
|
||||||
|
static const char DRAFT_SQL[] =
|
||||||
|
"SELECT revision_id,state FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS("
|
||||||
|
"SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 "
|
||||||
|
"AND deleted=1)";
|
||||||
|
static const char HISTORY_SQL[] =
|
||||||
|
"SELECT c.current_revision_id,'' FROM sessions s JOIN sync_causal_state c ON "
|
||||||
|
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=0 "
|
||||||
|
"WHERE s.session_id=?1";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
const char *sql = strcmp(kind, "draft") == 0 ? DRAFT_SQL : HISTORY_SQL;
|
||||||
|
const char *current;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
*active_draft = false;
|
||||||
|
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
current = (const char *)sqlite3_column_text(statement, 0);
|
||||||
|
if (current == NULL || strlen(current) > TRAINLOG_ID_MAX) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
(void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current);
|
||||||
|
*active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0;
|
||||||
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
||||||
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool sha256_hex(const char *input, char output[65]) {
|
||||||
|
unsigned char digest[32];
|
||||||
|
unsigned int digest_size = 0U;
|
||||||
|
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
||||||
|
size_t index;
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
if (context == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
success = EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
||||||
|
EVP_DigestUpdate(context, input, strlen(input)) == 1 &&
|
||||||
|
EVP_DigestFinal_ex(context, digest, &digest_size) == 1 && digest_size == 32U;
|
||||||
|
EVP_MD_CTX_free(context);
|
||||||
|
if (!success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < 32U; ++index) {
|
||||||
|
(void)snprintf(output + (index * 2U), 3U, "%02x", digest[index]);
|
||||||
|
}
|
||||||
|
output[64] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus causal_capacity_available(TrainlogDatabase *database) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
sqlite3_int64 count;
|
||||||
|
sqlite3_int64 bytes;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"SELECT COUNT(*),COALESCE(SUM(length(operation_id)+length(target_kind)+"
|
||||||
|
"length(target_id)+length(creator_id)+length(predecessor_revision_id)+"
|
||||||
|
"length(created_at)+length(payload_sha256)+128),0) FROM sync_causal_operations",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
count = sqlite3_column_int64(statement, 0);
|
||||||
|
bytes = sqlite3_column_int64(statement, 1);
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
return count < CAUSAL_OPERATION_MAX && bytes < CAUSAL_TEXT_BUDGET ? TRAINLOG_STATUS_OK
|
||||||
|
: TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus insert_causal_operation(TrainlogDatabase *database,
|
||||||
|
const DeleteContext *context,
|
||||||
|
const char *causal_kind,
|
||||||
|
const char *predecessor_revision,
|
||||||
|
const char *operation_id) {
|
||||||
|
static const char CREATOR[] = "peer_desktop_web";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
char payload[1024];
|
||||||
|
char digest[65];
|
||||||
|
int written;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
written = snprintf(payload,
|
||||||
|
sizeof(payload),
|
||||||
|
"{\"created_at\":\"%s\",\"creator_id\":\"%s\","
|
||||||
|
"\"operation_id\":\"%s\",\"predecessor_revision_id\":\"%s\","
|
||||||
|
"\"publication_context\":null,\"target_id\":\"%s\","
|
||||||
|
"\"target_kind\":\"%s\"}",
|
||||||
|
context->created_at,
|
||||||
|
CREATOR,
|
||||||
|
operation_id,
|
||||||
|
predecessor_revision,
|
||||||
|
context->target_id,
|
||||||
|
causal_kind);
|
||||||
|
if (written < 0 || (size_t)written >= sizeof(payload) || !sha256_hex(payload, digest)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
result =
|
||||||
|
sqlite3_prepare_v2(database->connection,
|
||||||
|
"INSERT INTO sync_causal_operations VALUES(?1,?2,?3,?4,?5,?6,?7,NULL)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, causal_kind, -1, SQLITE_STATIC);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 4, CREATOR, -1, SQLITE_STATIC);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 5, predecessor_revision, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 6, context->created_at, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 7, digest, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||||
|
result = SQLITE_ERROR;
|
||||||
|
}
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus apply_causal_effect(TrainlogDatabase *database,
|
||||||
|
const DeleteContext *context,
|
||||||
|
const char *causal_kind,
|
||||||
|
const char *operation_id) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
if (strcmp(context->kind, "history") == 0) {
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id "
|
||||||
|
"FROM sessions WHERE session_id=?1)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||||
|
result == SQLITE_DONE) {
|
||||||
|
result = SQLITE_ERROR;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
if (result != SQLITE_DONE) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
|
strcmp(context->kind, "draft") == 0
|
||||||
|
? "DELETE FROM execution_drafts WHERE session_id=?1"
|
||||||
|
: "DELETE FROM sessions WHERE session_id=?1",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||||
|
result = SQLITE_ERROR;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
if (result != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (strcmp(context->kind, "history") == 0) {
|
||||||
|
char final_revision[TRAINLOG_GENERATED_ID_CAPACITY + sizeof("deleted:")];
|
||||||
|
int written = snprintf(final_revision, sizeof(final_revision), "deleted:%s", operation_id);
|
||||||
|
|
||||||
|
if (written < 0 || (size_t)written >= sizeof(final_revision)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"INSERT OR IGNORE INTO execution_draft_finalizations VALUES(?1,?2,?3)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, final_revision, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 3, context->created_at, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||||
|
result == SQLITE_DONE) {
|
||||||
|
result = SQLITE_ERROR;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
if (result != SQLITE_DONE) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
|
"INSERT OR REPLACE INTO sync_causal_state VALUES(?1,?2,?3,1,?3)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, causal_kind, -1, SQLITE_STATIC);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 3, operation_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||||
|
result = SQLITE_ERROR;
|
||||||
|
}
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus delete_causal_resource(TrainlogDatabase *database,
|
||||||
|
const DeleteContext *context) {
|
||||||
|
const char *causal_kind = strcmp(context->kind, "draft") == 0 ? "execution_draft" : "session";
|
||||||
|
char current_revision[TRAINLOG_ID_MAX + 1U];
|
||||||
|
char operation_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
|
bool active_draft;
|
||||||
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
status = load_current_revision(
|
||||||
|
database, context->kind, context->target_id, current_revision, &active_draft);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
if (active_draft || strcmp(current_revision, context->expected_revision) != 0) {
|
||||||
|
return TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
status = causal_capacity_available(database);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
if (trainlog_id_generate("del", operation_id, sizeof(operation_id)) != TRAINLOG_STATUS_OK) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
status =
|
||||||
|
insert_causal_operation(database, context, causal_kind, current_revision, operation_id);
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = apply_causal_effect(database, context, causal_kind, operation_id);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus delete_proposal(TrainlogDatabase *database, const DeleteContext *context) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
const char *revision;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
result = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
"SELECT COALESCE(i.payload_sha256,d.draft_id) FROM ai_session_drafts d LEFT JOIN "
|
||||||
|
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1 AND "
|
||||||
|
"d.withdrawn_at IS NULL",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
revision = (const char *)sqlite3_column_text(statement, 0);
|
||||||
|
if (revision == NULL || strcmp(revision, context->expected_revision) != 0) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
|
"SELECT COUNT(*) FROM ai_session_drafts "
|
||||||
|
"WHERE withdrawn_at IS NOT NULL",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (result != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
if (sqlite3_column_int64(statement, 0) >= PROPOSAL_WITHDRAWAL_MAX) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_CONFLICT;
|
||||||
|
}
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
|
"UPDATE ai_session_drafts SET withdrawn_at=?1 WHERE draft_id=?2 "
|
||||||
|
"AND withdrawn_at IS NULL",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, context->created_at, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||||
|
result = SQLITE_ERROR;
|
||||||
|
}
|
||||||
|
if (result != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus
|
||||||
|
build_response(const DeleteContext *context, char **output_json, size_t *output_size) {
|
||||||
|
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val *root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||||
|
|
||||||
|
if (document == NULL || root == NULL) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(document, root);
|
||||||
|
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, root, "kind", context->kind) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, root, "identity", context->target_id) ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, root, "state", "deleted") ||
|
||||||
|
!yyjson_mut_obj_add_strcpy(document, root, "deleted_at", context->created_at)) {
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
*output_json = yyjson_mut_write(document, 0U, output_size);
|
||||||
|
yyjson_mut_doc_free(document);
|
||||||
|
return *output_json != NULL ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus
|
||||||
|
store_request(TrainlogDatabase *database, const DeleteContext *context, const char *response_json) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int result;
|
||||||
|
|
||||||
|
result = sqlite3_prepare_v2(database->connection,
|
||||||
|
"INSERT INTO web_session_deletion_requests "
|
||||||
|
"VALUES(?1,?2,?3,?4,?5,?6)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL);
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 1, context->request_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 2, context->kind, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 4, context->expected_revision, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 5, response_json, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_bind_text(statement, 6, context->created_at, -1, SQLITE_TRANSIENT);
|
||||||
|
}
|
||||||
|
if (result == SQLITE_OK) {
|
||||||
|
result = sqlite3_step(statement);
|
||||||
|
}
|
||||||
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||||
|
result = SQLITE_ERROR;
|
||||||
|
}
|
||||||
|
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database,
|
||||||
|
const char *kind,
|
||||||
|
const char *target_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
const char *request_id,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size) {
|
||||||
|
DeleteContext context;
|
||||||
|
TrainlogStatus status;
|
||||||
|
bool replayed;
|
||||||
|
|
||||||
|
if (database == NULL || kind == NULL || target_id == NULL || expected_revision == NULL ||
|
||||||
|
request_id == NULL || output_json == NULL || output_size == NULL || target_id[0] == '\0' ||
|
||||||
|
expected_revision[0] == '\0' || request_id[0] == '\0' ||
|
||||||
|
strlen(target_id) > TRAINLOG_ID_MAX || strlen(expected_revision) > TRAINLOG_ID_MAX ||
|
||||||
|
strlen(request_id) > 128U ||
|
||||||
|
(strcmp(kind, "proposal") != 0 && strcmp(kind, "draft") != 0 &&
|
||||||
|
strcmp(kind, "history") != 0)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
context.kind = kind;
|
||||||
|
context.target_id = target_id;
|
||||||
|
context.expected_revision = expected_revision;
|
||||||
|
context.request_id = request_id;
|
||||||
|
if (!timestamp_now(context.created_at)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
status = replay_request(database, &context, output_json, output_size, &replayed);
|
||||||
|
if (status == TRAINLOG_STATUS_OK && replayed) {
|
||||||
|
status = sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK
|
||||||
|
? TRAINLOG_STATUS_OK
|
||||||
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = strcmp(kind, "proposal") == 0 ? delete_proposal(database, &context)
|
||||||
|
: delete_causal_resource(database, &context);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = build_response(&context, output_json, output_size);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = store_request(database, &context, *output_json);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK &&
|
||||||
|
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
free(*output_json);
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,8 @@ static const ListDefinition LISTS[] = {
|
||||||
"SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN "
|
"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 "
|
"'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 "
|
"WHERE d.withdrawn_at IS NULL AND (?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 "
|
"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)) "
|
"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 "
|
"GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),d.created_at,d.draft_id LIMIT ?2 "
|
||||||
|
|
@ -198,9 +199,11 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
|
||||||
const char *sql =
|
const char *sql =
|
||||||
proposal ? "SELECT "
|
proposal ? "SELECT "
|
||||||
"d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,"
|
"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 "
|
"i.payload_sha256,COALESCE(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 "
|
"'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"
|
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1 AND "
|
||||||
|
"d.withdrawn_at IS NULL"
|
||||||
: "SELECT "
|
: "SELECT "
|
||||||
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
|
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
|
||||||
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
|
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
|
||||||
|
|
@ -339,8 +342,11 @@ 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[] = "SELECT session_id,session_type,started_at,ended_at,notes "
|
static const char SESSION_SQL[] =
|
||||||
"FROM sessions WHERE session_id=?1";
|
"SELECT s.session_id,s.session_type,s.started_at,s.ended_at,s.notes,"
|
||||||
|
"COALESCE(c.current_revision_id,'') FROM sessions s LEFT JOIN sync_causal_state c ON "
|
||||||
|
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=0 "
|
||||||
|
"WHERE s.session_id=?1";
|
||||||
static const char ENTRY_SQL[] =
|
static const char ENTRY_SQL[] =
|
||||||
"SELECT "
|
"SELECT "
|
||||||
"se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se."
|
"se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se."
|
||||||
|
|
@ -369,7 +375,8 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
|
||||||
!add_nullable_text(document, root, "session_type", statement, 1) ||
|
!add_nullable_text(document, root, "session_type", statement, 1) ||
|
||||||
!add_nullable_text(document, root, "started_at", statement, 2) ||
|
!add_nullable_text(document, root, "started_at", statement, 2) ||
|
||||||
!add_nullable_text(document, root, "ended_at", statement, 3) ||
|
!add_nullable_text(document, root, "ended_at", statement, 3) ||
|
||||||
!add_nullable_text(document, root, "notes", statement, 4)) {
|
!add_nullable_text(document, root, "notes", statement, 4) ||
|
||||||
|
!add_nullable_text(document, root, "revision_id", statement, 5)) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ static int scalar(sqlite3 *database, const char *sql) {
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
|
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
|
||||||
|
"DROP TABLE web_session_deletion_requests;"
|
||||||
"DROP TABLE program_requests;"
|
"DROP TABLE program_requests;"
|
||||||
"DROP TABLE program_session_entries;"
|
"DROP TABLE program_session_entries;"
|
||||||
"DROP TABLE program_sessions;"
|
"DROP TABLE program_sessions;"
|
||||||
|
|
@ -78,6 +79,10 @@ int main(void) {
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 0);
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 0);
|
||||||
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM web_session_deletion_requests") == 0);
|
||||||
|
CHECK(scalar(raw,
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('ai_session_drafts') "
|
||||||
|
"WHERE name='withdrawn_at'") == 1);
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_generations") == 0);
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_generations") == 0);
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_consumed_generations") == 0);
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_consumed_generations") == 0);
|
||||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_acknowledgements") == 0);
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_acknowledgements") == 0);
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,126 @@ extract_string(const char *json, const char *key, char *output, size_t output_ca
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static char *replace_once(const char *source, const char *needle, const char *replacement) {
|
||||||
|
const char *match = strstr(source, needle);
|
||||||
|
size_t prefix_size;
|
||||||
|
size_t output_size;
|
||||||
|
char *output;
|
||||||
|
|
||||||
|
if (match == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
prefix_size = (size_t)(match - source);
|
||||||
|
output_size = strlen(source) - strlen(needle) + strlen(replacement) + 1U;
|
||||||
|
output = malloc(output_size);
|
||||||
|
if (output == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
(void)snprintf(output,
|
||||||
|
output_size,
|
||||||
|
"%.*s%s%s",
|
||||||
|
(int)prefix_size,
|
||||||
|
source,
|
||||||
|
replacement,
|
||||||
|
match + strlen(needle));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool strict_import_rejections(void) {
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char *response = NULL;
|
||||||
|
size_t response_size = 0U;
|
||||||
|
char *unknown_exercise;
|
||||||
|
char *unknown_equipment;
|
||||||
|
char *performed_data;
|
||||||
|
char *divergent;
|
||||||
|
char *oversized;
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||||
|
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
|
"Exercise fixture",
|
||||||
|
"exercise fixture",
|
||||||
|
TRAINLOG_TRACKING_REPS,
|
||||||
|
TRAINLOG_RECORDING_SETS,
|
||||||
|
0U) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_web_programs_import_json(database, "{", 1U, true, &response, &response_size) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(trainlog_web_programs_import_json(
|
||||||
|
database,
|
||||||
|
"{\"format\":\"trainlog-program\",\"format\":\"trainlog-program\","
|
||||||
|
"\"version\":1,\"program\":{}}",
|
||||||
|
strlen("{\"format\":\"trainlog-program\",\"format\":\"trainlog-program\","
|
||||||
|
"\"version\":1,\"program\":{}}"),
|
||||||
|
true,
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
|
||||||
|
unknown_exercise = replace_once(PROGRAM_JSON,
|
||||||
|
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
|
"ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee");
|
||||||
|
unknown_equipment =
|
||||||
|
replace_once(PROGRAM_JSON, "\"equipment_id\":null", "\"equipment_id\":\"missing\"");
|
||||||
|
performed_data = replace_once(PROGRAM_JSON, "\"notes\":null", "\"notes\":null,\"sets\":[]");
|
||||||
|
divergent = replace_once(PROGRAM_JSON, "Cycle lisible", "Cycle divergent");
|
||||||
|
CHECK(unknown_exercise != NULL && unknown_equipment != NULL && performed_data != NULL &&
|
||||||
|
divergent != NULL);
|
||||||
|
CHECK(trainlog_web_programs_import_json(database,
|
||||||
|
unknown_exercise,
|
||||||
|
strlen(unknown_exercise),
|
||||||
|
true,
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(trainlog_web_programs_import_json(database,
|
||||||
|
unknown_equipment,
|
||||||
|
strlen(unknown_equipment),
|
||||||
|
true,
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(trainlog_web_programs_import_json(
|
||||||
|
database, performed_data, strlen(performed_data), true, &response, &response_size) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 0);
|
||||||
|
|
||||||
|
CHECK(trainlog_web_programs_import_json(
|
||||||
|
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
free(response);
|
||||||
|
response = NULL;
|
||||||
|
CHECK(trainlog_web_programs_import_json(
|
||||||
|
database, divergent, strlen(divergent), true, &response, &response_size) ==
|
||||||
|
TRAINLOG_STATUS_CONFLICT);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 1);
|
||||||
|
|
||||||
|
oversized = malloc(TRAINLOG_WEB_PROGRAM_BYTES_MAX + 2U);
|
||||||
|
CHECK(oversized != NULL);
|
||||||
|
(void)memset(oversized, ' ', TRAINLOG_WEB_PROGRAM_BYTES_MAX + 1U);
|
||||||
|
oversized[TRAINLOG_WEB_PROGRAM_BYTES_MAX + 1U] = '\0';
|
||||||
|
CHECK(trainlog_web_programs_import_json(database,
|
||||||
|
oversized,
|
||||||
|
TRAINLOG_WEB_PROGRAM_BYTES_MAX + 1U,
|
||||||
|
true,
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
|
||||||
|
free(oversized);
|
||||||
|
free(divergent);
|
||||||
|
free(performed_data);
|
||||||
|
free(unknown_equipment);
|
||||||
|
free(unknown_exercise);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool import_archive_and_prepare(void) {
|
static bool import_archive_and_prepare(void) {
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
char *response = NULL;
|
char *response = NULL;
|
||||||
size_t response_size = 0U;
|
size_t response_size = 0U;
|
||||||
char revision[128];
|
char revision[128];
|
||||||
char preparation_id[128];
|
char preparation_id[128];
|
||||||
|
char *second_program;
|
||||||
|
char *second_session;
|
||||||
|
char *second_entry;
|
||||||
|
|
||||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_insert_exercise_profiled(database,
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||||
|
|
@ -113,6 +227,35 @@ static bool import_archive_and_prepare(void) {
|
||||||
free(response);
|
free(response);
|
||||||
response = NULL;
|
response = NULL;
|
||||||
|
|
||||||
|
second_program = replace_once(PROGRAM_JSON,
|
||||||
|
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
"pg_11111111-1111-4111-8111-111111111111");
|
||||||
|
CHECK(second_program != NULL);
|
||||||
|
second_session = replace_once(second_program,
|
||||||
|
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
"pgs_22222222-2222-4222-8222-222222222222");
|
||||||
|
free(second_program);
|
||||||
|
CHECK(second_session != NULL);
|
||||||
|
second_entry = replace_once(second_session,
|
||||||
|
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
|
"pge_33333333-3333-4333-8333-333333333333");
|
||||||
|
free(second_session);
|
||||||
|
CHECK(second_entry != NULL);
|
||||||
|
CHECK(trainlog_web_programs_import_json(
|
||||||
|
database, second_entry, strlen(second_entry), true, &response, &response_size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
free(second_entry);
|
||||||
|
free(response);
|
||||||
|
response = NULL;
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 2);
|
||||||
|
CHECK(trainlog_web_programs_list_json(
|
||||||
|
database, "Cycle", "active", 0U, 1U, &response, &response_size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(response, "\"more\":true") != NULL);
|
||||||
|
CHECK(strstr(response, "\"next_offset\":1") != NULL);
|
||||||
|
free(response);
|
||||||
|
response = NULL;
|
||||||
|
|
||||||
CHECK(trainlog_web_programs_detail_json(
|
CHECK(trainlog_web_programs_detail_json(
|
||||||
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
|
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
|
||||||
TRAINLOG_STATUS_OK);
|
TRAINLOG_STATUS_OK);
|
||||||
|
|
@ -157,5 +300,5 @@ static bool import_archive_and_prepare(void) {
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
return import_archive_and_prepare() ? EXIT_SUCCESS : EXIT_FAILURE;
|
return import_archive_and_prepare() && strict_import_rejections() ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
|
|
||||||
#include <sqlite3.h>
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include "database_internal.h"
|
||||||
#include "trainlog/database.h"
|
#include "trainlog/database.h"
|
||||||
#include "trainlog/dashboard_layout.h"
|
#include "trainlog/dashboard_layout.h"
|
||||||
#include "trainlog/web_server.h"
|
#include "trainlog/web_server.h"
|
||||||
|
|
@ -235,6 +236,18 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
CHECK(json_string(created, "preparation_id", preparation_id, sizeof(preparation_id)));
|
CHECK(json_string(created, "preparation_id", preparation_id, sizeof(preparation_id)));
|
||||||
CHECK(json_string(created, "revision_id", revision_id, sizeof(revision_id)));
|
CHECK(json_string(created, "revision_id", revision_id, sizeof(revision_id)));
|
||||||
free(created);
|
free(created);
|
||||||
|
CHECK(sqlite3_exec(database->connection,
|
||||||
|
"INSERT INTO ai_session_drafts("
|
||||||
|
"draft_id,created_at,session_type,title,archive_status) VALUES("
|
||||||
|
"'aid_66666666-6666-4666-8666-666666666666',"
|
||||||
|
"'2026-09-18T10:00:00Z','training','Proposal HTTP','archived');"
|
||||||
|
"INSERT INTO ai_session_draft_imports VALUES("
|
||||||
|
"last_insert_rowid(),'aid_66666666-6666-4666-8666-666666666666',"
|
||||||
|
"'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',"
|
||||||
|
"'2026-09-18T10:00:00Z');",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL) == SQLITE_OK);
|
||||||
(void)unlink("/tmp/trainlog-web-test.db.sync-run.json");
|
(void)unlink("/tmp/trainlog-web-test.db.sync-run.json");
|
||||||
CHECK(reserve_port(&port, 0) == -2);
|
CHECK(reserve_port(&port, 0) == -2);
|
||||||
child = fork();
|
child = fork();
|
||||||
|
|
@ -307,18 +320,17 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
revision_id);
|
revision_id);
|
||||||
CHECK(exchange(port, request, response, sizeof(response)) &&
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
strstr(response, "HTTP/1.1 403") != NULL);
|
strstr(response, "HTTP/1.1 403") != NULL);
|
||||||
(void)snprintf(
|
(void)snprintf(request,
|
||||||
request,
|
sizeof(request),
|
||||||
sizeof(request),
|
"DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
|
||||||
"DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
|
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
|
||||||
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
|
"Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
|
||||||
"Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
|
"X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
|
||||||
"X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
|
"Content-Length: 2\r\n\r\n{}",
|
||||||
"Content-Length: 2\r\n\r\n{}",
|
preparation_id,
|
||||||
preparation_id,
|
(unsigned int)port,
|
||||||
(unsigned int)port,
|
revision_id,
|
||||||
revision_id,
|
token);
|
||||||
token);
|
|
||||||
CHECK(exchange(port, request, response, sizeof(response)) &&
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
strstr(response, "HTTP/1.1 200") != NULL &&
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
strstr(response, "\"android_cancellation\":\"not_required\"") != NULL);
|
strstr(response, "\"android_cancellation\":\"not_required\"") != NULL);
|
||||||
|
|
@ -330,6 +342,42 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
CHECK(exchange(port, request, response, sizeof(response)) &&
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
strstr(response, "HTTP/1.1 200") != NULL &&
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
strstr(response, "\"state\":\"withdrawn\"") != NULL);
|
strstr(response, "\"state\":\"withdrawn\"") != NULL);
|
||||||
|
(void)snprintf(request,
|
||||||
|
sizeof(request),
|
||||||
|
"DELETE /api/v1/sessions/proposal/"
|
||||||
|
"aid_66666666-6666-4666-8666-666666666666 HTTP/1.1\r\n"
|
||||||
|
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
|
||||||
|
"Content-Type: application/json\r\nIf-Match: \"stale\"\r\n"
|
||||||
|
"X-Trainlog-CSRF-Token: %s\r\n"
|
||||||
|
"X-Trainlog-Request-ID: request-http-delete-proposal-stale\r\n"
|
||||||
|
"Content-Length: 2\r\n\r\n{}",
|
||||||
|
(unsigned int)port,
|
||||||
|
token);
|
||||||
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 409") != NULL);
|
||||||
|
(void)snprintf(
|
||||||
|
request,
|
||||||
|
sizeof(request),
|
||||||
|
"DELETE /api/v1/sessions/proposal/"
|
||||||
|
"aid_66666666-6666-4666-8666-666666666666 HTTP/1.1\r\n"
|
||||||
|
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
|
||||||
|
"Content-Type: application/json\r\n"
|
||||||
|
"If-Match: \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\r\n"
|
||||||
|
"X-Trainlog-CSRF-Token: %s\r\n"
|
||||||
|
"X-Trainlog-Request-ID: request-http-delete-proposal\r\n"
|
||||||
|
"Content-Length: 2\r\n\r\n{}",
|
||||||
|
(unsigned int)port,
|
||||||
|
token);
|
||||||
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
|
strstr(response, "\"state\":\"deleted\"") != NULL);
|
||||||
|
CHECK(exchange(port,
|
||||||
|
"GET /api/v1/sessions/proposal/"
|
||||||
|
"aid_66666666-6666-4666-8666-666666666666 HTTP/1.1\r\n"
|
||||||
|
"Host: 127.0.0.1\r\n\r\n",
|
||||||
|
response,
|
||||||
|
sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 404") != NULL);
|
||||||
}
|
}
|
||||||
CHECK(exchange(port,
|
CHECK(exchange(port,
|
||||||
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||||
|
|
@ -362,16 +410,15 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) &&
|
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) &&
|
||||||
strcmp(etag, "\"0\"") == 0);
|
strcmp(etag, "\"0\"") == 0);
|
||||||
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)));
|
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)));
|
||||||
(void)snprintf(
|
(void)snprintf(request,
|
||||||
request,
|
sizeof(request),
|
||||||
sizeof(request),
|
"PUT /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||||
"PUT /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
|
||||||
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
|
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: 82\r\n\r\n"
|
||||||
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: 82\r\n\r\n"
|
"{\"format\":\"trainlog-web-preferences\",\"version\":1,\"revision\":0,"
|
||||||
"{\"format\":\"trainlog-web-preferences\",\"version\":1,\"revision\":0,"
|
"\"date_format\":\"iso\"}",
|
||||||
"\"date_format\":\"iso\"}",
|
(unsigned int)port,
|
||||||
(unsigned int)port,
|
token);
|
||||||
token);
|
|
||||||
CHECK(exchange(port, request, response, sizeof(response)) &&
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
strstr(response, "HTTP/1.1 200") != NULL &&
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
strstr(response, "\"revision\":1") != NULL &&
|
strstr(response, "\"revision\":1") != NULL &&
|
||||||
|
|
@ -390,10 +437,7 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
const char *config = getenv("XDG_CONFIG_HOME");
|
const char *config = getenv("XDG_CONFIG_HOME");
|
||||||
|
|
||||||
CHECK(config != NULL);
|
CHECK(config != NULL);
|
||||||
CHECK(snprintf(path,
|
CHECK(snprintf(path, sizeof(path), "%s/trainlog/web/preferences-v1.json", config) > 0);
|
||||||
sizeof(path),
|
|
||||||
"%s/trainlog/web/preferences-v1.json",
|
|
||||||
config) > 0);
|
|
||||||
stream = fopen(path, "w");
|
stream = fopen(path, "w");
|
||||||
CHECK(stream != NULL);
|
CHECK(stream != NULL);
|
||||||
CHECK(fputs("invalid preference\n", stream) >= 0);
|
CHECK(fputs("invalid preference\n", stream) >= 0);
|
||||||
|
|
|
||||||
185
tui/tests/test_web_session_deletions.c
Normal file
185
tui/tests/test_web_session_deletions.c
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include "database_internal.h"
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/status.h"
|
||||||
|
#include "trainlog/web_session_deletions.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static sqlite3_int64 scalar(TrainlogDatabase *database, const char *sql) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
sqlite3_int64 value = -1;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||||
|
sqlite3_step(statement) == SQLITE_ROW) {
|
||||||
|
value = sqlite3_column_int64(statement, 0);
|
||||||
|
}
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool proposal_deletion_is_durable_and_preserves_derivatives(void) {
|
||||||
|
char path[] = "/tmp/trainlog-web-delete-proposal-XXXXXX";
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char *response = NULL;
|
||||||
|
char *replayed = NULL;
|
||||||
|
size_t response_size = 0U;
|
||||||
|
size_t replayed_size = 0U;
|
||||||
|
int descriptor = mkstemp(path);
|
||||||
|
|
||||||
|
CHECK(descriptor >= 0);
|
||||||
|
CHECK(close(descriptor) == 0);
|
||||||
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sqlite3_exec(database->connection,
|
||||||
|
"INSERT INTO ai_session_drafts("
|
||||||
|
"draft_id,created_at,session_type,title,archive_status,published_at) "
|
||||||
|
"VALUES('aid_11111111-1111-4111-8111-111111111111',"
|
||||||
|
"'2026-09-18T10:00:00Z','training','Proposal','archived',"
|
||||||
|
"'2026-09-18T11:00:00Z');"
|
||||||
|
"INSERT INTO ai_session_draft_imports VALUES("
|
||||||
|
"last_insert_rowid(),'aid_11111111-1111-4111-8111-111111111111',"
|
||||||
|
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||||
|
"'2026-09-18T10:00:00Z');"
|
||||||
|
"INSERT INTO session_preparations VALUES("
|
||||||
|
"'sp_22222222-2222-4222-8222-222222222222','spr_source',"
|
||||||
|
"'2026-09-18T10:00:00Z','2026-09-18T10:00:00Z','draft','local',"
|
||||||
|
"'aid_11111111-1111-4111-8111-111111111111',"
|
||||||
|
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||||
|
"NULL,NULL,NULL);",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL) == SQLITE_OK);
|
||||||
|
CHECK(trainlog_web_session_delete_json(
|
||||||
|
database,
|
||||||
|
"proposal",
|
||||||
|
"aid_11111111-1111-4111-8111-111111111111",
|
||||||
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"request-delete-proposal",
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(response != NULL && strstr(response, "\"state\":\"deleted\"") != NULL);
|
||||||
|
CHECK(trainlog_web_session_delete_json(
|
||||||
|
database,
|
||||||
|
"proposal",
|
||||||
|
"aid_11111111-1111-4111-8111-111111111111",
|
||||||
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"request-delete-proposal",
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(response_size == replayed_size && strcmp(response, replayed) == 0);
|
||||||
|
CHECK(scalar(database,
|
||||||
|
"SELECT COUNT(*) FROM ai_session_drafts WHERE withdrawn_at IS NOT NULL") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM web_session_deletion_requests") == 1);
|
||||||
|
free(replayed);
|
||||||
|
free(response);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
CHECK(unlink(path) == 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool draft_and_history_use_causal_deletion(void) {
|
||||||
|
char path[] = "/tmp/trainlog-web-delete-causal-XXXXXX";
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char *response = NULL;
|
||||||
|
size_t response_size = 0U;
|
||||||
|
int descriptor = mkstemp(path);
|
||||||
|
|
||||||
|
CHECK(descriptor >= 0);
|
||||||
|
CHECK(close(descriptor) == 0);
|
||||||
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sqlite3_exec(database->connection,
|
||||||
|
"INSERT INTO execution_drafts VALUES("
|
||||||
|
"'se_33333333-3333-4333-8333-333333333333','training',NULL,"
|
||||||
|
"'2026-09-18T10:00:00Z','mu_draft',NULL,'pending','{}');"
|
||||||
|
"INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES("
|
||||||
|
"'se_44444444-4444-4444-8444-444444444444',"
|
||||||
|
"'2026-09-17T10:00:00Z','2026-09-17T11:00:00Z','training');"
|
||||||
|
"INSERT INTO sync_causal_state VALUES("
|
||||||
|
"'session','se_44444444-4444-4444-8444-444444444444',"
|
||||||
|
"'mu_history',0,NULL);",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL) == SQLITE_OK);
|
||||||
|
CHECK(trainlog_web_session_delete_json(database,
|
||||||
|
"draft",
|
||||||
|
"se_33333333-3333-4333-8333-333333333333",
|
||||||
|
"mu_draft",
|
||||||
|
"request-delete-draft",
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_OK);
|
||||||
|
free(response);
|
||||||
|
response = NULL;
|
||||||
|
CHECK(trainlog_web_session_delete_json(database,
|
||||||
|
"history",
|
||||||
|
"se_44444444-4444-4444-8444-444444444444",
|
||||||
|
"mu_history",
|
||||||
|
"request-delete-history",
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 0);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM sessions") == 0);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 2);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_state WHERE deleted=1") == 2);
|
||||||
|
CHECK(scalar(database,
|
||||||
|
"SELECT COUNT(*) FROM execution_draft_finalizations "
|
||||||
|
"WHERE session_id='se_44444444-4444-4444-8444-444444444444' "
|
||||||
|
"AND final_revision_id LIKE 'deleted:del_%'") == 1);
|
||||||
|
free(response);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
CHECK(unlink(path) == 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool active_draft_conflicts_without_mutation(void) {
|
||||||
|
char path[] = "/tmp/trainlog-web-delete-active-XXXXXX";
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
char *response = NULL;
|
||||||
|
size_t response_size = 0U;
|
||||||
|
int descriptor = mkstemp(path);
|
||||||
|
|
||||||
|
CHECK(descriptor >= 0);
|
||||||
|
CHECK(close(descriptor) == 0);
|
||||||
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sqlite3_exec(database->connection,
|
||||||
|
"INSERT INTO execution_drafts VALUES("
|
||||||
|
"'se_55555555-5555-4555-8555-555555555555','training',NULL,"
|
||||||
|
"'2026-09-18T10:00:00Z','mu_active',NULL,'active','{}');",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL) == SQLITE_OK);
|
||||||
|
CHECK(trainlog_web_session_delete_json(database,
|
||||||
|
"draft",
|
||||||
|
"se_55555555-5555-4555-8555-555555555555",
|
||||||
|
"mu_active",
|
||||||
|
"request-delete-active",
|
||||||
|
&response,
|
||||||
|
&response_size) == TRAINLOG_STATUS_CONFLICT);
|
||||||
|
CHECK(response == NULL && response_size == 0U);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 0);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
CHECK(unlink(path) == 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
return proposal_deletion_is_durable_and_preserves_derivatives() &&
|
||||||
|
draft_and_history_use_causal_deletion() &&
|
||||||
|
active_draft_conflicts_without_mutation()
|
||||||
|
? EXIT_SUCCESS
|
||||||
|
: EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
@ -58,11 +58,32 @@ export interface ProgramDetail {
|
||||||
export interface ProgramImportPreview {
|
export interface ProgramImportPreview {
|
||||||
api_version: 1
|
api_version: 1
|
||||||
program_id: string
|
program_id: string
|
||||||
|
title: string
|
||||||
|
start_date: string | null
|
||||||
|
end_date: string | null
|
||||||
payload_sha256: string
|
payload_sha256: string
|
||||||
session_count: number
|
session_count: number
|
||||||
|
unknown_exercise_count: number
|
||||||
|
warnings: string[]
|
||||||
imported: boolean
|
imported: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function programImportPreview(value: unknown): ProgramImportPreview {
|
||||||
|
if (!object(value) || value.api_version !== 1 ||
|
||||||
|
typeof value.program_id !== 'string' || typeof value.title !== 'string' ||
|
||||||
|
(value.start_date !== null && typeof value.start_date !== 'string') ||
|
||||||
|
(value.end_date !== null && typeof value.end_date !== 'string') ||
|
||||||
|
typeof value.payload_sha256 !== 'string' ||
|
||||||
|
!Number.isSafeInteger(value.session_count) ||
|
||||||
|
!Number.isSafeInteger(value.unknown_exercise_count) ||
|
||||||
|
!Array.isArray(value.warnings) ||
|
||||||
|
!value.warnings.every((warning) => typeof warning === 'string') ||
|
||||||
|
typeof value.imported !== 'boolean') {
|
||||||
|
throw new TypeError('aperçu de programme invalide')
|
||||||
|
}
|
||||||
|
return value as unknown as ProgramImportPreview
|
||||||
|
}
|
||||||
|
|
||||||
function object(value: unknown): value is Record<string, unknown> {
|
function object(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +173,7 @@ export async function validateProgramImport(body: string): Promise<ProgramImport
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await errorReason(response))
|
throw new Error(await errorReason(response))
|
||||||
}
|
}
|
||||||
return response.json() as Promise<ProgramImportPreview>
|
return programImportPreview(await response.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importProgram(body: string): Promise<ProgramImportPreview> {
|
export async function importProgram(body: string): Promise<ProgramImportPreview> {
|
||||||
|
|
@ -160,7 +181,7 @@ export async function importProgram(body: string): Promise<ProgramImportPreview>
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await errorReason(response))
|
throw new Error(await errorReason(response))
|
||||||
}
|
}
|
||||||
return response.json() as Promise<ProgramImportPreview>
|
return programImportPreview(await response.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveProgram(programId: string, revision: string): Promise<void> {
|
export async function archiveProgram(programId: string, revision: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -264,3 +264,34 @@ export async function withdrawPreparation(
|
||||||
}
|
}
|
||||||
return value as unknown as PreparationWithdrawalResult
|
return value as unknown as PreparationWithdrawalResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeletableSessionKind = 'proposal' | 'draft' | 'history'
|
||||||
|
|
||||||
|
export async function deleteSessionResource(
|
||||||
|
kind: DeletableSessionKind,
|
||||||
|
identity: string,
|
||||||
|
revision: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const csrf = await mutationCsrfToken()
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Trainlog-CSRF-Token': csrf,
|
||||||
|
'X-Trainlog-Request-ID': requestId(),
|
||||||
|
'If-Match': `"${revision}"`,
|
||||||
|
},
|
||||||
|
body: '{}',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const value: unknown = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
const reason = object(value) && typeof value.error === 'string'
|
||||||
|
? value.error
|
||||||
|
: `HTTP ${response.status}`
|
||||||
|
throw new Error(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
79
web/src/components/DeleteConfirmationDialog.tsx
Normal file
79
web/src/components/DeleteConfirmationDialog.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useEffect, useRef, type RefObject } from 'react'
|
||||||
|
|
||||||
|
interface DeleteConfirmationDialogProps {
|
||||||
|
title: string
|
||||||
|
itemType: string
|
||||||
|
date?: string | null
|
||||||
|
consequence: string
|
||||||
|
cancelLabel: string
|
||||||
|
confirmLabel: string
|
||||||
|
busy: boolean
|
||||||
|
error: string
|
||||||
|
returnFocus: RefObject<HTMLElement | null>
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmationDialog(props: DeleteConfirmationDialogProps) {
|
||||||
|
const dialog = useRef<HTMLDivElement>(null)
|
||||||
|
const cancel = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancel.current?.focus()
|
||||||
|
const returnTarget = props.returnFocus.current
|
||||||
|
return () => returnTarget?.focus()
|
||||||
|
}, [props.returnFocus])
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'Escape' && !props.busy) {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return
|
||||||
|
const buttons = dialog.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')
|
||||||
|
if (!buttons || buttons.length === 0) return
|
||||||
|
const first = buttons[0]
|
||||||
|
const last = buttons[buttons.length - 1]
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="delete-dialog-backdrop">
|
||||||
|
<div
|
||||||
|
ref={dialog}
|
||||||
|
className="delete-confirmation"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-dialog-title"
|
||||||
|
aria-describedby="delete-dialog-consequence"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<p className="eyebrow">{props.itemType}</p>
|
||||||
|
<h2 id="delete-dialog-title">{props.confirmLabel} « {props.title} » ?</h2>
|
||||||
|
{props.date && <p>{props.date}</p>}
|
||||||
|
<p id="delete-dialog-consequence">{props.consequence}</p>
|
||||||
|
{props.error && <p className="form-error" role="alert">{props.error}</p>}
|
||||||
|
<div className="confirmation-actions">
|
||||||
|
<button
|
||||||
|
ref={cancel}
|
||||||
|
type="button"
|
||||||
|
className="quiet-action"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={props.onCancel}
|
||||||
|
>{props.cancelLabel}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-action"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={props.onConfirm}
|
||||||
|
>{props.busy ? 'Suppression…' : props.confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
151
web/src/routes/ProgramsTab.test.tsx
Normal file
151
web/src/routes/ProgramsTab.test.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ProgramsTab } from './ProgramsTab'
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown, headers?: HeadersInit) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers(headers),
|
||||||
|
json: () => Promise.resolve(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function programPage() {
|
||||||
|
return {
|
||||||
|
api_version: 1,
|
||||||
|
offset: 0,
|
||||||
|
more: false,
|
||||||
|
next_offset: 0,
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(imported: boolean) {
|
||||||
|
return {
|
||||||
|
api_version: 1,
|
||||||
|
program_id: 'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||||
|
title: 'Cycle test',
|
||||||
|
start_date: '2026-09-21',
|
||||||
|
end_date: null,
|
||||||
|
payload_sha256: 'a'.repeat(64),
|
||||||
|
session_count: 2,
|
||||||
|
unknown_exercise_count: 0,
|
||||||
|
warnings: [],
|
||||||
|
imported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Programs tab', () => {
|
||||||
|
it('shows a server preview before the only importing mutation', async () => {
|
||||||
|
const onOpen = vi.fn()
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url.startsWith('/api/v1/sessions/programs?')) {
|
||||||
|
return jsonResponse(programPage())
|
||||||
|
}
|
||||||
|
if (url === '/api/v1/sync/status') {
|
||||||
|
return jsonResponse(
|
||||||
|
{ phase: 'idle', result: 'ok' },
|
||||||
|
{ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (url === '/api/v1/sessions/programs/validate') {
|
||||||
|
return jsonResponse(preview(false))
|
||||||
|
}
|
||||||
|
if (url === '/api/v1/sessions/programs/import') {
|
||||||
|
return jsonResponse(preview(true))
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request ${url}`)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
render(<ProgramsTab detailProgramId={null} onOpen={onOpen} onBack={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(screen.getByText('Aucun programme.')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||||
|
const file = new File(['{"format":"trainlog-program"}'], 'cycle.json', {
|
||||||
|
type: 'application/json',
|
||||||
|
})
|
||||||
|
fireEvent.change(input, { target: { files: [file] } })
|
||||||
|
|
||||||
|
const previewPanel = await screen.findByRole('region', { name: 'Aperçu avant import' })
|
||||||
|
expect(previewPanel).toHaveTextContent('Cycle test')
|
||||||
|
expect(previewPanel).toHaveTextContent('2 séances')
|
||||||
|
expect(previewPanel).toHaveTextContent('Aucune donnée n’a encore été enregistrée.')
|
||||||
|
expect(fetchMock.mock.calls.filter(([url]) =>
|
||||||
|
String(url) === '/api/v1/sessions/programs/import')).toHaveLength(0)
|
||||||
|
|
||||||
|
fireEvent.click(within(previewPanel).getByRole('button', { name: 'Importer' }))
|
||||||
|
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(
|
||||||
|
'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||||
|
))
|
||||||
|
expect(fetchMock.mock.calls.filter(([url]) =>
|
||||||
|
String(url) === '/api/v1/sessions/programs/import')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates one preparation from a program session and navigates to it', async () => {
|
||||||
|
window.history.replaceState(null, '', '/seances/program/pg_test')
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url === '/api/v1/sessions/program/pg_test') {
|
||||||
|
return jsonResponse({
|
||||||
|
api_version: 1,
|
||||||
|
program_id: 'pg_test',
|
||||||
|
title: 'Cycle test',
|
||||||
|
note: null,
|
||||||
|
state: 'active',
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
created_at: '2026-09-18T12:00:00Z',
|
||||||
|
updated_at: '2026-09-18T12:00:00Z',
|
||||||
|
revision_id: 'revision',
|
||||||
|
source_format: 'trainlog-program',
|
||||||
|
source_version: 1,
|
||||||
|
source_payload_sha256: 'a'.repeat(64),
|
||||||
|
sessions: [{
|
||||||
|
program_session_id: 'pgs_test',
|
||||||
|
position: 0,
|
||||||
|
title: 'Séance A',
|
||||||
|
session_type: 'training',
|
||||||
|
planned_for: null,
|
||||||
|
note: null,
|
||||||
|
occurrences: [{
|
||||||
|
entry_id: 'pge_test',
|
||||||
|
position: 0,
|
||||||
|
exercise_id: 'ex_test',
|
||||||
|
equipment_id: null,
|
||||||
|
recording_mode: 'sets',
|
||||||
|
tracking_mode: 'reps',
|
||||||
|
data_fields: 0,
|
||||||
|
load_mode: 'none',
|
||||||
|
rest_seconds: 90,
|
||||||
|
target_sets: 3,
|
||||||
|
target_reps: 8,
|
||||||
|
target_duration_seconds: null,
|
||||||
|
target_weight_kg: null,
|
||||||
|
notes: null,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/v1/sync/status') {
|
||||||
|
return jsonResponse(
|
||||||
|
{ phase: 'idle', result: 'ok' },
|
||||||
|
{ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (url.endsWith('/sessions/pgs_test/prepare')) {
|
||||||
|
return jsonResponse({ preparation_id: 'sp_created' })
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request ${url}`)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
render(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Créer une préparation' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(window.location.pathname).toBe('/seances/preparation/sp_created'))
|
||||||
|
expect(fetchMock.mock.calls.filter(([, options]) =>
|
||||||
|
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -101,6 +101,18 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
||||||
{' · '}{session.occurrences.length} exercice
|
{' · '}{session.occurrences.length} exercice
|
||||||
{session.occurrences.length > 1 ? 's' : ''}
|
{session.occurrences.length > 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
<ol className="program-occurrence-list">
|
||||||
|
{session.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
|
||||||
|
<span>{occurrence.exercise_id}</span>
|
||||||
|
<small>
|
||||||
|
{occurrence.target_sets ?? '—'} série
|
||||||
|
{occurrence.target_sets === 1 ? '' : 's'} ·{' '}
|
||||||
|
{occurrence.tracking_mode === 'reps'
|
||||||
|
? `${occurrence.target_reps ?? '—'} répétitions`
|
||||||
|
: `${occurrence.target_duration_seconds ?? '—'} s`}
|
||||||
|
</small>
|
||||||
|
</li>)}
|
||||||
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -219,11 +231,24 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
|
||||||
</div>
|
</div>
|
||||||
{preview && <section className="import-preview" aria-labelledby="import-preview-title">
|
{preview && <section className="import-preview" aria-labelledby="import-preview-title">
|
||||||
<h2 id="import-preview-title">Aperçu avant import</h2>
|
<h2 id="import-preview-title">Aperçu avant import</h2>
|
||||||
<p><strong>{fileName}</strong></p>
|
<p><strong>{preview.title}</strong> · fichier {fileName}</p>
|
||||||
<p>
|
<p>
|
||||||
Identité : {preview.program_id} · {preview.session_count} séance
|
{preview.session_count} séance
|
||||||
{preview.session_count > 1 ? 's' : ''}
|
{preview.session_count > 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
{preview.start_date
|
||||||
|
? formatCivilDate(preview.start_date, dateFormat)
|
||||||
|
: 'Début non défini'}
|
||||||
|
{' — '}
|
||||||
|
{preview.end_date
|
||||||
|
? formatCivilDate(preview.end_date, dateFormat)
|
||||||
|
: 'fin non définie'}
|
||||||
|
</p>
|
||||||
|
<p>{preview.unknown_exercise_count} identité d’exercice inconnue.</p>
|
||||||
|
{preview.warnings.length > 0 && <ul>
|
||||||
|
{preview.warnings.map((warning) => <li key={warning}>{warning}</li>)}
|
||||||
|
</ul>}
|
||||||
<p>Aucune donnée n’a encore été enregistrée.</p>
|
<p>Aucune donnée n’a encore été enregistrée.</p>
|
||||||
<div className="confirmation-actions">
|
<div className="confirmation-actions">
|
||||||
<button type="button" onClick={cancelImport}>Annuler</button>
|
<button type="button" onClick={cancelImport}>Annuler</button>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,14 @@ describe('Sessions page', () => {
|
||||||
}))
|
}))
|
||||||
render(<SessionsPage />)
|
render(<SessionsPage />)
|
||||||
await waitFor(() => expect(screen.getByText('Proposition cible')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('Proposition cible')).toBeInTheDocument())
|
||||||
expect(screen.getByText('Proposition IA')).toBeInTheDocument()
|
expect(screen.getByText('PROPOSITION IA')).toBeInTheDocument()
|
||||||
|
const trash = screen.getByRole('button', { name: 'Supprimer la proposition' })
|
||||||
|
fireEvent.click(trash)
|
||||||
|
expect(window.location.pathname).toBe('/seances')
|
||||||
|
fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', {
|
||||||
|
name: 'Conserver',
|
||||||
|
}))
|
||||||
|
await waitFor(() => expect(trash).toHaveFocus())
|
||||||
fireEvent.click(screen.getByText('Proposition cible'))
|
fireEvent.click(screen.getByText('Proposition cible'))
|
||||||
expect(window.location.pathname).toBe('/seances/proposal/aid_target')
|
expect(window.location.pathname).toBe('/seances/proposal/aid_target')
|
||||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Proposition cible' })).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByRole('heading', { name: 'Proposition cible' })).toBeInTheDocument())
|
||||||
|
|
@ -153,14 +160,20 @@ describe('Sessions page', () => {
|
||||||
const deleteTrigger = screen.getByRole('button', { name: 'Supprimer la préparation' })
|
const deleteTrigger = screen.getByRole('button', { name: 'Supprimer la préparation' })
|
||||||
fireEvent.click(deleteTrigger)
|
fireEvent.click(deleteTrigger)
|
||||||
let dialog = screen.getByRole('alertdialog')
|
let dialog = screen.getByRole('alertdialog')
|
||||||
expect(dialog).toHaveTextContent('toute exécution commencée sera conservée')
|
expect(dialog).toHaveTextContent('sans effacer un entraînement réel')
|
||||||
const keepButton = within(dialog).getByRole('button', { name: 'Conserver la préparation' })
|
const keepButton = within(dialog).getByRole('button', { name: 'Conserver' })
|
||||||
expect(keepButton).toHaveFocus()
|
expect(keepButton).toHaveFocus()
|
||||||
fireEvent.click(keepButton)
|
fireEvent.click(keepButton)
|
||||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||||
await waitFor(() => expect(deleteTrigger).toHaveFocus())
|
await waitFor(() => expect(deleteTrigger).toHaveFocus())
|
||||||
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
|
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' }))
|
||||||
|
dialog = screen.getByRole('alertdialog')
|
||||||
|
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||||
|
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||||
|
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' }))
|
||||||
dialog = screen.getByRole('alertdialog')
|
dialog = screen.getByRole('alertdialog')
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer la préparation' }))
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer la préparation' }))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
|
deleteSessionResource,
|
||||||
fetchSessionDetail,
|
fetchSessionDetail,
|
||||||
fetchReadonlySessionDetail,
|
fetchReadonlySessionDetail,
|
||||||
prepareForAndroid,
|
prepareForAndroid,
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
type SessionDetail,
|
type SessionDetail,
|
||||||
type SessionListItem,
|
type SessionListItem,
|
||||||
} from '../api/sessions'
|
} from '../api/sessions'
|
||||||
|
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
|
||||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
import {
|
import {
|
||||||
formatCivilDate,
|
formatCivilDate,
|
||||||
|
|
@ -40,6 +42,53 @@ function viewLabel(view: View): string {
|
||||||
|
|
||||||
export type SessionListEntry = SessionListItem & { collection: SessionCollection }
|
export type SessionListEntry = SessionListItem & { collection: SessionCollection }
|
||||||
|
|
||||||
|
function deletionKind(collection: SessionCollection): 'preparation' | 'proposal' | 'draft' | 'history' {
|
||||||
|
if (collection === 'preparations') return 'preparation'
|
||||||
|
if (collection === 'proposals') return 'proposal'
|
||||||
|
if (collection === 'drafts') return 'draft'
|
||||||
|
return 'history'
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletionLabel(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
|
||||||
|
const labels = {
|
||||||
|
preparation: 'Supprimer la préparation',
|
||||||
|
proposal: 'Supprimer la proposition',
|
||||||
|
draft: 'Abandonner le brouillon',
|
||||||
|
history: 'Supprimer la séance de l’historique',
|
||||||
|
}
|
||||||
|
return labels[kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletionType(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
|
||||||
|
const labels = {
|
||||||
|
preparation: 'PRÉPARATION MANUELLE',
|
||||||
|
proposal: 'PROPOSITION IA',
|
||||||
|
draft: 'BROUILLON D’EXÉCUTION',
|
||||||
|
history: 'SÉANCE RÉALISÉE',
|
||||||
|
}
|
||||||
|
return labels[kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletionConsequence(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
|
||||||
|
const consequences = {
|
||||||
|
preparation: 'La préparation sera retirée. Ses livraisons Android non commencées seront annulées, sans effacer un entraînement réel.',
|
||||||
|
proposal: 'La proposition sera retirée et son retrait sera propagé. Les préparations ou séances déjà dérivées seront conservées.',
|
||||||
|
draft: 'Le brouillon sera abandonné seulement s’il n’est pas actif ou modifié concurremment sur Android.',
|
||||||
|
history: 'La séance sera retirée de l’historique avec une protection empêchant sa réapparition depuis un ancien instantané.',
|
||||||
|
}
|
||||||
|
return consequences[kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletionSuccess(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string {
|
||||||
|
const messages = {
|
||||||
|
preparation: 'Préparation supprimée.',
|
||||||
|
proposal: 'Proposition supprimée.',
|
||||||
|
draft: 'Brouillon abandonné.',
|
||||||
|
history: 'Séance supprimée de l’historique.',
|
||||||
|
}
|
||||||
|
return messages[kind]
|
||||||
|
}
|
||||||
|
|
||||||
function compareIdentity(left: SessionListEntry, right: SessionListEntry): number {
|
function compareIdentity(left: SessionListEntry, right: SessionListEntry): number {
|
||||||
return `${left.collection}:${left.identity}`.localeCompare(
|
return `${left.collection}:${left.identity}`.localeCompare(
|
||||||
`${right.collection}:${right.identity}`, 'en')
|
`${right.collection}:${right.identity}`, 'en')
|
||||||
|
|
@ -250,8 +299,7 @@ function Editor({ initial, onSaved, onCancel }: {
|
||||||
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
|
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
|
||||||
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
|
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
|
||||||
const { dateFormat } = useDatePreferences()
|
const { dateFormat } = useDatePreferences()
|
||||||
const deleteTrigger = useRef<HTMLButtonElement>(null)
|
const deleteTrigger = useRef<HTMLElement | null>(null)
|
||||||
const cancelDelete = useRef<HTMLButtonElement>(null)
|
|
||||||
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
|
@ -265,9 +313,6 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
|
||||||
if (!controller.signal.aborted) setFailed(true)
|
if (!controller.signal.aborted) setFailed(true)
|
||||||
})
|
})
|
||||||
return () => controller.abort() }, [kind, identity])
|
return () => controller.abort() }, [kind, identity])
|
||||||
useEffect(() => {
|
|
||||||
if (confirming) cancelDelete.current?.focus()
|
|
||||||
}, [confirming])
|
|
||||||
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
||||||
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
||||||
const withdrawn = detail.state === 'withdrawn'
|
const withdrawn = detail.state === 'withdrawn'
|
||||||
|
|
@ -275,17 +320,20 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
setDeleteError('')
|
setDeleteError('')
|
||||||
try {
|
try {
|
||||||
const result = await withdrawPreparation(detail.identity, detail.revision_id)
|
if (kind === 'preparation') {
|
||||||
setConfirming(false)
|
const result = await withdrawPreparation(detail.identity, detail.revision_id)
|
||||||
setMessage(result.android_cancellation === 'pending'
|
setMessage(result.android_cancellation === 'pending'
|
||||||
? 'Supprimée sur le PC — annulation Android à synchroniser.'
|
? 'Supprimée sur le PC — annulation Android à synchroniser.'
|
||||||
: 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.')
|
: 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.')
|
||||||
setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id })
|
setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id })
|
||||||
try {
|
} else {
|
||||||
setDetail(await fetchSessionDetail('preparation', detail.identity))
|
await deleteSessionResource('proposal', detail.identity, detail.revision_id)
|
||||||
} catch {
|
setMessage('Proposition supprimée. Son retrait sera propagé sans toucher aux préparations dérivées.')
|
||||||
setMessage((current) => `${current} Rechargez la fiche pour obtenir l’état synchronisé.`)
|
setConfirming(false)
|
||||||
|
onBack()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setConfirming(false)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
|
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -297,27 +345,28 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
|
||||||
{!withdrawn && <div className="detail-actions">
|
{!withdrawn && <div className="detail-actions">
|
||||||
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
|
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
|
||||||
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button>
|
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button>
|
||||||
{kind === 'preparation' && <button ref={deleteTrigger} type="button" className="danger-action"
|
<button
|
||||||
onClick={() => setConfirming(true)}>Supprimer la préparation</button>}
|
ref={(element) => { deleteTrigger.current = element }}
|
||||||
|
type="button"
|
||||||
|
className="danger-action"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>{deletionLabel(kind)}</button>
|
||||||
</div>}</div>
|
</div>}</div>
|
||||||
{message && <p className="success-panel" role="status">{message}</p>}
|
{message && <p className="success-panel" role="status">{message}</p>}
|
||||||
{deleteError && <p className="form-error" role="alert">{deleteError}</p>}
|
{deleteError && <p className="form-error" role="alert">{deleteError}</p>}
|
||||||
{confirming && <section className="delete-confirmation" role="alertdialog" aria-modal="true"
|
{confirming && <DeleteConfirmationDialog
|
||||||
aria-labelledby="delete-preparation-title">
|
title={detail.title || 'Sans titre'}
|
||||||
<h3 id="delete-preparation-title">Supprimer « {detail.title || 'Sans titre'} » ?</h3>
|
itemType={deletionType(kind)}
|
||||||
<p>{detail.state === 'local'
|
date={detail.planned_for ? formatCivilDate(detail.planned_for, dateFormat) : null}
|
||||||
? 'La préparation sera retirée du planning. Elle n’a aucune livraison Android connue.'
|
consequence={deletionConsequence(kind)}
|
||||||
: 'La préparation sera retirée du planning. Ses livraisons Android non commencées seront annulées lors de la prochaine synchronisation ; toute exécution commencée sera conservée.'}</p>
|
cancelLabel="Conserver"
|
||||||
<div className="confirmation-actions">
|
confirmLabel={deletionLabel(kind)}
|
||||||
<button ref={cancelDelete} type="button" className="quiet-action" disabled={deleting}
|
busy={deleting}
|
||||||
onClick={() => {
|
error={deleteError}
|
||||||
setConfirming(false)
|
returnFocus={deleteTrigger}
|
||||||
requestAnimationFrame(() => deleteTrigger.current?.focus())
|
onCancel={() => setConfirming(false)}
|
||||||
}}>Conserver la préparation</button>
|
onConfirm={() => void remove()}
|
||||||
<button type="button" className="danger-action" disabled={deleting}
|
/>}
|
||||||
onClick={() => void remove()}>{deleting ? 'Suppression…' : 'Supprimer la préparation'}</button>
|
|
||||||
</div>
|
|
||||||
</section>}
|
|
||||||
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
|
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
|
||||||
<h2>{detail.title || 'Sans titre'}</h2><p>
|
<h2>{detail.title || 'Sans titre'}</h2><p>
|
||||||
{detail.planned_for === null ? 'Non planifiée' : <time dateTime={detail.planned_for}>
|
{detail.planned_for === null ? 'Non planifiée' : <time dateTime={detail.planned_for}>
|
||||||
|
|
@ -351,8 +400,12 @@ function ReadonlyDetail({ kind, identity, onBack }: {
|
||||||
kind: 'draft' | 'history'; identity: string; onBack: () => void
|
kind: 'draft' | 'history'; identity: string; onBack: () => void
|
||||||
}) {
|
}) {
|
||||||
const { dateFormat } = useDatePreferences()
|
const { dateFormat } = useDatePreferences()
|
||||||
|
const deleteTrigger = useRef<HTMLElement | null>(null)
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState('')
|
||||||
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
|
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
|
||||||
fetchReadonlySessionDetail(kind, identity, controller.signal).then((value) => {
|
fetchReadonlySessionDetail(kind, identity, controller.signal).then((value) => {
|
||||||
if (!controller.signal.aborted) setDetail(value)
|
if (!controller.signal.aborted) setDetail(value)
|
||||||
|
|
@ -367,8 +420,47 @@ function ReadonlyDetail({ kind, identity, onBack }: {
|
||||||
? detail.payload as Record<string, unknown> : null
|
? detail.payload as Record<string, unknown> : null
|
||||||
const startedAt = typeof detail.started_at === 'string' ? detail.started_at : null
|
const startedAt = typeof detail.started_at === 'string' ? detail.started_at : null
|
||||||
const endedAt = typeof detail.ended_at === 'string' ? detail.ended_at : null
|
const endedAt = typeof detail.ended_at === 'string' ? detail.ended_at : null
|
||||||
|
const revision = typeof detail.revision_id === 'string' ? detail.revision_id : ''
|
||||||
|
const title = kind === 'draft'
|
||||||
|
? String(detail.session_type ?? 'Brouillon')
|
||||||
|
: String(detail.session_type ?? 'Séance')
|
||||||
|
const remove = async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
setDeleteError('')
|
||||||
|
try {
|
||||||
|
await deleteSessionResource(kind, identity, revision)
|
||||||
|
setConfirming(false)
|
||||||
|
onBack()
|
||||||
|
} catch (reason) {
|
||||||
|
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
return <article className="session-detail"><div className="sessions-toolbar">
|
return <article className="session-detail"><div className="sessions-toolbar">
|
||||||
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
|
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
|
||||||
|
<button
|
||||||
|
ref={(element) => { deleteTrigger.current = element }}
|
||||||
|
type="button"
|
||||||
|
className="danger-action"
|
||||||
|
disabled={!revision}
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>{deletionLabel(kind)}</button>
|
||||||
|
</div>
|
||||||
|
{deleteError && !confirming && <p className="form-error" role="alert">{deleteError}</p>}
|
||||||
|
{confirming && <DeleteConfirmationDialog
|
||||||
|
title={title}
|
||||||
|
itemType={deletionType(kind)}
|
||||||
|
date={startedAt ? formatDateTime(startedAt, dateFormat) : null}
|
||||||
|
consequence={deletionConsequence(kind)}
|
||||||
|
cancelLabel="Conserver"
|
||||||
|
confirmLabel={deletionLabel(kind)}
|
||||||
|
busy={deleting}
|
||||||
|
error={deleteError}
|
||||||
|
returnFocus={deleteTrigger}
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
/>}
|
||||||
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
|
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
|
||||||
<h2>{String(detail.session_type ?? 'Séance')}</h2>
|
<h2>{String(detail.session_type ?? 'Séance')}</h2>
|
||||||
<p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
|
<p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
|
||||||
|
|
@ -402,6 +494,11 @@ export function SessionsPage() {
|
||||||
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 [deleteTarget, setDeleteTarget] = useState<SessionListEntry | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState('')
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const deleteReturnFocus = useRef<HTMLElement | null>(null)
|
||||||
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
|
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
|
||||||
const loadRevision = useRef(0)
|
const loadRevision = useRef(0)
|
||||||
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
|
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
|
||||||
|
|
@ -458,6 +555,34 @@ export function SessionsPage() {
|
||||||
return owner === undefined ? `État non reconnu (${state})`
|
return owner === undefined ? `État non reconnu (${state})`
|
||||||
: sessionStateLabel(owner.collection, state)
|
: sessionStateLabel(owner.collection, state)
|
||||||
}
|
}
|
||||||
|
const confirmRowDeletion = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
const kind = deletionKind(deleteTarget.collection)
|
||||||
|
setDeleting(true)
|
||||||
|
setDeleteError('')
|
||||||
|
try {
|
||||||
|
if (kind === 'preparation' || kind === 'proposal') {
|
||||||
|
const detail = await fetchSessionDetail(kind, deleteTarget.identity)
|
||||||
|
if (kind === 'preparation') {
|
||||||
|
await withdrawPreparation(detail.identity, detail.revision_id)
|
||||||
|
} else {
|
||||||
|
await deleteSessionResource(kind, detail.identity, detail.revision_id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const detail = await fetchReadonlySessionDetail(kind, deleteTarget.identity)
|
||||||
|
const revision = typeof detail.revision_id === 'string' ? detail.revision_id : ''
|
||||||
|
if (!revision) throw new Error('Cette ressource ne possède pas de révision supprimable.')
|
||||||
|
await deleteSessionResource(kind, deleteTarget.identity, revision)
|
||||||
|
}
|
||||||
|
setDeleteTarget(null)
|
||||||
|
setStatusMessage(deletionSuccess(kind))
|
||||||
|
load()
|
||||||
|
} catch (reason) {
|
||||||
|
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
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')) {
|
||||||
|
|
@ -495,6 +620,7 @@ export function SessionsPage() {
|
||||||
onBack={back}
|
onBack={back}
|
||||||
/>}
|
/>}
|
||||||
{view !== 'programs' && <>
|
{view !== 'programs' && <>
|
||||||
|
{statusMessage && <p className="success-panel" role="status">{statusMessage}</p>}
|
||||||
<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">
|
<div className="session-filters" aria-label="Filtres de séances">
|
||||||
|
|
@ -508,13 +634,48 @@ export function SessionsPage() {
|
||||||
<p className="sort-indicator">Date décroissante</p>
|
<p className="sort-indicator">Date décroissante</p>
|
||||||
{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 && visibleItems.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">{visibleItems.map((item) => <button type="button" className="session-row"
|
<div className="session-list">{visibleItems.map((item) => {
|
||||||
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
|
const kind = deletionKind(item.collection)
|
||||||
<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>
|
return <div className="session-row" key={`${item.collection}-${item.identity}`}>
|
||||||
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date === null
|
<button
|
||||||
? item.collection === 'preparations' || item.collection === 'proposals' ? 'Non planifiée' : 'Date inconnue'
|
type="button"
|
||||||
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
|
className="session-row-main"
|
||||||
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
|
onClick={() => open(item.collection, item.identity)}
|
||||||
<span>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div></>}
|
>
|
||||||
|
<span><small>{deletionType(kind)}</small><strong>{item.title || 'Sans titre'}</strong></span>
|
||||||
|
<span>{item.date === null
|
||||||
|
? item.collection === 'preparations' || item.collection === 'proposals'
|
||||||
|
? 'Non planifiée'
|
||||||
|
: 'Date inconnue'
|
||||||
|
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
|
||||||
|
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
|
||||||
|
<span>{sessionStateLabel(item.collection, item.state)}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trash-action"
|
||||||
|
aria-label={deletionLabel(kind)}
|
||||||
|
title={deletionLabel(kind)}
|
||||||
|
onClick={(event) => {
|
||||||
|
deleteReturnFocus.current = event.currentTarget
|
||||||
|
setDeleteError('')
|
||||||
|
setDeleteTarget(item)
|
||||||
|
}}
|
||||||
|
><span aria-hidden="true">🗑</span></button>
|
||||||
|
</div>
|
||||||
|
})}</div>
|
||||||
|
{deleteTarget && <DeleteConfirmationDialog
|
||||||
|
title={deleteTarget.title || 'Sans titre'}
|
||||||
|
itemType={deletionType(deletionKind(deleteTarget.collection))}
|
||||||
|
date={deleteTarget.date ? formatCivilDate(deleteTarget.date, dateFormat) : null}
|
||||||
|
consequence={deletionConsequence(deletionKind(deleteTarget.collection))}
|
||||||
|
cancelLabel="Conserver"
|
||||||
|
confirmLabel={deletionLabel(deletionKind(deleteTarget.collection))}
|
||||||
|
busy={deleting}
|
||||||
|
error={deleteError}
|
||||||
|
returnFocus={deleteReturnFocus}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => void confirmRowDeletion()}
|
||||||
|
/>}</>}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,31 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
||||||
.sort-indicator { width: max-content; margin: -.5rem 0 1rem; padding: .35rem .55rem; color: var(--lavender); background: rgb(180 190 254 / 8%); border-radius: .4rem; font: 650 var(--font-size-xs) var(--font-mono); }
|
.sort-indicator { width: max-content; margin: -.5rem 0 1rem; padding: .35rem .55rem; color: var(--lavender); background: rgb(180 190 254 / 8%); border-radius: .4rem; font: 650 var(--font-size-xs) var(--font-mono); }
|
||||||
.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 {
|
||||||
.session-row:hover { border-color: var(--lavender); background: var(--base); }
|
align-items: stretch;
|
||||||
|
background: var(--mantle);
|
||||||
|
border: 1px solid var(--surface-1);
|
||||||
|
border-radius: .75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.session-row:hover { border-color: var(--lavender); }
|
||||||
|
.session-row-main {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr));
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.session-row-main:hover { background: var(--base); }
|
||||||
.session-row span:first-child { display: grid; gap: .2rem; }
|
.session-row span:first-child { display: grid; gap: .2rem; }
|
||||||
.session-row small { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
|
.session-row small { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
|
||||||
.sessions-editor, .session-detail { padding: clamp(1rem, 3vw, 2.25rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
|
.sessions-editor, .session-detail { padding: clamp(1rem, 3vw, 2.25rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
|
||||||
|
|
@ -112,6 +135,37 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
||||||
.delete-confirmation { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .75rem; background: var(--base); }
|
.delete-confirmation { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .75rem; background: var(--base); }
|
||||||
.delete-confirmation h3 { margin-top: 0; }
|
.delete-confirmation h3 { margin-top: 0; }
|
||||||
.delete-confirmation p { color: var(--subtext-0); line-height: 1.55; }
|
.delete-confirmation p { color: var(--subtext-0); line-height: 1.55; }
|
||||||
|
.delete-dialog-backdrop {
|
||||||
|
align-items: center;
|
||||||
|
background: rgb(17 17 27 / 78%);
|
||||||
|
display: flex;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.delete-dialog-backdrop .delete-confirmation {
|
||||||
|
margin: 0;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
max-width: 34rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.trash-action {
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--red);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
.trash-action:hover,
|
||||||
|
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
|
||||||
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
||||||
.program-session-list { padding: 0; list-style: none; }
|
.program-session-list { padding: 0; list-style: none; }
|
||||||
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
|
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
|
||||||
|
|
@ -124,8 +178,19 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
||||||
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
||||||
.detail-tile code { overflow-wrap: anywhere; color: var(--lavender); }
|
.detail-tile code { overflow-wrap: anywhere; color: var(--lavender); }
|
||||||
.draft-payload { max-height: 32rem; overflow: auto; color: var(--subtext-0); font: var(--font-size-sm)/1.6 var(--font-mono); white-space: pre-wrap; }
|
.draft-payload { max-height: 32rem; overflow: auto; color: var(--subtext-0); font: var(--font-size-sm)/1.6 var(--font-mono); white-space: pre-wrap; }
|
||||||
@media (max-width: 52rem) { .session-row { grid-template-columns: 1fr 1fr; } .form-grid { grid-template-columns: 1fr 1fr; } .occurrence-fields { grid-template-columns: 1fr 1fr; } }
|
@media (max-width: 52rem) {
|
||||||
@media (max-width: 30rem) { .session-row, .form-grid, .occurrence-fields { grid-template-columns: 1fr; } .session-tabs { width: 100%; overflow-x: auto; } .session-tabs button { white-space: nowrap; } .editor-actions { align-items: stretch; flex-direction: column; } }
|
.session-row-main { grid-template-columns: 1fr 1fr; }
|
||||||
|
.form-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.occurrence-fields { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
.session-row-main,
|
||||||
|
.form-grid,
|
||||||
|
.occurrence-fields { grid-template-columns: 1fr; }
|
||||||
|
.session-tabs { width: 100%; overflow-x: auto; }
|
||||||
|
.session-tabs button { white-space: nowrap; }
|
||||||
|
.editor-actions { align-items: stretch; flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-layout { min-width: 0; }
|
.dashboard-layout { min-width: 0; }
|
||||||
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
|
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue