Compare commits

..

7 commits

75 changed files with 8107 additions and 4268 deletions

View file

@ -294,6 +294,11 @@ flow, declarations, assertions, or cleanup onto one line to reduce line or
token count. Grouped missions may and should use small comprehensible commits.
Perform an explicit readability review before the final commit.
Readable source is a blocking acceptance criterion. Every new or modified C/H
file must pass the changed-file `clang-format` check; tests and fixtures follow
the same rule. Run `tools/check_changed_c_format.sh <base-revision>` without
formatting unrelated historical files.
Canonical documents:
- `README.md`;

View file

@ -2,6 +2,32 @@
## Unreleased — 0.1.2
- Completed the private grouped rollout of Web Sessions deletion and Program
V1 from desktop source `a0fc48f082b76dba2ae3a56fceb492169de89f89`.
Desktop migrated from schema v24 to v25 after backup, restoration, and
copy-migration verification. Android was updated in place to private
versionCode 15 with no uninstall or data clearing; the installed APK is
non-debuggable and retains the daily-installation certificate. Exact
pre/post-update logical comparison preserved all 46 Android tables and 1,023
rows. A preparation created explicitly as disposable was withdrawn through
the real 390×844 confirmation UI. Two full-generation exchanges around a
Trainlog-only restart retained exactly one Android withdrawal result, no
matching delivery or performed session, and no resurrection or duplication.
- Completed Web Sessions deletion and Program V1 on desktop schema v25. Separate
revision-guarded Core commands now withdraw manual preparations and AI
proposals or causally delete inactive execution drafts and selected completed
sessions. The accessible trash confirmation preserves derived preparations,
active/concurrent drafts and unrelated performed history; proposal withdrawals
use the bounded `trainlog-ai-session-drafts` V2 tombstone extension to prevent
legacy replay resurrection on Android.
- Added Programs as the fourth Sessions subtab with complete paged listing,
search/state filtering, stable detail links, archival, strict bounded
`trainlog-program` V1 import preview and transactional commit. An explicit
program-session action creates a distinct provenance-bearing manual
preparation without automatic Android delivery or performed data.
- Completed the controlled Web Sessions presentation/withdrawal rollout on the
paired private installation. Desktop migrated from schema v23 to v24 and
Android from v22 to v23 without clearing application data. The requested

View file

@ -17,13 +17,15 @@ over direct MTP; it never copies SQLite database files between devices.
|---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, detail, manual preparation, and Android-delivery workflow through typed Trainlog Core/API boundaries. Analyse, Programmes, and Exercises remain placeholders. |
| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and embedded Programs workflow through typed Trainlog Core/API boundaries. Analyse, the standalone Programmes route, and Exercises remain placeholders. |
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
visualizations, and private layout persistence are implemented. This does not
make the other four Web routes functional or publish version 0.1.2.
make the remaining placeholder routes functional or publish version 0.1.2.
Programs is intentionally implemented as a Sessions subtab; the standalone
top-level Programmes route remains a placeholder.
## Releases
@ -318,7 +320,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-18:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v24 and Android SQLite schema v23;
- desktop SQLite schema v25 and Android SQLite schema v23;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
@ -331,7 +333,8 @@ As of 2026-09-18:
Android-triggered bidirectional smoke test.
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
Analyse, Programmes, and Exercises remain placeholders;
Programs is implemented inside Sessions; Analyse, Exercises, and the
standalone top-level Programmes route remain placeholders;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The trusted local

View file

@ -69,7 +69,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
)
private val SUPPORTED =
(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))
.map { it.format to it.version }
.toSet()

View file

