Compare commits
6 commits
679ed6184a
...
a0fc48f082
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fc48f082 | ||
|
|
4dffb05210 | ||
|
|
c8a292326d | ||
|
|
a4d7f5d350 | ||
|
|
0cff443893 | ||
|
|
ed9395366e |
|
|
@ -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`;
|
||||
|
|
|
|||
14
CHANGELOG.md
|
|
@ -2,6 +2,20 @@
|
|||
|
||||
## Unreleased — 0.1.2
|
||||
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,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 |
|
||||
|
|
@ -221,10 +221,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 +242,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
BIN
docs/reviews/evidence/web-programs-empty-mobile.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 86 KiB |
47
docs/reviews/web_sessions_delete_programs_v1_evidence.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Web Sessions deletion and Programs V1 evidence
|
||||
|
||||
Date: 2026-09-18
|
||||
|
||||
This pre-deployment record covers the completed development branch. It does not
|
||||
claim installation, migration of real data, service restart, phone validation,
|
||||
tagging or release publication.
|
||||
|
||||
## 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.
|
||||
|
||||
## Deployment gate
|
||||
|
||||
Desktop schema v25 and proposal synchronization V2 require the grouped gate:
|
||||
retain the installed package, create consistent desktop and Android backups,
|
||||
validate v24→v25 migration on a database copy, verify Android package signing
|
||||
and monotonic versioning, then install/restart/synchronize and inspect the real
|
||||
Web routes only after explicit authorization. No operation in this record
|
||||
crossed that gate.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
56
tui/include/trainlog/web_programs.h
Normal 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
|
||||
27
tui/include/trainlog/web_session_deletions.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* Trainlog Web Sessions deletion command service. */
|
||||
#ifndef TRAINLOG_WEB_SESSION_DELETIONS_H
|
||||
#define TRAINLOG_WEB_SESSION_DELETIONS_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
/*
|
||||
* CONTRACT: kind is proposal, draft, or history. Preparation withdrawal keeps
|
||||
* its separate delivery-aware service. expected_revision and request_id are
|
||||
* required bounded strings. Exact request replay returns the original JSON.
|
||||
* No performed session is deleted by proposal or draft removal.
|
||||
*
|
||||
* On success output_json transfers one heap allocation to the caller, which
|
||||
* must release it with free(). No allocation is returned on failure.
|
||||
*/
|
||||
TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database,
|
||||
const char *kind,
|
||||
const char *target_id,
|
||||
const char *expected_revision,
|
||||
const char *request_id,
|
||||
char **output_json,
|
||||
size_t *output_size);
|
||||
|
||||
#endif
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
6512
tui/src/database.c
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
600
tui/src/web_session_deletions.c
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
#include "trainlog/web_session_deletions.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <sqlite3.h>
|
||||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/id.h"
|
||||
#include "trainlog/model.h"
|
||||
|
||||
#define CAUSAL_OPERATION_MAX 4096
|
||||
#define CAUSAL_TEXT_BUDGET (3 * 1024 * 1024)
|
||||
#define PROPOSAL_WITHDRAWAL_MAX 256
|
||||
|
||||
typedef struct DeleteContext {
|
||||
const char *kind;
|
||||
const char *target_id;
|
||||
const char *expected_revision;
|
||||
const char *request_id;
|
||||
char created_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||
} DeleteContext;
|
||||
|
||||
static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
|
||||
time_t now = time(NULL);
|
||||
struct tm utc;
|
||||
|
||||
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
|
||||
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
|
||||
}
|
||||
|
||||
static bool copy_column(sqlite3_stmt *statement, int column, char **output, size_t *output_size) {
|
||||
const char *value = (const char *)sqlite3_column_text(statement, column);
|
||||
int bytes = sqlite3_column_bytes(statement, column);
|
||||
|
||||
if (value == NULL || bytes <= 0) {
|
||||
return false;
|
||||
}
|
||||
*output = malloc((size_t)bytes + 1U);
|
||||
if (*output == NULL) {
|
||||
return false;
|
||||
}
|
||||
(void)memcpy(*output, value, (size_t)bytes);
|
||||
(*output)[bytes] = '\0';
|
||||
*output_size = (size_t)bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
static TrainlogStatus replay_request(TrainlogDatabase *database,
|
||||
const DeleteContext *context,
|
||||
char **output_json,
|
||||
size_t *output_size,
|
||||
bool *replayed) {
|
||||
static const char SQL[] =
|
||||
"SELECT resource_kind,target_id,expected_revision,response_json "
|
||||
"FROM web_session_deletion_requests WHERE request_id=?1 OR "
|
||||
"(resource_kind=?2 AND target_id=?3) ORDER BY request_id=?1 DESC LIMIT 1";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int result;
|
||||
|
||||
*replayed = false;
|
||||
result = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, context->request_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, context->kind, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (result == SQLITE_DONE) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (result != SQLITE_ROW) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (strcmp((const char *)sqlite3_column_text(statement, 0), context->kind) != 0 ||
|
||||
strcmp((const char *)sqlite3_column_text(statement, 1), context->target_id) != 0 ||
|
||||
strcmp((const char *)sqlite3_column_text(statement, 2), context->expected_revision) != 0) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
if (!copy_column(statement, 3, output_json, output_size)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
free(*output_json);
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
*replayed = true;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static TrainlogStatus load_current_revision(TrainlogDatabase *database,
|
||||
const char *kind,
|
||||
const char *target_id,
|
||||
char revision[TRAINLOG_ID_MAX + 1U],
|
||||
bool *active_draft) {
|
||||
static const char DRAFT_SQL[] =
|
||||
"SELECT revision_id,state FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS("
|
||||
"SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 "
|
||||
"AND deleted=1)";
|
||||
static const char HISTORY_SQL[] =
|
||||
"SELECT c.current_revision_id,'' FROM sessions s JOIN sync_causal_state c ON "
|
||||
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=0 "
|
||||
"WHERE s.session_id=?1";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
const char *sql = strcmp(kind, "draft") == 0 ? DRAFT_SQL : HISTORY_SQL;
|
||||
const char *current;
|
||||
int result;
|
||||
|
||||
*active_draft = false;
|
||||
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (result != SQLITE_ROW) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
current = (const char *)sqlite3_column_text(statement, 0);
|
||||
if (current == NULL || strlen(current) > TRAINLOG_ID_MAX) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
(void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current);
|
||||
*active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0;
|
||||
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static bool sha256_hex(const char *input, char output[65]) {
|
||||
unsigned char digest[32];
|
||||
unsigned int digest_size = 0U;
|
||||
EVP_MD_CTX *context = EVP_MD_CTX_new();
|
||||
size_t index;
|
||||
bool success;
|
||||
|
||||
if (context == NULL) {
|
||||
return false;
|
||||
}
|
||||
success = EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
|
||||
EVP_DigestUpdate(context, input, strlen(input)) == 1 &&
|
||||
EVP_DigestFinal_ex(context, digest, &digest_size) == 1 && digest_size == 32U;
|
||||
EVP_MD_CTX_free(context);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
for (index = 0U; index < 32U; ++index) {
|
||||
(void)snprintf(output + (index * 2U), 3U, "%02x", digest[index]);
|
||||
}
|
||||
output[64] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static TrainlogStatus causal_capacity_available(TrainlogDatabase *database) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
sqlite3_int64 count;
|
||||
sqlite3_int64 bytes;
|
||||
int result;
|
||||
|
||||
result = sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"SELECT COUNT(*),COALESCE(SUM(length(operation_id)+length(target_kind)+"
|
||||
"length(target_id)+length(creator_id)+length(predecessor_revision_id)+"
|
||||
"length(created_at)+length(payload_sha256)+128),0) FROM sync_causal_operations",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (result != SQLITE_ROW) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
count = sqlite3_column_int64(statement, 0);
|
||||
bytes = sqlite3_column_int64(statement, 1);
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
return count < CAUSAL_OPERATION_MAX && bytes < CAUSAL_TEXT_BUDGET ? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
|
||||
static TrainlogStatus insert_causal_operation(TrainlogDatabase *database,
|
||||
const DeleteContext *context,
|
||||
const char *causal_kind,
|
||||
const char *predecessor_revision,
|
||||
const char *operation_id) {
|
||||
static const char CREATOR[] = "peer_desktop_web";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char payload[1024];
|
||||
char digest[65];
|
||||
int written;
|
||||
int result;
|
||||
|
||||
written = snprintf(payload,
|
||||
sizeof(payload),
|
||||
"{\"created_at\":\"%s\",\"creator_id\":\"%s\","
|
||||
"\"operation_id\":\"%s\",\"predecessor_revision_id\":\"%s\","
|
||||
"\"publication_context\":null,\"target_id\":\"%s\","
|
||||
"\"target_kind\":\"%s\"}",
|
||||
context->created_at,
|
||||
CREATOR,
|
||||
operation_id,
|
||||
predecessor_revision,
|
||||
context->target_id,
|
||||
causal_kind);
|
||||
if (written < 0 || (size_t)written >= sizeof(payload) || !sha256_hex(payload, digest)) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
result =
|
||||
sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO sync_causal_operations VALUES(?1,?2,?3,?4,?5,?6,?7,NULL)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, causal_kind, -1, SQLITE_STATIC);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 4, CREATOR, -1, SQLITE_STATIC);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 5, predecessor_revision, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 6, context->created_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 7, digest, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus apply_causal_effect(TrainlogDatabase *database,
|
||||
const DeleteContext *context,
|
||||
const char *causal_kind,
|
||||
const char *operation_id) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int result;
|
||||
|
||||
if (strcmp(context->kind, "history") == 0) {
|
||||
result = sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id "
|
||||
"FROM sessions WHERE session_id=?1)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||
result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
if (result != SQLITE_DONE) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
strcmp(context->kind, "draft") == 0
|
||||
? "DELETE FROM execution_drafts WHERE session_id=?1"
|
||||
: "DELETE FROM sessions WHERE session_id=?1",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
if (result != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (strcmp(context->kind, "history") == 0) {
|
||||
char final_revision[TRAINLOG_GENERATED_ID_CAPACITY + sizeof("deleted:")];
|
||||
int written = snprintf(final_revision, sizeof(final_revision), "deleted:%s", operation_id);
|
||||
|
||||
if (written < 0 || (size_t)written >= sizeof(final_revision)) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
result = sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"INSERT OR IGNORE INTO execution_draft_finalizations VALUES(?1,?2,?3)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, final_revision, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, context->created_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||
result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
if (result != SQLITE_DONE) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"INSERT OR REPLACE INTO sync_causal_state VALUES(?1,?2,?3,1,?3)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, causal_kind, -1, SQLITE_STATIC);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, operation_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus delete_causal_resource(TrainlogDatabase *database,
|
||||
const DeleteContext *context) {
|
||||
const char *causal_kind = strcmp(context->kind, "draft") == 0 ? "execution_draft" : "session";
|
||||
char current_revision[TRAINLOG_ID_MAX + 1U];
|
||||
char operation_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
bool active_draft;
|
||||
TrainlogStatus status;
|
||||
|
||||
status = load_current_revision(
|
||||
database, context->kind, context->target_id, current_revision, &active_draft);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
if (active_draft || strcmp(current_revision, context->expected_revision) != 0) {
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
status = causal_capacity_available(database);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
if (trainlog_id_generate("del", operation_id, sizeof(operation_id)) != TRAINLOG_STATUS_OK) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
status =
|
||||
insert_causal_operation(database, context, causal_kind, current_revision, operation_id);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = apply_causal_effect(database, context, causal_kind, operation_id);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus delete_proposal(TrainlogDatabase *database, const DeleteContext *context) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
const char *revision;
|
||||
int result;
|
||||
|
||||
result = sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"SELECT COALESCE(i.payload_sha256,d.draft_id) FROM ai_session_drafts d LEFT JOIN "
|
||||
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1 AND "
|
||||
"d.withdrawn_at IS NULL",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (result != SQLITE_ROW) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
revision = (const char *)sqlite3_column_text(statement, 0);
|
||||
if (revision == NULL || strcmp(revision, context->expected_revision) != 0) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT COUNT(*) FROM ai_session_drafts "
|
||||
"WHERE withdrawn_at IS NOT NULL",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (result != SQLITE_ROW) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (sqlite3_column_int64(statement, 0) >= PROPOSAL_WITHDRAWAL_MAX) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"UPDATE ai_session_drafts SET withdrawn_at=?1 WHERE draft_id=?2 "
|
||||
"AND withdrawn_at IS NULL",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, context->created_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
if (result != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static TrainlogStatus
|
||||
build_response(const DeleteContext *context, char **output_json, size_t *output_size) {
|
||||
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
|
||||
if (document == NULL || root == NULL) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||
!yyjson_mut_obj_add_strcpy(document, root, "kind", context->kind) ||
|
||||
!yyjson_mut_obj_add_strcpy(document, root, "identity", context->target_id) ||
|
||||
!yyjson_mut_obj_add_strcpy(document, root, "state", "deleted") ||
|
||||
!yyjson_mut_obj_add_strcpy(document, root, "deleted_at", context->created_at)) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
*output_json = yyjson_mut_write(document, 0U, output_size);
|
||||
yyjson_mut_doc_free(document);
|
||||
return *output_json != NULL ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus
|
||||
store_request(TrainlogDatabase *database, const DeleteContext *context, const char *response_json) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int result;
|
||||
|
||||
result = sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO web_session_deletion_requests "
|
||||
"VALUES(?1,?2,?3,?4,?5,?6)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, context->request_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, context->kind, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 4, context->expected_revision, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 5, response_json, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 6, context->created_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database,
|
||||
const char *kind,
|
||||
const char *target_id,
|
||||
const char *expected_revision,
|
||||
const char *request_id,
|
||||
char **output_json,
|
||||
size_t *output_size) {
|
||||
DeleteContext context;
|
||||
TrainlogStatus status;
|
||||
bool replayed;
|
||||
|
||||
if (database == NULL || kind == NULL || target_id == NULL || expected_revision == NULL ||
|
||||
request_id == NULL || output_json == NULL || output_size == NULL || target_id[0] == '\0' ||
|
||||
expected_revision[0] == '\0' || request_id[0] == '\0' ||
|
||||
strlen(target_id) > TRAINLOG_ID_MAX || strlen(expected_revision) > TRAINLOG_ID_MAX ||
|
||||
strlen(request_id) > 128U ||
|
||||
(strcmp(kind, "proposal") != 0 && strcmp(kind, "draft") != 0 &&
|
||||
strcmp(kind, "history") != 0)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
context.kind = kind;
|
||||
context.target_id = target_id;
|
||||
context.expected_revision = expected_revision;
|
||||
context.request_id = request_id;
|
||||
if (!timestamp_now(context.created_at)) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
status = replay_request(database, &context, output_json, output_size, &replayed);
|
||||
if (status == TRAINLOG_STATUS_OK && replayed) {
|
||||
status = sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK
|
||||
? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return status;
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = strcmp(kind, "proposal") == 0 ? delete_proposal(database, &context)
|
||||
: delete_causal_resource(database, &context);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = build_response(&context, output_json, output_size);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = store_request(database, &context, *output_json);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK &&
|
||||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||
free(*output_json);
|
||||
*output_json = NULL;
|
||||
*output_size = 0U;
|
||||
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||
}
|
||||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
304
tui/tests/test_web_programs.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
185
tui/tests/test_web_session_deletions.c
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/status.h"
|
||||
#include "trainlog/web_session_deletions.h"
|
||||
|
||||
#define CHECK(condition) \
|
||||
do { \
|
||||
if (!(condition)) { \
|
||||
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
|
||||
return false; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static sqlite3_int64 scalar(TrainlogDatabase *database, const char *sql) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
sqlite3_int64 value = -1;
|
||||
|
||||
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
value = sqlite3_column_int64(statement, 0);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool proposal_deletion_is_durable_and_preserves_derivatives(void) {
|
||||
char path[] = "/tmp/trainlog-web-delete-proposal-XXXXXX";
|
||||
TrainlogDatabase *database = NULL;
|
||||
char *response = NULL;
|
||||
char *replayed = NULL;
|
||||
size_t response_size = 0U;
|
||||
size_t replayed_size = 0U;
|
||||
int descriptor = mkstemp(path);
|
||||
|
||||
CHECK(descriptor >= 0);
|
||||
CHECK(close(descriptor) == 0);
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO ai_session_drafts("
|
||||
"draft_id,created_at,session_type,title,archive_status,published_at) "
|
||||
"VALUES('aid_11111111-1111-4111-8111-111111111111',"
|
||||
"'2026-09-18T10:00:00Z','training','Proposal','archived',"
|
||||
"'2026-09-18T11:00:00Z');"
|
||||
"INSERT INTO ai_session_draft_imports VALUES("
|
||||
"last_insert_rowid(),'aid_11111111-1111-4111-8111-111111111111',"
|
||||
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||
"'2026-09-18T10:00:00Z');"
|
||||
"INSERT INTO session_preparations VALUES("
|
||||
"'sp_22222222-2222-4222-8222-222222222222','spr_source',"
|
||||
"'2026-09-18T10:00:00Z','2026-09-18T10:00:00Z','draft','local',"
|
||||
"'aid_11111111-1111-4111-8111-111111111111',"
|
||||
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||
"NULL,NULL,NULL);",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_session_delete_json(
|
||||
database,
|
||||
"proposal",
|
||||
"aid_11111111-1111-4111-8111-111111111111",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"request-delete-proposal",
|
||||
&response,
|
||||
&response_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(response != NULL && strstr(response, "\"state\":\"deleted\"") != NULL);
|
||||
CHECK(trainlog_web_session_delete_json(
|
||||
database,
|
||||
"proposal",
|
||||
"aid_11111111-1111-4111-8111-111111111111",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"request-delete-proposal",
|
||||
&replayed,
|
||||
&replayed_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(response_size == replayed_size && strcmp(response, replayed) == 0);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM ai_session_drafts WHERE withdrawn_at IS NOT NULL") == 1);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM web_session_deletion_requests") == 1);
|
||||
free(replayed);
|
||||
free(response);
|
||||
trainlog_database_close(database);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool draft_and_history_use_causal_deletion(void) {
|
||||
char path[] = "/tmp/trainlog-web-delete-causal-XXXXXX";
|
||||
TrainlogDatabase *database = NULL;
|
||||
char *response = NULL;
|
||||
size_t response_size = 0U;
|
||||
int descriptor = mkstemp(path);
|
||||
|
||||
CHECK(descriptor >= 0);
|
||||
CHECK(close(descriptor) == 0);
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO execution_drafts VALUES("
|
||||
"'se_33333333-3333-4333-8333-333333333333','training',NULL,"
|
||||
"'2026-09-18T10:00:00Z','mu_draft',NULL,'pending','{}');"
|
||||
"INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES("
|
||||
"'se_44444444-4444-4444-8444-444444444444',"
|
||||
"'2026-09-17T10:00:00Z','2026-09-17T11:00:00Z','training');"
|
||||
"INSERT INTO sync_causal_state VALUES("
|
||||
"'session','se_44444444-4444-4444-8444-444444444444',"
|
||||
"'mu_history',0,NULL);",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_session_delete_json(database,
|
||||
"draft",
|
||||
"se_33333333-3333-4333-8333-333333333333",
|
||||
"mu_draft",
|
||||
"request-delete-draft",
|
||||
&response,
|
||||
&response_size) == TRAINLOG_STATUS_OK);
|
||||
free(response);
|
||||
response = NULL;
|
||||
CHECK(trainlog_web_session_delete_json(database,
|
||||
"history",
|
||||
"se_44444444-4444-4444-8444-444444444444",
|
||||
"mu_history",
|
||||
"request-delete-history",
|
||||
&response,
|
||||
&response_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 0);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM sessions") == 0);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 2);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_state WHERE deleted=1") == 2);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM execution_draft_finalizations "
|
||||
"WHERE session_id='se_44444444-4444-4444-8444-444444444444' "
|
||||
"AND final_revision_id LIKE 'deleted:del_%'") == 1);
|
||||
free(response);
|
||||
trainlog_database_close(database);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool active_draft_conflicts_without_mutation(void) {
|
||||
char path[] = "/tmp/trainlog-web-delete-active-XXXXXX";
|
||||
TrainlogDatabase *database = NULL;
|
||||
char *response = NULL;
|
||||
size_t response_size = 0U;
|
||||
int descriptor = mkstemp(path);
|
||||
|
||||
CHECK(descriptor >= 0);
|
||||
CHECK(close(descriptor) == 0);
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO execution_drafts VALUES("
|
||||
"'se_55555555-5555-4555-8555-555555555555','training',NULL,"
|
||||
"'2026-09-18T10:00:00Z','mu_active',NULL,'active','{}');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_session_delete_json(database,
|
||||
"draft",
|
||||
"se_55555555-5555-4555-8555-555555555555",
|
||||
"mu_active",
|
||||
"request-delete-active",
|
||||
&response,
|
||||
&response_size) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(response == NULL && response_size == 0U);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 1);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 0);
|
||||
trainlog_database_close(database);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return proposal_deletion_is_durable_and_preserves_derivatives() &&
|
||||
draft_and_history_use_causal_deletion() &&
|
||||
active_draft_conflicts_without_mutation()
|
||||
? EXIT_SUCCESS
|
||||
: EXIT_FAILURE;
|
||||
}
|
||||
209
web/src/api/programs.ts
Normal 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 }>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
web/src/components/DeleteConfirmationDialog.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
title: string
|
||||
itemType: string
|
||||
date?: string | null
|
||||
consequence: string
|
||||
cancelLabel: string
|
||||
confirmLabel: string
|
||||
busy: boolean
|
||||
error: string
|
||||
returnFocus: RefObject<HTMLElement | null>
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog(props: DeleteConfirmationDialogProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null)
|
||||
const cancel = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
cancel.current?.focus()
|
||||
const returnTarget = props.returnFocus.current
|
||||
return () => returnTarget?.focus()
|
||||
}, [props.returnFocus])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Escape' && !props.busy) {
|
||||
event.preventDefault()
|
||||
props.onCancel()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const buttons = dialog.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')
|
||||
if (!buttons || buttons.length === 0) return
|
||||
const first = buttons[0]
|
||||
const last = buttons[buttons.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="delete-dialog-backdrop">
|
||||
<div
|
||||
ref={dialog}
|
||||
className="delete-confirmation"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-consequence"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<p className="eyebrow">{props.itemType}</p>
|
||||
<h2 id="delete-dialog-title">{props.confirmLabel} « {props.title} » ?</h2>
|
||||
{props.date && <p>{props.date}</p>}
|
||||
<p id="delete-dialog-consequence">{props.consequence}</p>
|
||||
{props.error && <p className="form-error" role="alert">{props.error}</p>}
|
||||
<div className="confirmation-actions">
|
||||
<button
|
||||
ref={cancel}
|
||||
type="button"
|
||||
className="quiet-action"
|
||||
disabled={props.busy}
|
||||
onClick={props.onCancel}
|
||||
>{props.cancelLabel}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-action"
|
||||
disabled={props.busy}
|
||||
onClick={props.onConfirm}
|
||||
>{props.busy ? 'Suppression…' : props.confirmLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
151
web/src/routes/ProgramsTab.test.tsx
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
290
web/src/routes/ProgramsTab.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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' }))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||