@ -1721,15 +1721,26 @@ class TrainlogRepository(
val root = try { JSONObject(json) } catch (_: Exception) {
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("version").isExactJsonInteger(1) ||
(!version.isExactJsonInteger(1) && !version.isExactJsonInteger(2)) ||
!root.value("generated_at").isNonemptyJsonString() || root.value("drafts") !is JSONArray ||
!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")
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.")
val db = database.writableDatabase
@ -1847,6 +1858,31 @@ class TrainlogRepository(
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()
AiSessionDraftImportResult.Applied(imported, skipped)
} catch (error: IllegalArgumentException) {

View file

@ -62,6 +62,41 @@ class AiSessionDraftRepositoryTest {
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
fun pendingDraftIsListed() {
val exercise = exercise()

View file

@ -16,6 +16,8 @@ evidence and chronology.
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
history and Android-delivery workflow.
- [Program format V1](program_format_v1.md) owns the strict desktop planning
import contract used by the Sessions Programs subtab.
- [Android](android.md) owns field-companion capture, local persistence,
glanceable summaries, permissions, and Android interaction rules.

View file

@ -6,7 +6,9 @@ preparation is inert until explicit start; an occupied singleton is never
overwritten. Starting preserves the desktop-reserved execution `session_id`,
copies targets only and creates no performed facts.
Android schema v23 adds the durable manual-preparation withdrawal ledger.
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
state stores the V2 companion's `deleted` tombstone.
Consumption is part of the outer generation transaction. Exact pending
deliveries are changed to `cancelled`; exact started deliveries remain started,
and their active or completed execution data is untouched. The stored result is
@ -93,7 +95,9 @@ most 64 contiguous ordered SETS entries, target sets 1..99 and REPS targets
1..999 (or the profile-required duration target). Entries must match the
resolved exercise profile and known equipment.
Pending proposals can be explicitly started or deleted. Start is one local
Pending proposals can be explicitly started or deleted. A desktop V2 proposal
withdrawal uses that same permanent tombstone boundary, removes only proposal
entries, and leaves started drafts and performed sessions untouched. Start is one local
transaction: it refuses when the singleton active draft exists, copies targets
only, marks the proposal `started`, and removes its proposal entries. Delete
is gated by a destructive confirmation naming the proposal; Cancel, Back and

View file

@ -102,24 +102,21 @@ explicit control-flow braces, vertical long parameter lists, and no short
function or control body on one line. Contract literals may exceed the target
when splitting them would make the protocol less reviewable.
Do not apply the formatter blindly to the entire historical tree. A change
normalizes the files it owns and checks that explicit scope. The reproducible,
non-mutating check for the currently normalized C readability scope is:
Readable source is a blocking acceptance criterion. Do not compress source to
save lines or model tokens. Tests and fixtures follow the same standard.
Do not apply the formatter blindly to the entire historical tree. Format the
new or modified regions, then use the reproducible, non-mutating changed-file
gate with the branch base revision:
```bash
clang-format --dry-run --Werror \
tui/src/dashboard_layout.c \
tui/src/generation_mtp.c \
tui/src/json_writer.c \
tui/src/web_dashboard.c \
tui/src/web_dashboard_json.c \
tui/include/trainlog/generation_mtp.h \
tui/tools/generation_mtp_adapter.c \
tui/tests/generation_mtp_adapter_double.c \
tui/tests/test_generation_mtp.c \
tui/tests/test_web_server.c
tools/check_changed_c_format.sh main
```
The helper derives the merge-base, includes changed and untracked C/H files,
and invokes `clang-format --dry-run --Werror` without modifying them. A
different base revision may be supplied for stacked work.
Formatting changes should not be mixed with unrelated semantic changes when
avoidable. Review readability explicitly before the final commit, including
tests and cleanup/error paths.

View file

@ -4,12 +4,17 @@ The opt-in full-generation path is operational on the private daily
installation. The loopback Web control and foreground Android coordinator use
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
persistent peer identities. The latest controlled rollout verified real
backups and restoration, desktop schema v23 to v24 and Android schema v22 to
v23 migration, signing-preserving Android replacement, the requested durable
preparation withdrawal, two complete post-withdrawal hardware conversations,
restart, and idempotent replay. Drive was not configured and remains outside
this local MTP result. This is a private 0.1.2 development deployment, not a
public release; v0.1.1 is unchanged.
desktop and Android backups, restoration and migration on copies, desktop
schema v24 to v25, and an in-place private Android update from versionCode 14
to 15. The installed APK remains `com.labfytools.trainlog` 0.1.2, is
non-debuggable, and retains certificate SHA-256
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`.
One preparation created explicitly as disposable was withdrawn through the Web
confirmation flow. Two complete generation conversations around a Trainlog-only
restart proved one durable Android `no_matching_delivery` result, no delivery
or performed session, no resurrection, and no duplication. Drive was not
configured and remains outside this local MTP result. This is a private 0.1.2
development deployment, not a public release; v0.1.1 is unchanged.
`TRAINLOG_NATIVE_BUILD_AND_CANDIDATE_V1` closes the native warning gate. Strict
GCC 16.2.1 and Clang 22.1.8 builds now pass the same 81-test inventory, and the
@ -52,7 +57,7 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v24 |
| Desktop SQLite | schema v25 |
| Android SQLite | schema v23 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
@ -75,6 +80,7 @@ parallel implementation of its rules.
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
@ -221,10 +227,11 @@ progression series, an original BODY ZONES SVG, and private versioned layout
persistence with optimistic conflict and CSRF/Origin protection. It provides no
automatic browser launch. Sessions provides complete paged preparation,
resume and history views, stable details, optimistic manual-preparation writes,
explicit proposal derivation and generation-backed Android delivery. Analyse,
Programmes and Exercises remain explicit placeholders.
explicit proposal derivation and generation-backed Android delivery. Programmes
is implemented as the fourth Sessions subtab; Analyse and Exercises remain
explicit placeholders, and no standalone Programs route exists.
Desktop schema v24 owns immutable manual-preparation revisions, stable ordered
Desktop schema v25 owns immutable manual-preparation revisions, stable ordered
occurrences, persistent HTTP idempotency keys, delivery-to-execution identity,
and durable revision-bound withdrawal records. Android schema v23 stores
received preparations and permanent withdrawal results separately from AI
@ -241,6 +248,19 @@ presentation. The preparation detail exposes a protected, idempotent **Delete
preparation** action; withdrawn rows remain technically inspectable but cannot
be edited or delivered.
Sessions rows expose independent right-edge trash actions and an accessible
destructive confirmation dialog. Proposal withdrawal preserves derived
preparations and is propagated by `trainlog-ai-session-drafts` V2; inactive
execution drafts and completed sessions use revision-guarded causal deletion.
Active or concurrently changed drafts conflict instead of being erased.
Program V1 is desktop-owned planning data in schema v25. The Sessions Programs
subtab lists, searches, filters, deep-links and archives programs. Local JSON
import is strict and bounded, previews through Core before committing, and has
deterministic replay/conflict behavior. One explicit program-session action
creates a provenance-bearing manual preparation without Android delivery or
performed work.
The controlled deployment preserved the installed Android signing identity and
advanced its private `versionCode` from 13 to 14 without uninstalling or
clearing data. The requested six-occurrence validation preparation was already

View file

@ -1,5 +1,19 @@
# Desktop database
## Schema v25: Web deletion requests and Program V1
Schema v25 adds durable Program V1 definitions, their ordered session and
occurrence children, immutable import digests, request replay records and
source provenance on derived manual preparations. It also adds the bounded Web
session-deletion request ledger and nullable AI-proposal `withdrawn_at` state.
Program import and each application command are transactional. Program rows
contain planning targets only and cannot own performed sets.
Proposal deletion is logical. Draft and completed-session deletion append an
immutable causal operation and advance deleted causal state in the same
transaction; completed sessions also receive a durable deleted finalization.
These paths do not implement a generic cascading browser delete.
## Schema v24: durable preparation withdrawal
Schema v24 adds nullable `session_preparations.withdrawn_at` and the immutable
@ -37,8 +51,8 @@ is a later, separately versioned migration.
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=23
DATABASE_SCHEMA_V23=IMPLEMENTED
TRAINLOG_DATABASE_SCHEMA_VERSION=25
DATABASE_SCHEMA_V25=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN
```
@ -57,7 +71,7 @@ PRAGMA user_version;
Current value:
```text
23
25
```
The independent actual-set loads documented in the current desktop, Android

View file

@ -4,12 +4,13 @@ Status: implementation contract for `TRAINLOG_WEB_SESSIONS_V1`.
## Scope and domain boundaries
The Sessions route has three independent views:
The Sessions route has four independent views:
- Preparation lists manual preparations and immutable imported AI proposals.
- Resume lists lifecycle-backed execution drafts that are not finalized or causally deleted.
- History lists completed sessions. A missing `ended_at` is rendered as an unknown end time; it
does not make a historical row an active draft.
- Programs lists durable planning definitions and can explicitly derive a manual preparation.
An AI proposal is never mutated by viewing it or by deriving a manual preparation. Derivation is
an explicit command which creates a new `sp_<uuid-v4>` preparation and records the proposal ID and
@ -78,6 +79,10 @@ generation containing it is published.
## Explicit exclusions
This V1 does not implement Programs, Analysis, or the standalone Exercises route. It does not add
Program V1 and its import boundary are specified separately in
[`program_format_v1.md`](../program_format_v1.md). They do not turn a program
into a template, proposal, draft or completed session.
This V1 does not implement Analysis or the standalone Exercises route. It does not add
live Web workout capture, historical correction, automatic training prescriptions, proposal
acceptance, or automatic proposal-to-execution conversion.

62
docs/program_format_v1.md Normal file
View file

@ -0,0 +1,62 @@
# Trainlog Program format V1
`trainlog-program` V1 is a strict desktop import format for planning data. It
does not change `TRAINLOG_FORMAT_V1` and is not an Android synchronization
artifact.
## Identity and ownership
A program, each ordered session definition and each ordered exercise occurrence
has a non-empty stable import identity of at most 128 UTF-8 bytes. The `pg_`,
`pgs_`, and `pge_` UUIDv4 forms are recommended creator forms, while imported
identities remain opaque. Display titles are not identities. A program is not a
reusable session template, AI proposal, manual preparation, execution draft or
completed session.
Desktop schema v25 owns the imported program and its exact source SHA-256.
Exact same-ID content replay is idempotent. The same ID with a different byte
payload is an explicit conflict. A different ID may reuse a title because titles
have no identity semantics.
## Document shape
The root has exactly `format`, `version`, and `program`, with format
`trainlog-program` and integer version `1`. The program object has exactly:
- `program_id`, `title`, nullable `note`, `state`, nullable `start_date`,
nullable `end_date`, and `sessions`;
- state `active` on import;
- 1–64 ordered sessions;
- title length 1–200 UTF-8 bytes and note length at most 4,000;
- canonical `YYYY-MM-DD` civil dates, with the end not before the start.
Each session has exactly `program_session_id`, `title`, `session_type`, nullable
`planned_for`, nullable `note`, and `occurrences`. Session type is `training` or
`max_test`; each session contains 1–64 occurrences.
Each occurrence has exactly `entry_id`, `exercise_id`, nullable `equipment_id`,
`load_mode`, `rest_seconds`, nullable `target_sets`, nullable `target_reps`,
nullable `target_duration_seconds`, nullable `target_weight_kg`, and nullable
`notes`. Exercise and equipment identities must already resolve in the desktop
catalog. Recording/tracking profiles and target/load combinations are validated
by the same Core rules used for manual preparations. No actual set, MAX result,
completion timestamp or other performed field is accepted.
The complete UTF-8 JSON document is limited to 512 KiB. Duplicate object keys,
unknown keys, non-finite numbers, invalid UTF-8, duplicate identities, excessive
collections and incompatible profiles are rejected before any row is written.
## Preview, commit and lifecycle
The Web file picker sends bytes to the Core validation endpoint first. Its
preview reports identity, title, dates, session count, unknown-exercise count
and warnings while leaving the database unchanged. Import occurs only after the
user confirms and commits all program/session/occurrence/source rows in one
transaction.
Archive is a revision-guarded lifecycle transition. It hides no derived data
and retains the source definition and digest. Creating a preparation from one
session is a separate idempotent command that copies the ordered plan and stores
program/session provenance on a new `sp_<uuid-v4>` manual preparation. It does
not create a delivery, Android object, execution draft, completed session or
performed measurement.

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View file

@ -0,0 +1,70 @@
# Web Sessions deletion and Programs V1 evidence
Date: 2026-09-18
This record covers the completed development branch and its authorized private
grouped rollout. It does not claim a tag, public release, key rotation, Android
uninstall, or application-data clearing.
## Implemented boundaries
- Separate Core commands withdraw manual preparations and AI proposals, or
causally delete inactive execution drafts and explicitly selected completed
sessions. They require current revisions and durable request identities.
- Proposal withdrawal retains its import ledger and every derived preparation.
The V2 AI-proposal companion publishes a permanent Android tombstone; an
older V1 proposal cannot recreate it. Started executions and performed work
are not proposal-deletion effects.
- The row trash action is independent from detail navigation. The accessible
confirmation dialog has initial safe focus, an Escape path, a focus trap and
trigger focus restoration.
- Program V1 remains desktop planning data. Strict preview and transactional
import share the Core validator. Archive is lifecycle state, and explicit
preparation creation retains program/session provenance without delivery or
performed data.
## Validation
- GCC/Web Meson suite: 88/88 passed.
- Clang strict suite: 86/86 passed.
- Clang ASan/UBSan suite: 86/86 passed.
- Frontend typecheck, production build and 93 Vitest tests passed.
- Android `testDebugUnitTest` and `assembleDebug` passed.
- JSON, import-contract and Python contract tests passed; the two Firefox tests
passed separately in an isolated temporary Python environment.
- The real embedded server was exercised at desktop size and at an exact
390×844 viewport. Its synthetic-only captures include the Sessions list,
destructive confirmation and Programs empty state.
- `tools/check_changed_c_format.sh main` and `git diff --check` passed.
## Controlled private rollout
- Desktop source commit:
`a0fc48f082b76dba2ae3a56fceb492169de89f89`.
- Coordinated desktop archive SHA-256:
`9f50757009fbdbe5145995daf4593caf658be695df62bd090cc91e29118202ed`.
- Private Android APK SHA-256:
`905590f84faa69ab7d4eff959f6518164c8ed7bd99e135223242a7e214755074`.
The installed package is `com.labfytools.trainlog`, versionName 0.1.2,
versionCode 15, non-debuggable, and signed by certificate SHA-256
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`.
The update used `adb install -r`; it did not uninstall or clear the app.
- The desktop schema-v24 backup passed integrity and foreign-key checks. A
restored copy retained the same SHA-256, and the v24→v25 rehearsal preserved
all 40 pre-existing tables and 820 rows before the live migration did the
same. The five new v25 tables were initially empty.
- The Android production `.tlbackup` archive passed manifest digest, SQLite
integrity, and foreign-key checks. Logical comparison before and after the
APK replacement preserved all 46 tables, 1,023 rows, and preferences.
- Real Web smoke checks passed at 1440×1000 and exactly 390×844. Program import
reached the Core preview and was cancelled before commit. The only destructive
proof used the explicitly disposable preparation
`sp_4e662fe4-f5bc-47a8-b9cb-7ed75c0cec8a`; no program or performed session
was created.
- Full-generation runs `sy_0623ed4c-eb69-40ab-ae68-bc5a57b5e3f5` and
`sy_3cf9e9bb-8631-4caa-9423-478467028ad8` completed around a restart of only
Trainlog components. Android retained exactly one correlated withdrawal with
result `no_matching_delivery`, zero corresponding deliveries, and zero
corresponding performed sessions. Desktop retained one acknowledged
withdrawal and excluded the preparation from active Web lists after replay.
- No debug APK, public-lineage APK, tag, or public release was used or created.

View file

@ -24,7 +24,7 @@ distinction between measured values and estimates.
## Current baseline
Desktop schema v24, Android schema v23, Notcurses, direct
Desktop schema v25, Android schema v23, Notcurses, direct
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft
@ -33,7 +33,9 @@ exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract, frozen interactive grid, versioned layout persistence and factual
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
visualizations and Sessions V1 are implemented; the other three Web routes remain placeholders.
visualizations and Sessions V1 are implemented. Programs is implemented inside
Sessions; Analyse, Exercises and the standalone top-level Programs route remain
placeholders or out of scope.
The corrective prepared-item projection, bounded phase-owned MTP outbox,
Sessions presentation correction, local date preference and durable preparation
withdrawal are implemented. The withdrawal uses the separate versioned
@ -55,8 +57,9 @@ transactional consumption, durable correlated ACK and restart/replay entry
points. The orchestrator, Web API and Web control are complete behind an
explicit trusted opt-in. The authorized private direct-MTP rollout passed on
real hardware; automatic V3 selection remains unchanged without that opt-in.
The next separately contracted Web surface is Exercises. Sessions V1 is
complete in software and is not reopened by that future contract.
The next separately contracted Web surface is Exercises. Sessions deletion and
its Programs subtab are complete in software and are not reopened by that
future contract.
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
defines the complete-sync target and its bounded dependency order.

View file

@ -40,7 +40,7 @@ available because availability is independently guarded by causal state.
## Machine-exercise Phase 1 compatibility
Desktop schema v24 and Android schema v23 retain mobile export V3, readable V1/V2 imports,
Desktop schema v25 and Android schema v23 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names
@ -143,7 +143,7 @@ does not publish to it or let it override an artifact in the new endpoint.
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1 companion |
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
@ -522,7 +522,11 @@ retryable desktop status and never undoes the committed import. The desktop
outbound companion selects at most 256 not-yet-published proposals in stable
creation/identity order. Successful MTP publication transactionally marks only
that exact batch; failure leaves it unchanged for retry, while later normal
syncs drain further batches. The permanent Drive import ledger is independent.
syncs drain further batches. V2 additionally carries at most 256 permanent
proposal withdrawals. Desktop admission keeps the complete withdrawal set
within that bound. Android stores each as a deleted tombstone before removing
pending proposal entries, so an older V1 replay cannot resurrect the proposal.
The permanent Drive import ledger is independent.
The inbound outcome (`NONE`, `IMPORTED`, `ALREADY_IMPORTED`, `REJECTED`,
`DRIVE_FAIL`, or `ARCHIVE_FAIL`) is retained in the successful sync report and
history; fetched-invalid, Drive, and archive outcomes do not fail an otherwise

View file

@ -41,6 +41,23 @@ The C MTP regression keeps a malformed retained generation beside the current
reference and proves that pull downloads only the referenced generation while
still rejecting a truncated object inside that current generation.
`web_session_deletions` exercises proposal withdrawal replay, preservation of a
derived preparation, active-draft conflict, causal draft/session deletion and
completed-session finalization. The HTTP suite covers stale and successful
proposal DELETE requests and post-delete deep-link absence. Vitest covers the
focus-managed confirmation, Cancel and Escape zero-mutation paths, the real
DELETE call and focus restoration. AI companion tests prove that V2 exports a
withdrawal without the proposal and that Android's tombstone rejects later V1
replay.
`web_programs` exercises validation preview without writes, transactional
import, exact replay, detail serialization, archive replay, preparation
creation, provenance and absence of delivery. Schema migration coverage checks
all v25 tables and columns. Programs Vitest covers preview before the only
importing mutation and explicit preparation creation/navigation. The full HTTP,
pagination, strict-parser and Core suites retain malformed, duplicate-key,
bounds, catalog/profile/equipment, conflict and rollback coverage.
`test_web_sessions_browser.py` runs headless Firefox against the embedded
production assets and C HTTP server with private temporary XDG roots. It creates
only a synthetic ready preparation and delivery, verifies French then persisted

View file

@ -1,10 +1,12 @@
# Web Sessions V1
The Sessions route implements three distinct views:
The Sessions route implements four distinct views:
- Preparation: manual preparations and immutable AI proposals;
- Resume: lifecycle-backed execution drafts, continued only on Android;
- History: completed sessions, including explicit unknown end times.
- Programs: durable desktop planning definitions, import and explicit manual
preparation creation.
Manual preparation supports a title, session type, optional calendar date,
note, catalogue selection, duplicate occurrences, keyboard reordering,
@ -58,5 +60,22 @@ The tombstone rejects an older delivery replay, including after process restart.
Desktop marks a withdrawal acknowledged only after the correlated Android
generation ACK. Until then Web reports that Android synchronization is pending.
Every deletable row has a separate right-edge trash action with an accessible
target. It never opens the detail. A focus-managed `alertdialog` names the item,
kind, relevant date and consequence; Cancel and Escape write nothing. Proposal
deletion records a durable logical withdrawal and publishes a bounded V2
proposal tombstone without deleting derived preparations or performed work.
Inactive execution drafts and completed sessions use the existing causal
operation/state boundary. Active or stale drafts conflict, and completed-session
deletion records a finalization so older snapshots cannot recreate the session.
Programs are planning data owned by desktop schema v25. Import first runs the
same strict Core validator in preview mode, then commits only after explicit
confirmation. Exact content replay is idempotent; same-ID divergent content is
a conflict. Archiving preserves definitions and derived preparations. Creating
a preparation from one program session copies targets and provenance into a
new manual preparation but creates no delivery, execution or performed data.
See [Program format V1](program_format_v1.md).
This V1 does not provide live Web capture, historical correction, automatic
training generation, Programs, Analysis or the standalone Exercises route.
training generation, Analysis or the standalone Exercises route.

View file

@ -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 exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
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_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;
@ -94,8 +94,13 @@ class DraftTests(unittest.TestCase):
with self.assertRaisesRegex(ImportFailure, "conflit"):
import_payload(con, changed)
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(2, exported["version"])
self.assertEqual([], exported["withdrawals"])
outbound = exported["drafts"][0]["entries"][0]
self.assertEqual(EX_REPS, outbound["exercise_id"])
self.assertEqual(3, outbound["data_fields"])
@ -112,6 +117,24 @@ class DraftTests(unittest.TestCase):
"PRAGMA table_info(ai_session_drafts)")})
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):
value = artifact()
value["draft"]["entries"][0]["target_sets"] = 99

View file

@ -123,6 +123,15 @@ class BrowserSessionsTest(unittest.TestCase):
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()
wait.until(lambda current: "Nouvelle séance" in current.page_source)
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24):
raise CausalError(f"desktop schema v20-v24 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25):
raise CausalError(f"desktop schema v20-v25 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

25
tools/check_changed_c_format.sh Executable file
View file

@ -0,0 +1,25 @@
#!/bin/sh
# Check only C and header files changed from a caller-selected Git base.
set -eu
base_ref=${1:-main}
base_commit=$(git merge-base HEAD "$base_ref")
temporary_list=$(mktemp)
trap 'rm -f "$temporary_list"' EXIT HUP INT TERM
{
git diff --name-only --diff-filter=ACMR "$base_commit" -- '*.c' '*.h'
git ls-files --others --exclude-standard -- '*.c' '*.h'
} | sort -u >"$temporary_list"
if [ ! -s "$temporary_list" ]; then
echo "CHANGED_C_FORMAT=PASS (no changed C/H files)"
exit 0
fi
while IFS= read -r source_file; do
clang-format --dry-run --Werror "$source_file"
done <"$temporary_list"
echo "CHANGED_C_FORMAT=PASS"

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24):
raise LifecycleError(f"desktop schema v19-v24 required, found v{version}")
if version not in (19, 20, 21, 22, 23, 24, 25):
raise LifecycleError(f"desktop schema v19-v25 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",

View file

@ -1,5 +1,5 @@
#!/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
@ -20,13 +20,17 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v18-v24 requis")
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v25 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:
connection.execute(
"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,
# including on an older v18 database whose FK was declared cascading.
connection.execute(
@ -44,7 +48,7 @@ def build_export(connection, generated_at):
drafts = []
rows = connection.execute(
"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 ?",
(MAX_DRAFTS,),
).fetchall()
@ -71,8 +75,22 @@ def build_export(connection, generated_at):
})
# CONTRACT: this companion is separate from every mobile-export version.
# INVARIANT: archive bookkeeping is desktop-private and is never published.
return {"format": "trainlog-ai-session-drafts", "version": 1,
"generated_at": generated_at, "drafts": drafts}
withdrawals = [
{"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):
@ -83,9 +101,17 @@ def mark_published(connection, payload, published_at):
retry exports the same deterministic IDs and the import ledger is retained.
"""
ensure_publication_state(connection)
if (not isinstance(payload, dict) or set(payload) != {"format", "version", "generated_at", "drafts"}
or payload["format"] != "trainlog-ai-session-drafts" or payload["version"] != 1
or not isinstance(payload["drafts"], list) or len(payload["drafts"]) > MAX_DRAFTS):
expected_keys = {"format", "version", "generated_at", "drafts", "withdrawals"}
if (
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")
draft_ids = []
for draft in payload["drafts"]:

View file

@ -28,7 +28,8 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = tuple(range(8, 26))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(

View file

@ -24,7 +24,8 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = tuple(range(8, 26))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v12-v24 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25):
raise ValueError("schema desktop v12-v25 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,7 +41,8 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = tuple(range(11, 26))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
if version not in range(8, 26):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,9 +83,11 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24) and not (args.version == 2 and schema_version == 10):
supported_versions = range(11, 26)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():

View file

@ -17,8 +17,9 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
if connection.execute("PRAGMA user_version").fetchone()[0] != 24:
raise ValueError("desktop schema v24 required")
supported_versions = (24, 25)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24 or v25 required")
deliveries = []
rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"

View file

@ -10,7 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23, 24): raise ValueError("schema desktop v15-v24 requis")
supported_versions = range(15, 26)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v25 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -193,8 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
raise ImportFailure("schema desktop v18-v24 requis")
supported_versions = range(18, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v25 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -187,7 +187,8 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = range(8, 26)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export
if proof_path is None:

View file

@ -53,7 +53,8 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = range(8, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")

View file

@ -56,7 +56,8 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
supported_versions = range(12, 26)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()

View file

@ -151,7 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23,24): raise ImportFailure("schema desktop v11-v24 requis")
supported_versions = range(11, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v25 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -49,7 +49,9 @@ def validate(item):
def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23,24): fail("schema desktop v17-v24 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25):
fail("schema desktop v17-v25 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -846,9 +846,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
if version not in range(5, 26):
raise ImportFailure(
f"base desktop schema v5 à v24 attendue, version trouvée: {version}"
f"base desktop schema v5 à v25 attendue, version trouvée: {version}"
)
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v24")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,7 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23,24):fail("schema desktop v15-v24 requis")
supported_versions = range(15, 26)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v25 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -96,7 +96,7 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.2",
"desktop_schema": 24,
"desktop_schema": 25,
"android_schema": 23,
"protocols": [
"mobile-export-v3",
@ -107,6 +107,7 @@ def main() -> int:
"generation-ack-v1",
"generation-archive-v1",
"session-preparations-v2",
"ai-session-drafts-v2",
],
"entry_points": [
"bin/trainlog",

View file

@ -68,7 +68,7 @@ ARTIFACTS = (
"export_training_feedback.py", ()),
("causal-deletions", "trainlog-causal-deletions", 1, "causal-deletions-v1.json", True,
"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", ()),
("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
@ -201,8 +201,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 24:
raise GenerationError("desktop schema v24 required")
supported_versions = (24, 25)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 or v25 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:

View file

@ -1,7 +1,8 @@
/*
* Trainlog database interface.
*
* Declares the module boundary and ownership contract; implementation and persistence remain in their owning modules.
* Declares the module boundary and ownership contract; implementation and persistence remain in
* their owning modules.
*/
#ifndef TRAINLOG_DATABASE_H
#define TRAINLOG_DATABASE_H
@ -16,7 +17,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 24
#define TRAINLOG_DATABASE_SCHEMA_VERSION 25
typedef struct TrainlogDatabase TrainlogDatabase;
@ -31,16 +32,20 @@ typedef struct TrainlogFeedbackView {
/* CONTRACT: read-only chronological consultation; Android remains the sole
* V1 creator. Ties use the stable ID byte order. No SQLite resources escape. */
TrainlogStatus trainlog_database_list_training_feedback(
TrainlogDatabase *database, const char *session_id,
TrainlogFeedbackView *exercise_feedback, size_t exercise_capacity,
size_t *exercise_count, TrainlogFeedbackView *followups,
size_t followup_capacity, size_t *followup_count);
TrainlogStatus trainlog_database_list_training_feedback(TrainlogDatabase *database,
const char *session_id,
TrainlogFeedbackView *exercise_feedback,
size_t exercise_capacity,
size_t *exercise_count,
TrainlogFeedbackView *followups,
size_t followup_capacity,
size_t *followup_count);
/* Nestable read savepoints let composition services observe one database state
* without exposing SQLite or issuing writes to application data. */
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database);
TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database, bool commit_snapshot);
TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database,
bool commit_snapshot);
/* WHY: session occurrences retain an equipment ID, while custom definitions
* need durable presentation metadata. A reference alone is never a definition. */
@ -69,16 +74,12 @@ typedef struct TrainlogResolvedEquipment {
TrainlogEquipmentOrigin origin;
} TrainlogResolvedEquipment;
TrainlogStatus trainlog_database_create_custom_equipment(
TrainlogDatabase *database,
const TrainlogCustomEquipment *equipment
);
TrainlogStatus trainlog_database_list_custom_equipment(
TrainlogDatabase *database,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_create_custom_equipment(TrainlogDatabase *database,
const TrainlogCustomEquipment *equipment);
TrainlogStatus trainlog_database_list_custom_equipment(TrainlogDatabase *database,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count);
/* CONTRACT: reads at most capacity + 1 ordered definitions, so callers can
* filter pages before imposing a presentation cap. database and output are
* borrowed for the call; the caller owns copied strings. This read-only API
@ -90,33 +91,24 @@ TrainlogStatus trainlog_database_list_custom_equipment(
* including any storage copied before the failure. offset is valid only while
* data is unchanged; refresh after writes. It is neither a durable cursor nor
* an idempotency mechanism. */
TrainlogStatus trainlog_database_list_custom_equipment_page(
TrainlogDatabase *database,
size_t offset,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count,
bool *output_more
);
TrainlogStatus trainlog_database_list_custom_equipment_page(TrainlogDatabase *database,
size_t offset,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count,
bool *output_more);
/* CONTRACT: an occurrence ID always resolves to a visible value. Unknown IDs
* are returned verbatim with TRAINLOG_EQUIPMENT_UNKNOWN, never hidden. */
TrainlogStatus trainlog_database_resolve_equipment(
TrainlogDatabase *database,
const char *equipment_id,
TrainlogResolvedEquipment *output
);
TrainlogStatus trainlog_database_list_exercise_equipment(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogResolvedEquipment *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_resolve_equipment(TrainlogDatabase *database,
const char *equipment_id,
TrainlogResolvedEquipment *output);
TrainlogStatus trainlog_database_list_exercise_equipment(TrainlogDatabase *database,
const char *exercise_id,
TrainlogResolvedEquipment *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_open(
const char *path,
TrainlogDatabase **output_database
);
TrainlogStatus trainlog_database_open(const char *path, TrainlogDatabase **output_database);
/**
* @brief Open a database and report the backend failure that prevented it.
@ -127,24 +119,17 @@ TrainlogStatus trainlog_database_open(
* This preserves the stable application status surface without hiding the
* path-specific reason needed to repair a user's durable database safely.
*/
TrainlogStatus trainlog_database_open_with_diagnostic(
const char *path,
TrainlogDatabase **output_database,
char *output_diagnostic,
size_t output_diagnostic_capacity
);
TrainlogStatus trainlog_database_open_with_diagnostic(const char *path,
TrainlogDatabase **output_database,
char *output_diagnostic,
size_t output_diagnostic_capacity);
void trainlog_database_close(TrainlogDatabase *database);
TrainlogStatus trainlog_database_schema_version(
TrainlogDatabase *database,
int *output_version
);
TrainlogStatus trainlog_database_schema_version(TrainlogDatabase *database, int *output_version);
TrainlogStatus trainlog_database_foreign_keys_enabled(
TrainlogDatabase *database,
int *output_enabled
);
TrainlogStatus trainlog_database_foreign_keys_enabled(TrainlogDatabase *database,
int *output_enabled);
TrainlogStatus trainlog_database_begin(TrainlogDatabase *database);
TrainlogStatus trainlog_database_commit(TrainlogDatabase *database);
@ -156,35 +141,26 @@ TrainlogStatus trainlog_database_rollback(TrainlogDatabase *database);
* Unicode normalization belongs to catalog.c. This lower-level API owns the
* final SQLite uniqueness barrier.
*/
TrainlogStatus trainlog_database_insert_exercise(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode
);
TrainlogStatus trainlog_database_insert_exercise(TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode);
TrainlogStatus trainlog_database_insert_exercise_profiled(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields
);
TrainlogStatus trainlog_database_insert_exercise_profiled(TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields);
TrainlogStatus trainlog_database_exercise_count(
TrainlogDatabase *database,
size_t *output_count
);
TrainlogStatus trainlog_database_exercise_count(TrainlogDatabase *database, size_t *output_count);
TrainlogStatus trainlog_database_list_exercises(
TrainlogDatabase *database,
TrainlogExercise *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercises(TrainlogDatabase *database,
TrainlogExercise *output,
size_t capacity,
size_t *output_count);
/**
* @brief Resolve a current or merged exercise identity to its canonical ID.
@ -193,12 +169,10 @@ TrainlogStatus trainlog_database_list_exercises(
* resolve through exercise_aliases. Unknown IDs return NOT_FOUND. The output
* buffer is caller-owned and must hold TRAINLOG_ID_MAX + 1 bytes.
*/
TrainlogStatus trainlog_database_resolve_exercise_id(
TrainlogDatabase *database,
const char *exercise_id,
char *output_canonical_id,
size_t output_capacity
);
TrainlogStatus trainlog_database_resolve_exercise_id(TrainlogDatabase *database,
const char *exercise_id,
char *output_canonical_id,
size_t output_capacity);
/**
* @brief Atomically merge one current catalogue exercise into another.
@ -210,11 +184,9 @@ TrainlogStatus trainlog_database_resolve_exercise_id(
* its stable entry ID or any owned actual/planning data. Existing aliases to
* source collapse directly to canonical and source becomes a durable alias.
*/
TrainlogStatus trainlog_database_merge_exercises(
TrainlogDatabase *database,
const char *source_exercise_id,
const char *canonical_exercise_id
);
TrainlogStatus trainlog_database_merge_exercises(TrainlogDatabase *database,
const char *source_exercise_id,
const char *canonical_exercise_id);
typedef struct TrainlogExerciseMergePreview {
size_t occurrences;
@ -227,11 +199,9 @@ typedef struct TrainlogExerciseMergePreview {
/* CONTRACT: counts describe source-owned records which the atomic merge will
* repoint or union. Unknown IDs return NOT_FOUND and output is never partial. */
TrainlogStatus trainlog_database_preview_exercise_merge(
TrainlogDatabase *database,
const char *source_exercise_id,
TrainlogExerciseMergePreview *output
);
TrainlogStatus trainlog_database_preview_exercise_merge(TrainlogDatabase *database,
const char *source_exercise_id,
TrainlogExerciseMergePreview *output);
/**
* @brief Atomically replace one exercise's direct body-zone relations.
@ -245,24 +215,20 @@ TrainlogStatus trainlog_database_preview_exercise_merge(
* @return INVALID_ARGUMENT for unknown/group/duplicate zones, NOT_FOUND for
* an unknown exercise, or the explicit persistence status.
*/
TrainlogStatus trainlog_database_replace_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
);
TrainlogStatus trainlog_database_replace_exercise_body_zones(TrainlogDatabase *database,
const char *exercise_id,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count);
/* NOT_FOUND distinguishes an unknown exercise identity from the valid empty
* relation set. Output is caller-owned; output_count reports required capacity
* and no relation is truncated as a successful result. */
TrainlogStatus trainlog_database_list_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExerciseBodyZone *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercise_body_zones(TrainlogDatabase *database,
const char *exercise_id,
TrainlogExerciseBodyZone *output,
size_t capacity,
size_t *output_count);
/* CONTRACT: the caller owns output storage; output_count reports the required
* count and INVALID_ARGUMENT is returned when capacity is insufficient.
@ -273,17 +239,15 @@ TrainlogStatus trainlog_database_list_exercise_body_zones(
* The resulting exercise IDs compose with list_exercise_body_zones() and
* list_exercise_performance(); the latter exposes newest history and explicit
* MAX without duplicating either datum for a future session generator. */
TrainlogStatus trainlog_database_list_exercises_filtered(
TrainlogDatabase *database,
const char *normalized_prefix,
const char *zone_id,
bool include_descendants,
bool primary_only,
bool unclassified_only,
TrainlogExercise *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercises_filtered(TrainlogDatabase *database,
const char *normalized_prefix,
const char *zone_id,
bool include_descendants,
bool primary_only,
bool unclassified_only,
TrainlogExercise *output,
size_t capacity,
size_t *output_count);
/* CONTRACT: name/profile/direct zones are one transaction and all string/list
* inputs are borrowed only for the duration of the call. A profile change is
@ -291,51 +255,38 @@ TrainlogStatus trainlog_database_list_exercises_filtered(
* rename or zone replacement preserves exercise_id and history. Zone rules
* match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND;
* invalid metadata and name collisions remain explicit errors. */
TrainlogStatus trainlog_database_update_exercise_profiled(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
);
TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count);
TrainlogStatus trainlog_database_insert_session(
TrainlogDatabase *database,
const TrainlogSessionInput *session
);
TrainlogStatus trainlog_database_insert_session(TrainlogDatabase *database,
const TrainlogSessionInput *session);
TrainlogStatus trainlog_database_session_count(
TrainlogDatabase *database,
size_t *output_count
);
TrainlogStatus trainlog_database_session_count(TrainlogDatabase *database, size_t *output_count);
/* Observable history only: a returned session owns at least one persisted
* performed set, continuous activity, or explicit MAX result. ended_at is
* presentation metadata and is not an actual-work gate. */
TrainlogStatus trainlog_database_list_sessions(
TrainlogDatabase *database,
TrainlogSessionSummary *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_sessions(TrainlogDatabase *database,
TrainlogSessionSummary *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_insert_body_observation(
TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation
);
TrainlogStatus trainlog_database_list_weight_points(
TrainlogDatabase *database,
TrainlogWeightPoint *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus
trainlog_database_insert_body_observation(TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation);
TrainlogStatus trainlog_database_list_weight_points(TrainlogDatabase *database,
TrainlogWeightPoint *output,
size_t capacity,
size_t *output_count);
/* TRAINLOG_SESSION_DETAILS_API */
@ -382,21 +333,17 @@ typedef struct TrainlogPersistedExerciseDetail {
* every partial allocation and, when output_exercise_count is non-NULL, sets
* it to zero. A nonzero capacity requires a non-NULL output_exercises array.
*/
TrainlogStatus trainlog_database_get_session_details(
TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogPersistedExerciseDetail *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count
);
TrainlogStatus
trainlog_database_get_session_details(TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogPersistedExerciseDetail *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count);
/* Safe for zero-initialized details and details returned by the loader. */
void trainlog_database_free_session_details(
TrainlogPersistedExerciseDetail *exercises,
size_t exercise_count
);
void trainlog_database_free_session_details(TrainlogPersistedExerciseDetail *exercises,
size_t exercise_count);
/* TRAINLOG_BODY_METRIC_HISTORY_API */
@ -430,21 +377,16 @@ typedef struct TrainlogBodyPairPoint {
double right_value;
} TrainlogBodyPairPoint;
TrainlogStatus trainlog_database_list_body_metric_points(
TrainlogDatabase *database,
TrainlogBodyMetric metric,
TrainlogBodyMetricPoint *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_latest_body_pair(
TrainlogDatabase *database,
TrainlogBodyMetric left_metric,
TrainlogBodyMetric right_metric,
TrainlogBodyPairPoint *output
);
TrainlogStatus trainlog_database_list_body_metric_points(TrainlogDatabase *database,
TrainlogBodyMetric metric,
TrainlogBodyMetricPoint *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_latest_body_pair(TrainlogDatabase *database,
TrainlogBodyMetric left_metric,
TrainlogBodyMetric right_metric,
TrainlogBodyPairPoint *output);
/* TRAINLOG_EXERCISE_PERFORMANCE_API */
@ -476,13 +418,11 @@ typedef struct TrainlogExercisePerformancePoint {
* A zero-repetition failed attempt is never promoted to representative
* performance. This API does not create or infer a measured maximum.
*/
TrainlogStatus trainlog_database_list_exercise_performance(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercisePerformancePoint *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercise_performance(TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercisePerformancePoint *output,
size_t capacity,
size_t *output_count);
/* TRAINING_KNOWLEDGE_RUNTIME_READ_V1 */
#define TRAINLOG_OCCURRENCE_PAGE_MAX 32U
@ -534,11 +474,9 @@ typedef struct TrainlogLatestExplicitMax {
} TrainlogLatestExplicitMax;
/* Exact-ID profile read; unknown IDs return NOT_FOUND. */
TrainlogStatus trainlog_database_get_exercise_profile(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercise *output
);
TrainlogStatus trainlog_database_get_exercise_profile(TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercise *output);
/**
* Current-data keyset page ordered by the exact started_at instant, then
@ -555,53 +493,46 @@ TrainlogStatus trainlog_database_get_exercise_profile(
* are DATABASE_ERROR rather than omitted or truncated. limit must be
* 1..TRAINLOG_OCCURRENCE_PAGE_MAX.
*/
TrainlogStatus trainlog_database_list_exercise_occurrences_page(
TrainlogDatabase *database,
const char *exercise_id,
const TrainlogExerciseOccurrenceCursor *after,
size_t limit,
TrainlogExerciseOccurrence *output,
size_t *output_count,
bool *output_has_more,
TrainlogExerciseOccurrenceCursor *output_next
);
TrainlogStatus
trainlog_database_list_exercise_occurrences_page(TrainlogDatabase *database,
const char *exercise_id,
const TrainlogExerciseOccurrenceCursor *after,
size_t limit,
TrainlogExerciseOccurrence *output,
size_t *output_count,
bool *output_has_more,
TrainlogExerciseOccurrenceCursor *output_next);
/* Original set positions are preserved. after_position is exclusive; use -1
* for the first page. Reps are non-negative, so zero preserves a failed
* attempt. Nullable values remain distinct from zero. Corrupt storage types or
* values outside the public int/size_t ranges return DATABASE_ERROR. No rows
* exist for continuous occurrences. Output remains caller-owned. */
TrainlogStatus trainlog_database_list_occurrence_sets_page(
TrainlogDatabase *database,
const char *entry_id,
int after_position,
size_t limit,
TrainlogOccurrenceSet *output,
size_t *output_count,
bool *output_has_more,
int *output_next_position
);
TrainlogStatus trainlog_database_list_occurrence_sets_page(TrainlogDatabase *database,
const char *entry_id,
int after_position,
size_t limit,
TrainlogOccurrenceSet *output,
size_t *output_count,
bool *output_has_more,
int *output_next_position);
/* Reads max_results joined only through max_test sessions; ordinary large sets
* never qualify. It uses the same exact instant and bytewise-ID ordering as
* occurrence pagination. Output keeps the exact source timestamp and
* occurrence load/equipment context; corrupt or over-capacity selected storage
* returns DATABASE_ERROR. */
TrainlogStatus trainlog_database_latest_explicit_max_context(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogLatestExplicitMax *output
);
TrainlogStatus trainlog_database_latest_explicit_max_context(TrainlogDatabase *database,
const char *exercise_id,
TrainlogLatestExplicitMax *output);
/* Same chronological contract, additionally restricted to one exact nonempty
* equipment ID. This is the compatibility reader for user-directed %MAX. */
TrainlogStatus trainlog_database_latest_explicit_max_equipment_context(
TrainlogDatabase *database,
const char *exercise_id,
const char *equipment_id,
TrainlogLatestExplicitMax *output
);
TrainlogStatus
trainlog_database_latest_explicit_max_equipment_context(TrainlogDatabase *database,
const char *exercise_id,
const char *equipment_id,
TrainlogLatestExplicitMax *output);
/* TRAINLOG_SESSION_EDIT_API */
@ -639,17 +570,16 @@ typedef struct TrainlogEditableExerciseRecord {
* too small it returns TRAINLOG_STATUS_INVALID_ARGUMENT and the outputs must not
* be used.
*/
TrainlogStatus trainlog_database_load_session_editable(
TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogEditableExerciseRecord *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count,
TrainlogSetInput *output_sets,
size_t set_capacity,
size_t *output_set_count
);
TrainlogStatus
trainlog_database_load_session_editable(TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogEditableExerciseRecord *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count,
TrainlogSetInput *output_sets,
size_t set_capacity,
size_t *output_set_count);
/**
* @brief Replace only the exercise/set contents of an existing session.
@ -661,13 +591,11 @@ TrainlogStatus trainlog_database_load_session_editable(
* inherit feedback. The replacement is atomic: any error rolls the whole
* operation back.
*/
TrainlogStatus trainlog_database_replace_session_exercises(
TrainlogDatabase *database,
const char *session_id,
const TrainlogSessionExerciseInput *exercises,
size_t exercise_count
);
TrainlogStatus
trainlog_database_replace_session_exercises(TrainlogDatabase *database,
const char *session_id,
const TrainlogSessionExerciseInput *exercises,
size_t exercise_count);
/* TRAINLOG_BODY_OBSERVATION_RECORD_API */
@ -708,18 +636,14 @@ typedef struct TrainlogBodyObservationRecord {
char notes[TRAINLOG_NOTE_MAX + 1U];
} TrainlogBodyObservationRecord;
TrainlogStatus trainlog_database_list_body_observations(
TrainlogDatabase *database,
TrainlogBodyObservationRecord *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_list_body_observations(TrainlogDatabase *database,
TrainlogBodyObservationRecord *output,
size_t capacity,
size_t *output_count);
TrainlogStatus trainlog_database_get_body_observation(
TrainlogDatabase *database,
const char *observation_id,
TrainlogBodyObservationRecord *output
);
TrainlogStatus trainlog_database_get_body_observation(TrainlogDatabase *database,
const char *observation_id,
TrainlogBodyObservationRecord *output);
/**
* @brief Correct metric values of one existing observation.
@ -727,9 +651,8 @@ TrainlogStatus trainlog_database_get_body_observation(
* observation_id identifies the stable row. observed_at and session linkage
* remain unchanged. At least one body metric must remain present.
*/
TrainlogStatus trainlog_database_update_body_observation(
TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation
);
TrainlogStatus
trainlog_database_update_body_observation(TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation);
#endif

View file

@ -41,7 +41,9 @@
* - `sy` for synchronization runs;
* - `sxe` for persisted session-exercise occurrences;
* - `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;
* - `del` for immutable causal deletion operations.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.

View file

@ -0,0 +1,56 @@
/* Trainlog Web Program V1 application service. */
#ifndef TRAINLOG_WEB_PROGRAMS_H
#define TRAINLOG_WEB_PROGRAMS_H
#include <stdbool.h>
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
#define TRAINLOG_WEB_PROGRAM_BYTES_MAX (512U * 1024U)
#define TRAINLOG_WEB_PROGRAM_SESSIONS_MAX 64U
#define TRAINLOG_WEB_PROGRAM_OCCURRENCES_MAX 64U
/* CONTRACT: successful JSON functions transfer one free()-owned allocation. */
TrainlogStatus trainlog_web_programs_list_json(TrainlogDatabase *database,
const char *search,
const char *state,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
const char *program_id,
char **output_json,
size_t *output_size);
/* CONTRACT: commit=false performs the same strict validation without writes.
* commit=true imports the complete document in one immediate transaction. */
TrainlogStatus trainlog_web_programs_import_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
bool commit,
char **output_json,
size_t *output_size);
/* CONTRACT: expected_revision prevents stale archive decisions; request_id
* makes a lost successful response safe to replay. */
TrainlogStatus trainlog_web_programs_archive_json(TrainlogDatabase *database,
const char *program_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size);
/* Creates a separate draft preparation and never changes or delivers the
* source program. request_id is forwarded to the preparation service. */
TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
const char *program_id,
const char *program_session_id,
const char *request_id,
char **output_json,
size_t *output_size);
#endif

View 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

View file

@ -9,6 +9,7 @@ mtp_dep = dependency('libmtp', required: true)
notcurses_dep = dependency('notcurses-core', required: true)
microhttpd_dep = dependency('libmicrohttpd', required: true)
yyjson_dep = dependency('yyjson', required: true)
crypto_dep = dependency('libcrypto', required: true)
utf8proc_dep = dependency('libutf8proc', required: false)
if not utf8proc_dep.found()
@ -56,6 +57,7 @@ if web_assets_enabled
'../web/src/app/useRoute.ts',
'../web/src/components/EmptyState.tsx',
'../web/src/components/DisplayPreferences.tsx',
'../web/src/components/DeleteConfirmationDialog.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/SyncControl.tsx',
@ -74,6 +76,8 @@ if web_assets_enabled
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
'../web/src/api/sessions.ts',
'../web/src/api/programs.ts',
'../web/src/routes/ProgramsTab.tsx',
'../web/src/theme/app.css',
'../web/src/theme/tokens.css',
)
@ -202,6 +206,8 @@ trainlog_core_sources = files(
'src/web_prepared_items.c',
'src/web_prepared_items_json.c',
'src/web_sessions.c',
'src/web_session_deletions.c',
'src/web_programs.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -235,6 +241,7 @@ trainlog_core = static_library(
udev_dep,
mtp_dep,
yyjson_dep,
crypto_dep,
],
c_args: strict_c_args,
)
@ -250,6 +257,7 @@ trainlog_core_dep = declare_dependency(
udev_dep,
mtp_dep,
yyjson_dep,
crypto_dep,
],
)
@ -364,6 +372,24 @@ test_web_sessions = executable(
)
test('web_sessions', test_web_sessions)
test_web_programs = executable(
'test_web_programs',
'tests/test_web_programs.c',
dependencies: trainlog_core_dep,
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
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',
'tests/test_dashboard_header.c',

File diff suppressed because it is too large Load diff

View file

@ -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 ||
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, "spw") == 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) {

View file

@ -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;
}
static TrainlogStatus load_ai_proposals(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static TrainlogStatus load_ai_proposals(TrainlogDatabase *database,
TrainlogWebPreparedItems *output) {
static const char SQL[] =
"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' "
"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;";
sqlite3_stmt *statement = NULL;
int step;
@ -99,8 +100,8 @@ fail:
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_manual_preparations(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static TrainlogStatus load_manual_preparations(TrainlogDatabase *database,
TrainlogWebPreparedItems *output) {
static const char SQL[] =
"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 "
@ -148,8 +149,8 @@ fail:
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_execution_drafts(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static TrainlogStatus load_execution_drafts(TrainlogDatabase *database,
TrainlogWebPreparedItems *output) {
static const char SQL[] =
"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,'') "
@ -201,8 +202,8 @@ fail:
return TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_web_prepared_items_load(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
TrainlogStatus trainlog_web_prepared_items_load(TrainlogDatabase *database,
TrainlogWebPreparedItems *output) {
TrainlogStatus status;
TrainlogStatus end_status;

1392
tui/src/web_programs.c Normal file

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,8 @@
#include "trainlog/web_dashboard.h"
#include "trainlog/web_prepared_items.h"
#include "trainlog/web_programs.h"
#include "trainlog/web_session_deletions.h"
#include "trainlog/web_sessions.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_preferences.h"
@ -31,7 +33,7 @@
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT (128U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT TRAINLOG_WEB_PROGRAM_BYTES_MAX
#define TRAINLOG_HTTP_LEGACY_BODY_LIMIT (4U * 1024U)
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
#define TRAINLOG_HTTP_BACKLOG 32U
@ -564,6 +566,305 @@ static enum MHD_Result queue_owned_sessions_json(struct MHD_Connection *connecti
return result;
}
static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connection,
TrainlogStatus status,
char *json,
size_t json_size) {
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_CONFLICT) {
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
}
return queue_owned_sessions_json(connection, status, json, json_size);
}
static bool program_mutation_allowed(TrainlogWebContext *context,
struct MHD_Connection *connection) {
const char *origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
const char *csrf =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
const char *content_type =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
return valid_origin(context, origin) && valid_csrf(context, csrf) && content_type != NULL &&
strcmp(content_type, "application/json") == 0;
}
static enum MHD_Result
handle_program_list(TrainlogWebContext *context, struct MHD_Connection *connection, bool is_get) {
const char *search;
const char *state;
char *json = NULL;
size_t json_size = 0U;
size_t offset;
size_t limit;
TrainlogStatus status;
if (!is_get) {
return queue_json(
connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET");
}
search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
state = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "state");
if (!parse_page_number(MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset"),
0U,
1000000U,
&offset) ||
!parse_page_number(MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"),
24U,
TRAINLOG_WEB_SESSIONS_PAGE_MAX,
&limit) ||
limit == 0U) {
return queue_json(connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
}
status = trainlog_web_programs_list_json(
context->database, search, state, offset, limit, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_import(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *url,
const char *method,
TrainlogHttpRequestState *request) {
char *json = NULL;
size_t json_size = 0U;
bool commit = strcmp(url, "/api/v1/sessions/programs/import") == 0;
TrainlogStatus status;
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_programs_import_json(
context->database, request->body, request->body_size, commit, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static bool
copy_path_component(char *output, size_t output_capacity, const char *input, size_t input_length) {
if (input_length == 0U || input_length >= output_capacity) {
return false;
}
(void)memcpy(output, input, input_length);
output[input_length] = '\0';
return true;
}
static bool parse_quoted_revision(const char *header, char *output, size_t output_capacity) {
size_t length = header == NULL ? 0U : strlen(header);
if (length < 3U || header[0] != '"' || header[length - 1U] != '"') {
return false;
}
return copy_path_component(output, output_capacity, header + 1, length - 2U);
}
static enum MHD_Result handle_program_archive(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *identity,
size_t identity_length,
const char *method) {
static const char SUFFIX[] = "/archive";
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);
char program_id[TRAINLOG_ID_MAX + 1U];
char revision[TRAINLOG_ID_MAX + 1U];
char *json = NULL;
size_t json_size = 0U;
size_t program_length = identity_length - strlen(SUFFIX);
TrainlogStatus status;
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection) || request_id == NULL ||
request_id[0] == '\0' ||
!copy_path_component(program_id, sizeof(program_id), identity, program_length) ||
!parse_quoted_revision(if_match, revision, sizeof(revision))) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
status = trainlog_web_programs_archive_json(
context->database, program_id, revision, request_id, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_prepare(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *identity,
const char *sessions_marker,
const char *method) {
static const char SUFFIX[] = "/prepare";
const char *request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *session_id = sessions_marker + strlen("/sessions/");
size_t session_length = strlen(session_id);
char program_id[TRAINLOG_ID_MAX + 1U];
char program_session_id[TRAINLOG_ID_MAX + 1U];
char *json = NULL;
size_t json_size = 0U;
TrainlogStatus status;
if (session_length <= strlen(SUFFIX) ||
strcmp(session_id + session_length - strlen(SUFFIX), SUFFIX) != 0 ||
strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
session_length -= strlen(SUFFIX);
if (!program_mutation_allowed(context, connection) || request_id == NULL ||
request_id[0] == '\0' ||
!copy_path_component(
program_id, sizeof(program_id), identity, (size_t)(sessions_marker - identity)) ||
!copy_path_component(
program_session_id, sizeof(program_session_id), session_id, session_length)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_programs_prepare_json(
context->database, program_id, program_session_id, request_id, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_request(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *url,
const char *method,
bool is_get,
TrainlogHttpRequestState *request) {
static const char PREFIX[] = "/api/v1/sessions/program/";
static const char ARCHIVE_SUFFIX[] = "/archive";
const char *identity;
const char *sessions_marker;
char *json = NULL;
size_t json_size = 0U;
size_t identity_length;
TrainlogStatus status;
if (strcmp(url, "/api/v1/sessions/programs") == 0) {
return handle_program_list(context, connection, is_get);
}
if (strcmp(url, "/api/v1/sessions/programs/validate") == 0 ||
strcmp(url, "/api/v1/sessions/programs/import") == 0) {
return handle_program_import(context, connection, url, method, request);
}
if (strncmp(url, PREFIX, strlen(PREFIX)) != 0) {
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
identity = url + strlen(PREFIX);
identity_length = strlen(identity);
sessions_marker = strstr(identity, "/sessions/");
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
return handle_program_archive(context, connection, identity, identity_length, method);
}
if (sessions_marker != NULL) {
return handle_program_prepare(context, connection, identity, sessions_marker, method);
}
if (!is_get || identity_length == 0U || identity_length > TRAINLOG_ID_MAX) {
return queue_json(
connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET");
}
status = trainlog_web_programs_detail_json(context->database, identity, &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,
struct MHD_Connection *connection,
const char *url,
@ -782,6 +1083,9 @@ static enum MHD_Result handle_request(void *closure,
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strncmp(url, "/api/v1/sessions/program", strlen("/api/v1/sessions/program")) == 0) {
return handle_program_request(context, connection, url, method, is_get, state);
}
if (strncmp(url, "/api/v1/sessions", strlen("/api/v1/sessions")) == 0) {
static const struct {
const char *path;
@ -801,6 +1105,13 @@ static enum MHD_Result handle_request(void *closure,
size_t limit;
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 ||
strncmp(url,
"/api/v1/sessions/preparation/",
@ -985,12 +1296,8 @@ static enum MHD_Result handle_request(void *closure,
if (is_get) {
preferences_result = trainlog_web_preferences_load(&preferences, &source);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
!trainlog_web_preferences_serialize(&preferences,
source,
true,
json,
sizeof(json),
&json_size)) {
!trainlog_web_preferences_serialize(
&preferences, source, true, json, sizeof(json), &json_size)) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"preferences_unavailable\"}\n",
@ -1002,11 +1309,8 @@ static enum MHD_Result handle_request(void *closure,
"Trainlog Web : préférences persistées invalides, défaut utilisé\n");
context->invalid_preferences_reported = true;
}
return queue_layout_json(connection,
MHD_HTTP_OK,
json,
preferences.revision,
context->csrf_token);
return queue_layout_json(
connection, MHD_HTTP_OK, json, preferences.revision, context->csrf_token);
}
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0) {
return queue_json(connection,
@ -1040,15 +1344,11 @@ static enum MHD_Result handle_request(void *closure,
}
preferences_result =
trainlog_web_preferences_parse(state->body, state->body_size, &preferences);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
preferences.revision != expected) {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_preferences\"}\n",
NULL);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK || preferences.revision != expected) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_preferences\"}\n", NULL);
}
preferences_result =
trainlog_web_preferences_save(&preferences, expected, &saved);
preferences_result = trainlog_web_preferences_save(&preferences, expected, &saved);
if (preferences_result == TRAINLOG_WEB_PREFERENCES_CONFLICT) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_FAILED,
@ -1056,12 +1356,8 @@ static enum MHD_Result handle_request(void *closure,
NULL);
}
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
!trainlog_web_preferences_serialize(&saved,
TRAINLOG_WEB_PREFERENCES_PERSISTED,
true,
json,
sizeof(json),
&json_size)) {
!trainlog_web_preferences_serialize(
&saved, TRAINLOG_WEB_PREFERENCES_PERSISTED, true, json, sizeof(json), &json_size)) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"preferences_save_failed\"}\n",

View 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;
}

View file

@ -23,7 +23,8 @@ static const ListDefinition LISTS[] = {
"FROM session_preparations p JOIN session_preparation_revisions r ON "
"r.revision_id=p.current_revision_id "
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
"WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
"EXISTS(SELECT 1 FROM "
"session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE "
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"GROUP BY p.preparation_id ORDER BY "
@ -32,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 "
"'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at "
"FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id "
"WHERE (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"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 "
"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 "
@ -194,22 +196,24 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
bool proposal,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
const char *sql = proposal
? "SELECT "
"d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,"
"i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN "
"'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN "
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
: "SELECT "
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
"p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
"w.acknowledged_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
"LEFT JOIN ai_session_drafts source ON source.draft_id=p.source_proposal_id "
"LEFT JOIN session_preparation_withdrawals w ON "
"w.preparation_id=p.preparation_id WHERE p.preparation_id=?1";
const char *sql =
proposal ? "SELECT "
"d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,"
"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 "
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1 AND "
"d.withdrawn_at IS NULL"
: "SELECT "
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
"p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
"w.acknowledged_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
"LEFT JOIN ai_session_drafts source ON source.draft_id=p.source_proposal_id "
"LEFT JOIN session_preparation_withdrawals w ON "
"w.preparation_id=p.preparation_id WHERE p.preparation_id=?1";
const char *entry_sql =
proposal
? "SELECT "
@ -254,16 +258,13 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
}
if (!proposal) {
static const char *const provenance_keys[] = {"source_proposal_id",
"source_proposal_title",
"withdrawn_at",
"withdrawal_id",
"withdrawal_acknowledged_at"};
"source_proposal_title",
"withdrawn_at",
"withdrawal_id",
"withdrawal_acknowledged_at"};
for (column = 0; column < 5; ++column) {
if (!add_nullable_text(document,
root,
provenance_keys[column],
statement,
column + 8)) {
if (!add_nullable_text(
document, root, provenance_keys[column], statement, column + 8)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
@ -341,8 +342,11 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
const char *identity,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
static const char SESSION_SQL[] = "SELECT session_id,session_type,started_at,ended_at,notes "
"FROM sessions WHERE session_id=?1";
static const char SESSION_SQL[] =
"SELECT 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[] =
"SELECT "
"se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se."
@ -371,7 +375,8 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
!add_nullable_text(document, root, "session_type", statement, 1) ||
!add_nullable_text(document, root, "started_at", statement, 2) ||
!add_nullable_text(document, root, "ended_at", statement, 3) ||
!add_nullable_text(document, root, "notes", statement, 4)) {
!add_nullable_text(document, root, "notes", statement, 4) ||
!add_nullable_text(document, root, "revision_id", statement, 5)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
@ -717,6 +722,96 @@ static bool bind_nullable_text(sqlite3_stmt *statement, int index, yyjson_val *v
SQLITE_TRANSIENT) == SQLITE_OK;
}
static bool program_session_exists(TrainlogDatabase *database,
const char *program_id,
const char *program_session_id) {
sqlite3_stmt *statement = NULL;
int result;
bool found = false;
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_sessions s JOIN programs p ON "
"p.program_id=s.program_id WHERE p.program_id=?1 AND "
"s.program_session_id=?2",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, program_session_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
found = sqlite3_step(statement) == SQLITE_ROW;
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return found;
}
static TrainlogStatus insert_preparation_root(TrainlogDatabase *database,
const char *preparation_id,
const char *revision_id,
const char *created_at,
const char *editing_state,
const char *source_proposal_id,
const char *source_payload_sha256,
const char *source_program_id,
const char *source_program_session_id) {
static const char INSERT_SQL[] =
"INSERT INTO session_preparations("
"preparation_id,current_revision_id,created_at,updated_at,editing_state,"
"delivery_state,source_proposal_id,source_payload_sha256,withdrawn_at,"
"source_program_id,source_program_session_id) "
"VALUES(?1,?2,?3,?3,?4,'local',?5,?6,NULL,?7,?8)";
sqlite3_stmt *statement = NULL;
int result;
result = sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, created_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = source_proposal_id == NULL
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(statement, 5, source_proposal_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = source_payload_sha256 == NULL
? sqlite3_bind_null(statement, 6)
: sqlite3_bind_text(statement, 6, source_payload_sha256, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = source_program_id == NULL
? sqlite3_bind_null(statement, 7)
: sqlite3_bind_text(statement, 7, source_program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result =
source_program_session_id == NULL
? sqlite3_bind_null(statement, 8)
: sqlite3_bind_text(statement, 8, source_program_session_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
const char *revision_id,
yyjson_val *entry,
@ -854,7 +949,9 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
"editing_state",
"occurrences",
"source_proposal_id",
"source_payload_sha256"};
"source_payload_sha256",
"source_program_id",
"source_program_session_id"};
yyjson_doc *input = NULL;
yyjson_val *root;
yyjson_val *occurrences;
@ -868,6 +965,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
const char *editing_state;
const char *source_proposal = NULL;
const char *source_fingerprint = NULL;
const char *source_program = NULL;
const char *source_program_session = NULL;
size_t index;
TrainlogStatus status = TRAINLOG_STATUS_OK;
yyjson_mut_doc *response;
@ -912,6 +1011,21 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (yyjson_obj_get(root, "source_program_id") != NULL &&
(!bounded_json_string(
yyjson_obj_get(root, "source_program_id"), TRAINLOG_ID_MAX + 1U, &source_program) ||
!bounded_json_string(yyjson_obj_get(root, "source_program_session_id"),
TRAINLOG_ID_MAX + 1U,
&source_program_session))) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if ((source_program == NULL) != (source_program_session == NULL) ||
(preparation_id != NULL && preparation_id[0] != '\0' && source_program != NULL) ||
(source_proposal != NULL && source_program != NULL)) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (actual_preparation == NULL || actual_preparation[0] == '\0') {
if (expected_revision[0] != '\0' ||
trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) !=
@ -948,6 +1062,12 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
(void)sqlite3_finalize(statement);
statement = NULL;
}
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_program != NULL) {
if (!program_session_exists(database, source_program, source_program_session)) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
}
}
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) {
if (sqlite3_prepare_v2(
database->connection,
@ -987,31 +1107,19 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
}
(void)sqlite3_finalize(statement);
statement = NULL;
} else if (sqlite3_prepare_v2(
database->connection,
"INSERT INTO session_preparations("
"preparation_id,current_revision_id,created_at,updated_at,editing_state,"
"delivery_state,source_proposal_id,source_payload_sha256,withdrawn_at) "
"VALUES(?1,?2,?3,?3,?4,'local',?5,?6,NULL)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
(source_proposal == NULL
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(statement, 5, source_proposal, -1, SQLITE_TRANSIENT)) !=
SQLITE_OK ||
(source_fingerprint == NULL
? sqlite3_bind_null(statement, 6)
: sqlite3_bind_text(statement, 6, source_fingerprint, -1, SQLITE_TRANSIENT)) !=
SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
} else {
status = insert_preparation_root(database,
actual_preparation,
revision,
now,
editing_state,
source_proposal,
source_fingerprint,
source_program,
source_program_session);
if (status != TRAINLOG_STATUS_OK) {
goto rollback;
}
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
@ -1372,10 +1480,8 @@ TrainlogStatus trainlog_web_sessions_withdraw_json(TrainlogDatabase *database,
(void)yyjson_mut_obj_add_strcpy(response, root, "preparation_id", preparation_id);
(void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", expected_revision);
(void)yyjson_mut_obj_add_strcpy(response, root, "withdrawal_id", withdrawal_id);
(void)yyjson_mut_obj_add_strcpy(response,
root,
"android_cancellation",
has_deliveries ? "pending" : "not_required");
(void)yyjson_mut_obj_add_strcpy(
response, root, "android_cancellation", has_deliveries ? "pending" : "not_required");
status = write_document(response, output_json, output_size);
response = NULL;
if (status != TRAINLOG_STATUS_OK ||

View file

@ -7,38 +7,87 @@
#include "trainlog/database.h"
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"CHECK failed at %s:%d: %s\n",__FILE__,__LINE__,#x); return 1; } } while (0)
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return 1; \
} \
} while (0)
static int scalar(sqlite3 *database, const char *sql)
{
sqlite3_stmt *statement=NULL;int value=-1;
if(sqlite3_prepare_v2(database,sql,-1,&statement,NULL)==SQLITE_OK && sqlite3_step(statement)==SQLITE_ROW)value=sqlite3_column_int(statement,0);
(void)sqlite3_finalize(statement);return value;
static int scalar(sqlite3 *database, const char *sql) {
sqlite3_stmt *statement = NULL;
int value = -1;
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
value = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return value;
}
int main(void)
{
char path[]="/tmp/trainlog-schema-v21-XXXXXX";
int fd=mkstemp(path);TrainlogDatabase *database=NULL;sqlite3 *raw=NULL;size_t exercises=0U;
CHECK(fd>=0);CHECK(close(fd)==0);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
int main(void) {
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
"DROP TABLE web_session_deletion_requests;"
"DROP TABLE program_requests;"
"DROP TABLE program_session_entries;"
"DROP TABLE program_sessions;"
"DROP TABLE programs;"
"DROP TABLE session_preparation_withdrawals;"
"DROP TABLE session_preparation_deliveries;"
"DROP TABLE session_preparation_requests;"
"DROP TABLE session_preparation_entries;"
"DROP TABLE session_preparation_revisions;"
"DROP TABLE session_preparations;"
"DROP TABLE sync_generation_archives;"
"DROP TABLE sync_causal_publications;"
"DROP TABLE sync_acknowledgements;"
"DROP TABLE sync_consumed_generations;"
"DROP TABLE sync_generation_artifacts;"
"DROP TABLE sync_generations;"
"DROP TABLE sync_peer_identity;"
"PRAGMA user_version=20;";
char path[] = "/tmp/trainlog-schema-v21-XXXXXX";
int file_descriptor = mkstemp(path);
TrainlogDatabase *database = NULL;
sqlite3 *raw = NULL;
size_t exercises = 0U;
CHECK(file_descriptor >= 0);
CHECK(close(file_descriptor) == 0);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","Migration sentinel","migration sentinel",
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_withdrawals;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_close(raw)==SQLITE_OK);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(scalar(raw,"PRAGMA user_version")==TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw,"SELECT COUNT(*) FROM session_preparation_withdrawals")==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_acknowledgements")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generation_archives")==0);
CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0);
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"Migration sentinel",
"migration sentinel",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, DOWNGRADE_TO_V20, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database, &exercises) == TRAINLOG_STATUS_OK);
CHECK(exercises > 0U);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
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_consumed_generations") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_acknowledgements") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_generation_archives") == 0);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return 0;
}

View file

@ -13,45 +13,72 @@
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); \
return false; \
} \
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static const char *const V7_FIXTURE_SQL =
"PRAGMA foreign_keys=ON;"
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL,notes TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);"
"CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER UNIQUE REFERENCES sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);"
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT "
"NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT "
"NULL,data_fields INTEGER NOT NULL);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT "
"NOT NULL,ended_at TEXT,session_type TEXT NOT NULL,notes TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL "
"UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT "
"NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL,data_fields INTEGER NOT "
"NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT "
"NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg "
"REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL "
"REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds "
"INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT "
"NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh "
"REAL,distance_km REAL);"
"CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL "
"UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER UNIQUE REFERENCES "
"sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm "
"REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm "
"REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);"
"INSERT INTO exercises VALUES(11,'ex_fixture_sets','Squat','squat','reps','sets',0);"
"INSERT INTO exercises VALUES(12,'ex_fixture_run','Run','run','duration','continuous',3);"
"INSERT INTO sessions VALUES(21,'se_fixture','2026-09-08T06:00:00Z','2026-09-08T07:00:00Z','training','fixture session');"
"INSERT INTO session_exercises VALUES(31,'sxe_fixture_sets',21,11,'sets',0,0,'external',90,2,5,NULL,82.5,'eq_fixture_rack','sets note');"
"INSERT INTO session_exercises VALUES(32,'sxe_fixture_run',21,12,'continuous',3,1,'none',0,NULL,NULL,NULL,NULL,NULL,'run note');"
"INSERT INTO sessions "
"VALUES(21,'se_fixture','2026-09-08T06:00:00Z','2026-09-08T07:00:00Z','training','fixture "
"session');"
"INSERT INTO session_exercises "
"VALUES(31,'sxe_fixture_sets',21,11,'sets',0,0,'external',90,2,5,NULL,82.5,'eq_fixture_rack','"
"sets note');"
"INSERT INTO session_exercises "
"VALUES(32,'sxe_fixture_run',21,12,'continuous',3,1,'none',0,NULL,NULL,NULL,NULL,NULL,'run "
"note');"
"INSERT INTO performed_sets VALUES(41,31,0,5,NULL,80.25);"
"INSERT INTO performed_sets VALUES(42,31,1,4,NULL,82.5);"
"INSERT INTO continuous_activity VALUES(51,32,1234,10.75,3.67);"
"INSERT INTO body_observations(id,observation_id,observed_at,session_row_id,body_weight_kg,waist_cm,notes) VALUES(61,'bo_fixture','2026-09-08T07:05:00Z',21,74.25,81.5,'body note');"
"INSERT INTO "
"body_observations(id,observation_id,observed_at,session_row_id,body_weight_kg,waist_cm,notes) "
"VALUES(61,'bo_fixture','2026-09-08T07:05:00Z',21,74.25,81.5,'body note');"
"PRAGMA user_version=7;";
static bool verify_preserved_values(const char *path)
{
static bool verify_preserved_values(const char *path) {
sqlite3 *connection = NULL;
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(connection,
"SELECT s.session_id,se.entry_id,se.position,se.equipment_id,ps.position,ps.reps,ps.weight_kg "
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id ORDER BY ps.position;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(
sqlite3_prepare_v2(
connection,
"SELECT "
"s.session_id,se.entry_id,se.position,se.equipment_id,ps.position,ps.reps,ps.weight_kg "
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id ORDER BY ps.position;",
-1,
&statement,
NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "se_fixture") == 0);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 1), "sxe_fixture_sets") == 0);
@ -68,10 +95,16 @@ static bool verify_preserved_values(const char *path)
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(connection,
"SELECT se.entry_id,se.position,ca.duration_seconds,ca.speed_kmh,ca.distance_km,bo.observation_id,bo.body_weight_kg,bo.waist_cm,bo.notes "
"FROM session_exercises se JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id "
"JOIN sessions s ON s.id=se.session_row_id JOIN body_observations bo ON bo.session_row_id=s.id;",
-1, &statement, NULL) == SQLITE_OK);
"SELECT "
"se.entry_id,se.position,ca.duration_seconds,ca.speed_kmh,ca.distance_"
"km,bo.observation_id,bo.body_weight_kg,bo.waist_cm,bo.notes "
"FROM session_exercises se JOIN continuous_activity ca ON "
"ca.session_exercise_row_id=se.id "
"JOIN sessions s ON s.id=se.session_row_id JOIN body_observations bo "
"ON bo.session_row_id=s.id;",
-1,
&statement,
NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "sxe_fixture_run") == 0);
CHECK(sqlite3_column_int(statement, 1) == 1);
@ -88,12 +121,12 @@ static bool verify_preserved_values(const char *path)
return true;
}
static bool test_v7_migrates_and_current_reopens(void)
{
static bool test_v7_migrates_and_current_reopens(void) {
char path[] = "/tmp/trainlog-schema-v7-XXXXXX";
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
int version = 0;
char diagnostic[256];
int fd = mkstemp(path);
CHECK(fd >= 0);
@ -101,22 +134,29 @@ static bool test_v7_migrates_and_current_reopens(void)
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
fprintf(stderr, "migration diagnostic: %s\n", diagnostic);
return false;
}
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(database);
CHECK(verify_preserved_values(path));
database = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
fprintf(stderr, "reopen diagnostic: %s\n", diagnostic);
return false;
}
trainlog_database_close(database);
CHECK(verify_preserved_values(path));
CHECK(unlink(path) == 0);
return true;
}
static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
{
static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
char path[] = "/tmp/trainlog-schema-v7-failure-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -128,14 +168,18 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
/* Synthetic collision: only the v8 migration target is pre-created. */
CHECK(sqlite3_exec(raw, "CREATE TABLE custom_equipment (equipment_id TEXT PRIMARY KEY);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"CREATE TABLE custom_equipment (equipment_id TEXT PRIMARY KEY);",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v24: SQLite rc=",
strlen("migrate database to schema v24: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic,
"migrate database to schema v25: SQLite rc=",
strlen("migrate database to schema v25: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -143,8 +187,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
return true;
}
static bool test_newer_schema_has_application_diagnostic(void)
{
static bool test_newer_schema_has_application_diagnostic(void) {
char path[] = "/tmp/trainlog-schema-v17-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -154,19 +197,18 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=25;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=26;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 25 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 26 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;
}
static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
{
static bool test_unrecognized_historic_schema_has_application_diagnostic(void) {
char path[] = "/tmp/trainlog-schema-invalid-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -178,8 +220,8 @@ static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=-1;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version -1 is unsupported") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
@ -187,12 +229,13 @@ static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
return true;
}
int main(void)
{
int main(void) {
if (!test_v7_migrates_and_current_reopens() ||
!test_v7_migration_sqlite_failure_has_diagnostic() ||
!test_newer_schema_has_application_diagnostic() ||
!test_unrecognized_historic_schema_has_application_diagnostic()) return 1;
!test_unrecognized_historic_schema_has_application_diagnostic()) {
return 1;
}
(void)printf("PASS schema_v7_migration\n");
return 0;
}

View file

@ -13,13 +13,13 @@
#include "trainlog/database.h"
#define CHECK(condition) do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static const char *const V9_FIXTURE_SQL =
"PRAGMA foreign_keys=ON;"
@ -36,7 +36,8 @@ static const char *const V9_FIXTURE_SQL =
"target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
"equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL "
"REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL CHECK(position>=0),"
"REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL "
"CHECK(position>=0),"
"reps INTEGER CHECK(reps>=0),duration_seconds INTEGER CHECK(duration_seconds>0),"
"weight_kg REAL CHECK(weight_kg>0.0),UNIQUE(session_exercise_row_id,position),"
"CHECK((reps IS NOT NULL AND duration_seconds IS NULL) OR "
@ -46,13 +47,13 @@ static const char *const V9_FIXTURE_SQL =
"CHECK(max_weight_kg>0.0));"
"INSERT INTO exercises VALUES(7,'ex_fixture','Fixture','fixture','reps','sets',0);"
"INSERT INTO sessions VALUES(11,'se_fixture','2026-09-09T10:00:00+02:00',NULL,'training',NULL);"
"INSERT INTO session_exercises VALUES(13,'sxe_fixture',11,7,'sets',0,4,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO session_exercises "
"VALUES(13,'sxe_fixture',11,7,'sets',0,4,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO performed_sets VALUES(17,13,2,8,NULL,32.5);"
"INSERT INTO performed_sets VALUES(19,13,5,7,NULL,NULL);"
"PRAGMA user_version=9;";
static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected)
{
static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected) {
sqlite3_stmt *statement = NULL;
bool matches = false;
if (sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK &&
@ -64,8 +65,7 @@ static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected)
return matches;
}
static bool test_v9_to_current_is_lossless(void)
{
static bool test_v9_to_current_is_lossless(void) {
char path[] = "/tmp/trainlog-schema-v9-current-XXXXXX";
sqlite3 *raw = NULL;
sqlite3_stmt *rows = NULL;
@ -96,9 +96,13 @@ static bool test_v9_to_current_is_lossless(void)
CHECK(sqlite3_step(rows) == SQLITE_DONE);
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
rows = NULL;
CHECK(sqlite3_prepare_v2(raw,
"SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
"FROM performed_sets ORDER BY position;", -1, &rows, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(
raw,
"SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
"FROM performed_sets ORDER BY position;",
-1,
&rows,
NULL) == SQLITE_OK);
CHECK(sqlite3_step(rows) == SQLITE_ROW);
CHECK(sqlite3_column_int64(rows, 0) == 17);
CHECK(sqlite3_column_int64(rows, 1) == 13);
@ -113,25 +117,24 @@ static bool test_v9_to_current_is_lossless(void)
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
rows = NULL;
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(23,13,6,6,NULL,0.0);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(29,13,7,5,NULL,-0.5);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(31,13,8,4,3,NULL);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(37,999,9,4,NULL,NULL);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(23,13,6,6,NULL,0.0);", NULL, NULL, NULL) ==
SQLITE_OK);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(29,13,7,5,NULL,-0.5);", NULL, NULL, NULL) ==
SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(31,13,8,4,3,NULL);", NULL, NULL, NULL) ==
SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(
raw, "INSERT INTO performed_sets VALUES(37,999,9,4,NULL,NULL);", NULL, NULL, NULL) ==
SQLITE_CONSTRAINT);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return true;
}
static bool test_v9_to_v10_failure_rolls_back(void)
{
static bool test_v9_to_v10_failure_rolls_back(void) {
char path[] = "/tmp/trainlog-schema-v9-v10-failure-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
@ -142,29 +145,28 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V9_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"CREATE TABLE performed_sets_v9(collision INTEGER);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(
sqlite3_exec(raw, "CREATE TABLE performed_sets_v9(collision INTEGER);", NULL, NULL, NULL) ==
SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v24") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v25") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar_text_is(raw, "PRAGMA integrity_check;", "ok"));
{
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_prepare_v2(raw, "PRAGMA user_version;", -1,
&statement, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "PRAGMA user_version;", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 9);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM performed_sets;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(
raw, "SELECT COUNT(*) FROM performed_sets;", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 2);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
@ -174,10 +176,10 @@ static bool test_v9_to_v10_failure_rolls_back(void)
return true;
}
int main(void)
{
if (!test_v9_to_current_is_lossless() ||
!test_v9_to_v10_failure_rolls_back()) return 1;
int main(void) {
if (!test_v9_to_current_is_lossless() || !test_v9_to_v10_failure_rolls_back()) {
return 1;
}
(void)printf("PASS schema_v9_migration\n");
return 0;
}

View file

@ -0,0 +1,304 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include "database_internal.h"
#include "trainlog/database.h"
#include "trainlog/web_programs.h"
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return false; \
} \
} while (0)
static const char PROGRAM_JSON[] =
"{\"format\":\"trainlog-program\",\"version\":1,\"program\":{"
"\"program_id\":\"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"title\":\"Cycle lisible\",\"note\":null,\"state\":\"active\","
"\"start_date\":\"2026-09-21\",\"end_date\":null,\"sessions\":[{"
"\"program_session_id\":\"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\","
"\"title\":\"Séance A\",\"session_type\":\"training\","
"\"planned_for\":null,\"note\":null,\"occurrences\":[{"
"\"entry_id\":\"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc\","
"\"exercise_id\":\"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd\","
"\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90,"
"\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null,"
"\"target_weight_kg\":40.0,\"notes\":null}]}]}}";
static int scalar(TrainlogDatabase *database, const char *sql) {
sqlite3_stmt *statement = NULL;
int value = -1;
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
value = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return value;
}
static bool
extract_string(const char *json, const char *key, char *output, size_t output_capacity) {
char marker[96];
const char *start;
const char *end;
size_t length;
if (snprintf(marker, sizeof(marker), "\"%s\":\"", key) < 0) {
return false;
}
start = strstr(json, marker);
if (start == NULL) {
return false;
}
start += strlen(marker);
end = strchr(start, '"');
if (end == NULL) {
return false;
}
length = (size_t)(end - start);
if (length == 0U || length >= output_capacity) {
return false;
}
(void)memcpy(output, start, length);
output[length] = '\0';
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) {
TrainlogDatabase *database = NULL;
char *response = NULL;
size_t response_size = 0U;
char revision[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_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, PROGRAM_JSON, strlen(PROGRAM_JSON), false, &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(response_size > 0U);
CHECK(strstr(response, "\"imported\":false") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 0);
free(response);
response = NULL;
CHECK(trainlog_web_programs_import_json(
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"imported\":true") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_entries") == 1);
free(response);
response = NULL;
CHECK(trainlog_web_programs_import_json(
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"imported\":false") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 1);
free(response);
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(
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(extract_string(response, "revision_id", revision, sizeof(revision)));
CHECK(strstr(response, "Exercise fixture") == NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_prepare_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"request-prepare-readable",
&response,
&response_size) == TRAINLOG_STATUS_OK);
CHECK(extract_string(response, "preparation_id", preparation_id, sizeof(preparation_id)));
CHECK(scalar(database,
"SELECT COUNT(*) FROM session_preparations "
"WHERE source_program_id='pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'") == 1);
free(response);
response = NULL;
CHECK(trainlog_web_programs_archive_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
revision,
"request-archive-readable",
&response,
&response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_archive_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
revision,
"request-archive-readable",
&response,
&response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
free(response);
trainlog_database_close(database);
return true;
}
int main(void) {
return import_archive_and_prepare() && strict_import_rejections() ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -15,6 +15,7 @@
#include <sqlite3.h>
#include "database_internal.h"
#include "trainlog/database.h"
#include "trainlog/dashboard_layout.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, "revision_id", revision_id, sizeof(revision_id)));
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");
CHECK(reserve_port(&port, 0) == -2);
child = fork();
@ -307,18 +320,17 @@ static bool test_http_contract(TrainlogDatabase *database) {
revision_id);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 403") != NULL);
(void)snprintf(
request,
sizeof(request),
"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"
"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"
"Content-Length: 2\r\n\r\n{}",
preparation_id,
(unsigned int)port,
revision_id,
token);
(void)snprintf(request,
sizeof(request),
"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"
"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"
"Content-Length: 2\r\n\r\n{}",
preparation_id,
(unsigned int)port,
revision_id,
token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != 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)) &&
strstr(response, "HTTP/1.1 200") != 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,
"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)) &&
strcmp(etag, "\"0\"") == 0);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)));
(void)snprintf(
request,
sizeof(request),
"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"
"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,"
"\"date_format\":\"iso\"}",
(unsigned int)port,
token);
(void)snprintf(request,
sizeof(request),
"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"
"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,"
"\"date_format\":\"iso\"}",
(unsigned int)port,
token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"revision\":1") != NULL &&
@ -390,10 +437,7 @@ static bool test_http_contract(TrainlogDatabase *database) {
const char *config = getenv("XDG_CONFIG_HOME");
CHECK(config != NULL);
CHECK(snprintf(path,
sizeof(path),
"%s/trainlog/web/preferences-v1.json",
config) > 0);
CHECK(snprintf(path, sizeof(path), "%s/trainlog/web/preferences-v1.json", config) > 0);
stream = fopen(path, "w");
CHECK(stream != NULL);
CHECK(fputs("invalid preference\n", stream) >= 0);

View 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;
}

209
web/src/api/programs.ts Normal file
View file

@ -0,0 +1,209 @@
import { mutationCsrfToken } from './sync'
export interface ProgramListItem {
program_id: string
title: string
state: 'active' | 'archived'
start_date: string | null
end_date: string | null
session_count: number
provenance: string
updated_at: string
}
export interface ProgramOccurrence {
entry_id: string
position: number
exercise_id: string
equipment_id: string | null
recording_mode: 'sets' | 'continuous'
tracking_mode: 'reps' | 'duration'
data_fields: number
load_mode: 'none' | 'external' | 'assistance'
rest_seconds: number
target_sets: number | null
target_reps: number | null
target_duration_seconds: number | null
target_weight_kg: number | null
notes: string | null
}
export interface ProgramSession {
program_session_id: string
position: number
title: string
session_type: 'training' | 'max_test'
planned_for: string | null
note: string | null
occurrences: ProgramOccurrence[]
}
export interface ProgramDetail {
api_version: 1
program_id: string
title: string
note: string | null
state: 'active' | 'archived'
start_date: string | null
end_date: string | null
created_at: string
updated_at: string
revision_id: string
source_format: 'trainlog-program'
source_version: 1
source_payload_sha256: string
sessions: ProgramSession[]
}
export interface ProgramImportPreview {
api_version: 1
program_id: string
title: string
start_date: string | null
end_date: string | null
payload_sha256: string
session_count: number
unknown_exercise_count: number
warnings: string[]
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> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function errorReason(response: Response): Promise<string> {
const value: unknown = await response.json()
return object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
}
function requestId(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
}
export async function fetchAllPrograms(
search = '',
state = '',
signal?: AbortSignal,
): Promise<ProgramListItem[]> {
const items: ProgramListItem[] = []
const identities = new Set<string>()
let offset = 0
for (;;) {
const query = new URLSearchParams({ offset: String(offset), limit: '24' })
if (search.trim()) query.set('search', search.trim())
if (state) query.set('state', state)
const response = await fetch(`/api/v1/sessions/programs?${query}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) {
throw new Error(await errorReason(response))
}
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.offset !== offset ||
!Array.isArray(value.items) || typeof value.more !== 'boolean' ||
!Number.isSafeInteger(value.next_offset)) {
throw new TypeError('liste de programmes invalide')
}
for (const candidate of value.items) {
if (!object(candidate) || typeof candidate.program_id !== 'string' ||
typeof candidate.title !== 'string' ||
(candidate.state !== 'active' && candidate.state !== 'archived') ||
identities.has(candidate.program_id)) {
throw new TypeError('pagination de programmes incohérente')
}
identities.add(candidate.program_id)
items.push(candidate as unknown as ProgramListItem)
}
if (!value.more) return items
if (Number(value.next_offset) <= offset || value.items.length === 0) {
throw new TypeError('curseur de programmes obsolète')
}
offset = Number(value.next_offset)
}
}
export async function fetchProgram(programId: string, signal?: AbortSignal): Promise<ProgramDetail> {
const response = await fetch(`/api/v1/sessions/program/${encodeURIComponent(programId)}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) {
throw new Error(await errorReason(response))
}
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.program_id !== programId ||
!Array.isArray(value.sessions) || typeof value.revision_id !== 'string') {
throw new TypeError('programme invalide')
}
return value as unknown as ProgramDetail
}
async function programMutation(path: string, body: string, extraHeaders = {}): Promise<Response> {
const csrf = await mutationCsrfToken()
return fetch(path, {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf, 'X-Trainlog-Request-ID': requestId(), ...extraHeaders,
},
body,
})
}
export async function validateProgramImport(body: string): Promise<ProgramImportPreview> {
const response = await programMutation('/api/v1/sessions/programs/validate', body)
if (!response.ok) {
throw new Error(await errorReason(response))
}
return programImportPreview(await response.json())
}
export async function importProgram(body: string): Promise<ProgramImportPreview> {
const response = await programMutation('/api/v1/sessions/programs/import', body)
if (!response.ok) {
throw new Error(await errorReason(response))
}
return programImportPreview(await response.json())
}
export async function archiveProgram(programId: string, revision: string): Promise<void> {
const response = await programMutation(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/archive`, '{}',
{ 'If-Match': `"${revision}"` },
)
if (!response.ok) {
throw new Error(await errorReason(response))
}
}
export async function createPreparationFromProgram(
programId: string,
programSessionId: string,
): Promise<{ preparation_id: string }> {
const response = await programMutation(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/sessions/${encodeURIComponent(programSessionId)}/prepare`,
'{}',
)
if (!response.ok) {
throw new Error(await errorReason(response))
}
return response.json() as Promise<{ preparation_id: string }>
}

View file

@ -264,3 +264,34 @@ export async function withdrawPreparation(
}
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)
}
}

View 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>
}

View 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)
})
})

View file

@ -0,0 +1,290 @@
import { useEffect, useRef, useState } from 'react'
import {
archiveProgram,
createPreparationFromProgram,
fetchAllPrograms,
fetchProgram,
importProgram,
validateProgramImport,
type ProgramDetail,
type ProgramImportPreview,
type ProgramListItem,
} from '../api/programs'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatCivilDate, formatDateTime } from '../presentation/dateFormat'
function ProgramDetailView({ programId, onBack, onChanged }: {
programId: string
onBack: () => void
onChanged: () => void
}) {
const { dateFormat } = useDatePreferences()
const [program, setProgram] = useState<ProgramDetail | null>(null)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
useEffect(() => {
const controller = new AbortController()
fetchProgram(programId, controller.signal).then(setProgram).catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : 'Programme indisponible')
}
})
return () => controller.abort()
}, [programId])
if (error) {
return <p role="alert" className="error-panel">
{error} <button onClick={onBack}>Retour</button>
</p>
}
if (!program) {
return <p aria-live="polite">Chargement du programme…</p>
}
const archive = async () => {
setError('')
try {
await archiveProgram(program.program_id, program.revision_id)
setProgram(await fetchProgram(program.program_id))
setMessage('Programme archivé. Ses définitions et préparations existantes sont conservées.')
onChanged()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Archivage impossible')
}
}
const prepare = async (sessionId: string) => {
setError('')
try {
const result = await createPreparationFromProgram(program.program_id, sessionId)
setMessage('Préparation créée sans livraison Android automatique.')
window.history.pushState(null, '', `/seances/preparation/${encodeURIComponent(result.preparation_id)}`)
window.dispatchEvent(new PopStateEvent('popstate'))
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Création impossible')
}
}
return <article className="session-detail program-detail">
<div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>
← Retour aux programmes
</button>
{program.state === 'active' && <button
type="button"
className="quiet-action"
onClick={() => void archive()}
>Archiver</button>}
</div>
{message && <p role="status" className="success-panel">{message}</p>}
{error && <p role="alert" className="form-error">{error}</p>}
<header className="detail-summary">
<p className="eyebrow">
PROGRAMME · {program.state === 'active' ? 'ACTIF' : 'ARCHIVÉ'}
</p>
<h2>{program.title}</h2>
{program.note && <p>{program.note}</p>}
<p>
{program.start_date
? formatCivilDate(program.start_date, dateFormat) : 'Début non défini'}
{' — '}
{program.end_date ? formatCivilDate(program.end_date, dateFormat) : 'fin non définie'}
</p>
<p>
Import {program.source_format} v{program.source_version} · modifié le{' '}
{formatDateTime(program.updated_at, dateFormat)}
</p>
</header>
<ol className="program-session-list">
{program.sessions.map((session) => <li key={session.program_session_id}>
<div>
<strong>{session.position + 1}. {session.title}</strong>
<p>
{session.session_type === 'max_test'
? 'Test MAX planifié' : 'Entraînement planifié'}
{' · '}{session.occurrences.length} exercice
{session.occurrences.length > 1 ? 's' : ''}
</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>
<button
type="button"
className="primary-action"
onClick={() => void prepare(session.program_session_id)}
>Créer une préparation</button>
</li>)}
</ol>
</article>
}
export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
detailProgramId: string | null
onOpen: (programId: string) => void
onBack: () => void
}) {
const { dateFormat } = useDatePreferences()
const fileInput = useRef<HTMLInputElement>(null)
const [items, setItems] = useState<ProgramListItem[]>([])
const [search, setSearch] = useState('')
const [state, setState] = useState('')
const [pending, setPending] = useState(true)
const [error, setError] = useState('')
const [rawImport, setRawImport] = useState('')
const [preview, setPreview] = useState<ProgramImportPreview | null>(null)
const [fileName, setFileName] = useState('')
const reload = () => {
setPending(true)
setError('')
const controller = new AbortController()
fetchAllPrograms(search, state, controller.signal).then(setItems).catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : 'Liste indisponible')
}
}).finally(() => {
if (!controller.signal.aborted) {
setPending(false)
}
})
return controller
}
useEffect(() => {
const controller = reload()
return () => controller.abort()
}, [search, state])
if (detailProgramId) {
return <ProgramDetailView
programId={detailProgramId}
onBack={onBack}
onChanged={reload}
/>
}
const choose = async (file: File | undefined) => {
setPreview(null)
setRawImport('')
setError('')
setFileName(file?.name ?? '')
if (!file) return
if (file.size > 512 * 1024) {
setError('Le fichier dépasse la limite de 512 Kio.')
return
}
try {
const body = await file.text()
const result = await validateProgramImport(body)
setRawImport(body)
setPreview(result)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Fichier invalide')
}
}
const confirmImport = async () => {
try {
const result = await importProgram(rawImport)
setPreview(null)
setRawImport('')
setFileName('')
onOpen(result.program_id)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Import impossible')
}
}
const cancelImport = () => {
setPreview(null)
setRawImport('')
}
return <section className="programs-tab">
<div className="sessions-toolbar">
<label className="session-search">
<span>Rechercher par titre</span>
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>État
<select value={state} onChange={(event) => setState(event.target.value)}>
<option value="">Tous</option>
<option value="active">Actifs</option>
<option value="archived">Archivés</option>
</select>
</label>
<input
ref={fileInput}
className="visually-hidden"
type="file"
accept="application/json,.json"
onChange={(event) => void choose(event.target.files?.[0])}
/>
<button
type="button"
className="primary-action"
onClick={() => fileInput.current?.click()}
>Importer un programme</button>
</div>
{preview && <section className="import-preview" aria-labelledby="import-preview-title">
<h2 id="import-preview-title">Aperçu avant import</h2>
<p><strong>{preview.title}</strong> · fichier {fileName}</p>
<p>
{preview.session_count} séance
{preview.session_count > 1 ? 's' : ''}
</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>
<div className="confirmation-actions">
<button type="button" onClick={cancelImport}>Annuler</button>
<button
type="button"
className="primary-action"
onClick={() => void confirmImport()}
>Importer</button>
</div>
</section>}
{error && <p role="alert" className="error-panel">{error}</p>}
{pending && <p aria-live="polite">Chargement…</p>}
{!pending && !error && items.length === 0 &&
<p className="empty-inline">Aucun programme.</p>}
<div className="program-list">
{items.map((program) => <button
type="button"
key={program.program_id}
className="session-row program-row"
onClick={() => onOpen(program.program_id)}
>
<span>
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
<strong>{program.title}</strong>
</span>
<span>
{program.start_date
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
</span>
<span>
{program.session_count} séance{program.session_count > 1 ? 's' : ''}
</span>
<span>
{program.provenance} · {formatDateTime(program.updated_at, dateFormat)}
</span>
</button>)}
</div>
</section>
}

View file

@ -45,7 +45,14 @@ describe('Sessions page', () => {
}))
render(<SessionsPage />)
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'))
expect(window.location.pathname).toBe('/seances/proposal/aid_target')
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' })
fireEvent.click(deleteTrigger)
let dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent('toute exécution commencée sera conservée')
const keepButton = within(dialog).getByRole('button', { name: 'Conserver la préparation' })
expect(dialog).toHaveTextContent('sans effacer un entraînement réel')
const keepButton = within(dialog).getByRole('button', { name: 'Conserver' })
expect(keepButton).toHaveFocus()
fireEvent.click(keepButton)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await waitFor(() => expect(deleteTrigger).toHaveFocus())
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' }))
dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer la préparation' }))

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
fetchCatalog,
deleteSessionResource,
fetchSessionDetail,
fetchReadonlySessionDetail,
prepareForAndroid,
@ -14,6 +15,7 @@ import {
type SessionDetail,
type SessionListItem,
} from '../api/sessions'
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
import { useDatePreferences } from '../presentation/DatePreferences'
import {
formatCivilDate,
@ -21,14 +23,72 @@ import {
validDateSortValue,
validTimestampValue,
} from '../presentation/dateFormat'
import { ProgramsTab } from './ProgramsTab'
type View = 'preparation' | 'resume' | 'history'
const collections: Record<View, SessionCollection[]> = {
type View = 'preparation' | 'resume' | 'history' | 'programs'
const collections: Record<Exclude<View, 'programs'>, SessionCollection[]> = {
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
}
function viewLabel(view: View): string {
const labels: Record<View, string> = {
preparation: 'Préparation',
resume: 'À reprendre',
history: 'Historique',
programs: 'Programmes',
}
return labels[view]
}
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 {
return `${left.collection}:${left.identity}`.localeCompare(
`${right.collection}:${right.identity}`, 'en')
@ -239,8 +299,7 @@ function Editor({ initial, onSaved, onCancel }: {
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
const { dateFormat } = useDatePreferences()
const deleteTrigger = useRef<HTMLButtonElement>(null)
const cancelDelete = useRef<HTMLButtonElement>(null)
const deleteTrigger = useRef<HTMLElement | null>(null)
const [detail, setDetail] = useState<SessionDetail | null>(null)
const [failed, setFailed] = useState(false)
const [confirming, setConfirming] = useState(false)
@ -254,9 +313,6 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
if (!controller.signal.aborted) setFailed(true)
})
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 (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
const withdrawn = detail.state === 'withdrawn'
@ -264,17 +320,20 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
setDeleting(true)
setDeleteError('')
try {
const result = await withdrawPreparation(detail.identity, detail.revision_id)
setConfirming(false)
setMessage(result.android_cancellation === 'pending'
? 'Supprimée sur le PC — annulation Android à synchroniser.'
: 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.')
setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id })
try {
setDetail(await fetchSessionDetail('preparation', detail.identity))
} catch {
setMessage((current) => `${current} Rechargez la fiche pour obtenir l’état synchronisé.`)
if (kind === 'preparation') {
const result = await withdrawPreparation(detail.identity, detail.revision_id)
setMessage(result.android_cancellation === 'pending'
? 'Supprimée sur le PC — annulation Android à synchroniser.'
: 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.')
setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id })
} else {
await deleteSessionResource('proposal', detail.identity, detail.revision_id)
setMessage('Proposition supprimée. Son retrait sera propagé sans toucher aux préparations dérivées.')
setConfirming(false)
onBack()
return
}
setConfirming(false)
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
@ -286,27 +345,28 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
{!withdrawn && <div className="detail-actions">
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button>
{kind === 'preparation' && <button ref={deleteTrigger} type="button" className="danger-action"
onClick={() => setConfirming(true)}>Supprimer la préparation</button>}
<button
ref={(element) => { deleteTrigger.current = element }}
type="button"
className="danger-action"
onClick={() => setConfirming(true)}
>{deletionLabel(kind)}</button>
</div>}</div>
{message && <p className="success-panel" role="status">{message}</p>}
{deleteError && <p className="form-error" role="alert">{deleteError}</p>}
{confirming && <section className="delete-confirmation" role="alertdialog" aria-modal="true"
aria-labelledby="delete-preparation-title">
<h3 id="delete-preparation-title">Supprimer « {detail.title || 'Sans titre'} » ?</h3>
<p>{detail.state === 'local'
? 'La préparation sera retirée du planning. Elle n’a aucune livraison Android connue.'
: '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>
<div className="confirmation-actions">
<button ref={cancelDelete} type="button" className="quiet-action" disabled={deleting}
onClick={() => {
setConfirming(false)
requestAnimationFrame(() => deleteTrigger.current?.focus())
}}>Conserver la préparation</button>
<button type="button" className="danger-action" disabled={deleting}
onClick={() => void remove()}>{deleting ? 'Suppression…' : 'Supprimer la préparation'}</button>
</div>
</section>}
{confirming && <DeleteConfirmationDialog
title={detail.title || 'Sans titre'}
itemType={deletionType(kind)}
date={detail.planned_for ? formatCivilDate(detail.planned_for, 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 === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
<h2>{detail.title || 'Sans titre'}</h2><p>
{detail.planned_for === null ? 'Non planifiée' : <time dateTime={detail.planned_for}>
@ -340,8 +400,12 @@ function ReadonlyDetail({ kind, identity, onBack }: {
kind: 'draft' | 'history'; identity: string; onBack: () => void
}) {
const { dateFormat } = useDatePreferences()
const deleteTrigger = useRef<HTMLElement | null>(null)
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
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)
fetchReadonlySessionDetail(kind, identity, controller.signal).then((value) => {
if (!controller.signal.aborted) setDetail(value)
@ -356,8 +420,47 @@ function ReadonlyDetail({ kind, identity, onBack }: {
? detail.payload as Record<string, unknown> : null
const startedAt = typeof detail.started_at === 'string' ? detail.started_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">
<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>
<h2>{String(detail.session_type ?? 'Séance')}</h2>
<p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
@ -391,13 +494,28 @@ export function SessionsPage() {
const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false)
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 loadRevision = useRef(0)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => { const controller = new AbortController(); const revision = ++loadRevision.current
setPending(true); setFailed(false)
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
.then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
const load = () => {
const controller = new AbortController()
const revision = ++loadRevision.current
setPending(true)
setFailed(false)
if (view === 'programs') {
setItems([])
setPending(false)
return controller
}
const pageRequests = collections[view].map((collection) =>
fetchAllSessionPages(collection, search, controller.signal).then((pageItems) =>
pageItems.map((item) => ({ ...item, collection }))))
Promise.all(pageRequests)
.then((pages) => {
if (revision === loadRevision.current) setItems(sortSessionItems(pages.flat(), view))
}).catch((reason) => {
@ -418,6 +536,13 @@ export function SessionsPage() {
window.history.pushState(null, '', path); setDetailPath(path)
}
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
const programMatch = useMemo(() => detailPath.match(/^\/seances\/program\/([^/]+)$/), [detailPath])
const openProgram = (identity: string) => {
const path = `/seances/program/${encodeURIComponent(identity)}`
window.history.pushState(null, '', path)
setDetailPath(path)
setView('programs')
}
const visibleItems = useMemo(() => sortSessionItems(items.filter((item) => {
if (stateFilter && item.state !== stateFilter) return false
if (dateFrom && (!item.date || item.date < dateFrom)) return false
@ -430,6 +555,34 @@ export function SessionsPage() {
return owner === undefined ? `État non reconnu (${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}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
@ -439,14 +592,35 @@ export function SessionsPage() {
if (detailMatch) return <section className="page"><ReadonlyDetail
kind={detailMatch[1] as 'draft' | 'history'} identity={decodeURIComponent(detailMatch[2])}
onBack={back} /></section>
if (programMatch) {
return <section className="page">
<ProgramsTab
detailProgramId={decodeURIComponent(programMatch[1])}
onOpen={openProgram}
onBack={back}
/>
</section>
}
return <section className="page sessions-page" aria-labelledby="page-title"><div className="page-heading">
<div><p className="eyebrow">ORGANISER</p><h1 id="page-title">Séances</h1></div>
<p className="page-intro">Préparez sans confondre proposition, brouillon d’exécution et séance réalisée.</p></div>
<div className="sessions-toolbar"><div className="session-tabs" role="tablist">
{(['preparation', 'resume', 'history'] as const).map((candidate) => <button type="button" role="tab"
aria-selected={view === candidate} key={candidate} onClick={() => setView(candidate)}>
{candidate === 'preparation' ? 'Préparation' : candidate === 'resume' ? 'À reprendre' : 'Historique'}</button>)}</div>
{(['preparation', 'resume', 'history', 'programs'] as const).map((candidate) =>
<button
type="button"
role="tab"
aria-selected={view === candidate}
key={candidate}
onClick={() => setView(candidate)}
>{viewLabel(candidate)}</button>)}</div>
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
{view === 'programs' && <ProgramsTab
detailProgramId={null}
onOpen={openProgram}
onBack={back}
/>}
{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}
onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances">
@ -460,13 +634,48 @@ export function SessionsPage() {
<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 && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date === 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>)}</div>
<div className="session-list">{visibleItems.map((item) => {
const kind = deletionKind(item.collection)
return <div className="session-row" key={`${item.collection}-${item.identity}`}>
<button
type="button"
className="session-row-main"
onClick={() => open(item.collection, item.identity)}
>
<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>
}

View file

@ -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); }
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
.session-list { display: grid; gap: .6rem; }
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
.session-row:hover { border-color: var(--lavender); background: var(--base); }
.session-row {
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 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); }
@ -112,13 +135,62 @@ 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 h3 { margin-top: 0; }
.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-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 p { margin: .35rem 0 0; color: var(--subtext-0); }
.import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .program-session-list li { align-items: stretch; flex-direction: column; } }
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
.detail-occurrences li { display: grid; gap: .35rem; }
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
.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; }
@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: 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; } }
@media (max-width: 52rem) {
.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; }
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }