Compare commits
10 Commits
chore/fix-
...
chore/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b43eb199d | |||
| 480a61b071 | |||
| 390f2c33e5 | |||
| 16485dc4a9 | |||
| a43ce5f188 | |||
| 0519ae2dd0 | |||
| 0d5b790e52 | |||
| 342e21b9ea | |||
| 4dec9e182b | |||
| c7ae883c6a |
@@ -6,7 +6,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
force:
|
force:
|
||||||
description: "Force deploy even if gate would skip (1=yes, 0=no)"
|
description: "Force FULL deploy (rebuild+restart) even if gate would hotpatch-only (1=yes, 0=no)"
|
||||||
required: false
|
required: false
|
||||||
default: "0"
|
default: "0"
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ env:
|
|||||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||||
DOCKER_API_VERSION: "1.43"
|
DOCKER_API_VERSION: "1.43"
|
||||||
COMPOSE_VERSION: "2.29.7"
|
COMPOSE_VERSION: "2.29.7"
|
||||||
|
ASTRO_TELEMETRY_DISABLED: "1"
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -37,77 +38,62 @@ jobs:
|
|||||||
node --version
|
node --version
|
||||||
npm --version
|
npm --version
|
||||||
|
|
||||||
- name: Checkout (from event.json, no external actions)
|
- name: Checkout (push or workflow_dispatch, no external actions)
|
||||||
|
env:
|
||||||
|
EVENT_JSON: /var/run/act/workflow/event.json
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
EVENT_JSON="/var/run/act/workflow/event.json"
|
|
||||||
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
|
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
|
||||||
|
|
||||||
# Node prints REPO_URL, DEFAULT_BRANCH, REF, SHA_CAND (may be empty in workflow_dispatch)
|
node --input-type=module <<'NODE'
|
||||||
OUT="$(node --input-type=module -e '
|
import fs from "node:fs";
|
||||||
import fs from "node:fs";
|
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
|
||||||
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON,"utf8"));
|
const repoObj = ev?.repository || {};
|
||||||
|
const cloneUrl =
|
||||||
|
repoObj?.clone_url ||
|
||||||
|
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
|
||||||
|
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
|
||||||
|
|
||||||
const repoObj = ev?.repository || {};
|
const defaultBranch = repoObj?.default_branch || "main";
|
||||||
const repo =
|
const sha =
|
||||||
repoObj?.clone_url ||
|
(process.env.GITHUB_SHA && String(process.env.GITHUB_SHA).trim()) ||
|
||||||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
|
ev?.after ||
|
||||||
|
ev?.sha ||
|
||||||
|
ev?.head_commit?.id ||
|
||||||
|
ev?.pull_request?.head?.sha ||
|
||||||
|
"";
|
||||||
|
|
||||||
if (!repo) throw new Error("No repository url in event.json");
|
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||||
|
fs.writeFileSync("/tmp/deploy.env", [
|
||||||
|
`REPO_URL=${shq(cloneUrl)}`,
|
||||||
|
`DEFAULT_BRANCH=${shq(defaultBranch)}`,
|
||||||
|
`SHA=${shq(sha)}`
|
||||||
|
].join("\n") + "\n");
|
||||||
|
NODE
|
||||||
|
|
||||||
const defaultBranch = repoObj?.default_branch || "main";
|
source /tmp/deploy.env
|
||||||
|
echo "Repo URL: $REPO_URL"
|
||||||
const ref =
|
echo "Default branch: $DEFAULT_BRANCH"
|
||||||
ev?.ref || `refs/heads/${defaultBranch}`;
|
echo "SHA: ${SHA:-<empty>}"
|
||||||
|
|
||||||
const sha =
|
|
||||||
ev?.after ||
|
|
||||||
ev?.pull_request?.head?.sha ||
|
|
||||||
ev?.head_commit?.id ||
|
|
||||||
ev?.sha ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
process.stdout.write(
|
|
||||||
`REPO_URL=${JSON.stringify(repo)}\n` +
|
|
||||||
`DEFAULT_BRANCH=${JSON.stringify(defaultBranch)}\n` +
|
|
||||||
`REF=${JSON.stringify(ref)}\n` +
|
|
||||||
`SHA_CAND=${JSON.stringify(sha)}\n`
|
|
||||||
);
|
|
||||||
' EVENT_JSON="$EVENT_JSON")" || { echo "❌ Cannot parse event.json"; exit 1; }"
|
|
||||||
|
|
||||||
eval "$OUT"
|
|
||||||
|
|
||||||
echo "Repo URL: $REPO_URL"
|
|
||||||
echo "Default branch: $DEFAULT_BRANCH"
|
|
||||||
echo "Ref: $REF"
|
|
||||||
echo "SHA candidate: ${SHA_CAND:-<empty>}"
|
|
||||||
|
|
||||||
rm -rf .git
|
rm -rf .git
|
||||||
git init -q
|
git init -q
|
||||||
git remote add origin "$REPO_URL"
|
git remote add origin "$REPO_URL"
|
||||||
|
|
||||||
if [[ -n "${SHA_CAND:-}" ]]; then
|
if [[ -n "${SHA:-}" ]]; then
|
||||||
echo "Checkout by SHA: $SHA_CAND"
|
git fetch --depth 1 origin "$SHA"
|
||||||
git fetch --depth 1 origin "$SHA_CAND"
|
|
||||||
git -c advice.detachedHead=false checkout -q FETCH_HEAD
|
git -c advice.detachedHead=false checkout -q FETCH_HEAD
|
||||||
else
|
else
|
||||||
# workflow_dispatch often has no SHA; fetch by ref/branch
|
git fetch --depth 1 origin "$DEFAULT_BRANCH"
|
||||||
REF_TO_FETCH="$REF"
|
git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH"
|
||||||
if [[ "$REF_TO_FETCH" == refs/heads/* ]]; then
|
SHA="$(git rev-parse HEAD)"
|
||||||
REF_TO_FETCH="${REF_TO_FETCH#refs/heads/}"
|
echo "SHA='$SHA'" >> /tmp/deploy.env
|
||||||
fi
|
echo "Resolved SHA: $SHA"
|
||||||
echo "Checkout by ref: $REF_TO_FETCH"
|
|
||||||
git fetch --depth 1 origin "$REF_TO_FETCH"
|
|
||||||
git -c advice.detachedHead=false checkout -q FETCH_HEAD
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SHA="$(git rev-parse HEAD)"
|
|
||||||
git log -1 --oneline
|
git log -1 --oneline
|
||||||
echo "SHA=$SHA" >> /tmp/deploy.env
|
|
||||||
echo "REPO_URL=$REPO_URL" >> /tmp/deploy.env
|
|
||||||
echo "DEFAULT_BRANCH=$DEFAULT_BRANCH" >> /tmp/deploy.env
|
|
||||||
|
|
||||||
- name: Gate — auto deploy only on annotations/media changes
|
- name: Gate — decide HOTPATCH vs FULL rebuild
|
||||||
env:
|
env:
|
||||||
INPUT_FORCE: ${{ inputs.force }}
|
INPUT_FORCE: ${{ inputs.force }}
|
||||||
run: |
|
run: |
|
||||||
@@ -115,45 +101,40 @@ jobs:
|
|||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
|
|
||||||
FORCE="${INPUT_FORCE:-0}"
|
FORCE="${INPUT_FORCE:-0}"
|
||||||
|
|
||||||
|
# liste fichiers touchés (utile pour copier les médias)
|
||||||
|
CHANGED="$(git show --name-only --pretty="" "$SHA" | sed '/^$/d' || true)"
|
||||||
|
printf "%s\n" "$CHANGED" > /tmp/changed.txt
|
||||||
|
|
||||||
|
echo "== changed files =="
|
||||||
|
echo "$CHANGED" | sed -n '1,260p'
|
||||||
|
|
||||||
if [[ "$FORCE" == "1" ]]; then
|
if [[ "$FORCE" == "1" ]]; then
|
||||||
echo "✅ force=1 -> bypass gate -> deploy allowed"
|
echo "GO=1" >> /tmp/deploy.env
|
||||||
echo "GO=1" >> /tmp/deploy.env
|
echo "MODE='full'" >> /tmp/deploy.env
|
||||||
|
echo "✅ force=1 -> MODE=full (rebuild+restart)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Robust changed-files list (merge commits included)
|
# Auto mode: uniquement annotations/media => hotpatch only
|
||||||
# Prefer diff vs first parent; fallback to git show.
|
|
||||||
if git rev-parse "${SHA}^" >/dev/null 2>&1; then
|
|
||||||
CHANGED="$(git diff --name-only "${SHA}^" "$SHA" || true)"
|
|
||||||
else
|
|
||||||
CHANGED=""
|
|
||||||
fi
|
|
||||||
if [[ -z "$CHANGED" ]]; then
|
|
||||||
CHANGED="$(git show --name-only --pretty="" -m "$SHA" | sed '/^$/d' || true)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== changed files =="
|
|
||||||
echo "$CHANGED" | sed -n '1,200p'
|
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
|
if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
|
||||||
echo "GO=1" >> /tmp/deploy.env
|
echo "GO=1" >> /tmp/deploy.env
|
||||||
echo "✅ deploy allowed (annotations/media change detected)"
|
echo "MODE='hotpatch'" >> /tmp/deploy.env
|
||||||
|
echo "✅ annotations/media change -> MODE=hotpatch"
|
||||||
else
|
else
|
||||||
echo "GO=0" >> /tmp/deploy.env
|
echo "GO=0" >> /tmp/deploy.env
|
||||||
|
echo "MODE='skip'" >> /tmp/deploy.env
|
||||||
echo "ℹ️ no annotations/media change -> skip deploy"
|
echo "ℹ️ no annotations/media change -> skip deploy"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Install docker client + docker compose plugin (v2)
|
- name: Install docker client + docker compose plugin (v2) + python yaml
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
|
||||||
# Must have docker socket mounted by runner
|
|
||||||
test -S /var/run/docker.sock || { echo "❌ /var/run/docker.sock missing in job container"; exit 10; }
|
|
||||||
|
|
||||||
apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update
|
apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update
|
||||||
apt-get install -y --no-install-recommends ca-certificates curl docker.io
|
apt-get install -y --no-install-recommends ca-certificates curl docker.io python3 python3-yaml
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
mkdir -p /usr/local/lib/docker/cli-plugins
|
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||||
@@ -164,8 +145,23 @@ jobs:
|
|||||||
|
|
||||||
docker version
|
docker version
|
||||||
docker compose version
|
docker compose version
|
||||||
|
python3 --version
|
||||||
|
|
||||||
- name: Assert required vars (PUBLIC_GITEA_*)
|
# Reuse existing compose project name if containers already exist
|
||||||
|
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
||||||
|
if [[ -z "${PROJ:-}" ]]; then
|
||||||
|
PROJ="$(docker inspect archicratie-web-green --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${PROJ:-}" ]]; then PROJ="archicratie-web"; fi
|
||||||
|
echo "COMPOSE_PROJECT_NAME='$PROJ'" >> /tmp/deploy.env
|
||||||
|
echo "✅ Using COMPOSE_PROJECT_NAME=$PROJ"
|
||||||
|
|
||||||
|
# Assert target containers exist (hotpatch needs them)
|
||||||
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
|
docker inspect "$c" >/dev/null 2>&1 || { echo "❌ missing container $c"; exit 5; }
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Assert required vars (PUBLIC_GITEA_*) — only needed for MODE=full
|
||||||
env:
|
env:
|
||||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||||
@@ -174,24 +170,26 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ hotpatch mode -> vars not required"; exit 0; }
|
||||||
|
|
||||||
test -n "${PUBLIC_GITEA_BASE:-}" || { echo "❌ missing repo var PUBLIC_GITEA_BASE"; exit 2; }
|
test -n "${PUBLIC_GITEA_BASE:-}" || { echo "❌ missing repo var PUBLIC_GITEA_BASE"; exit 2; }
|
||||||
test -n "${PUBLIC_GITEA_OWNER:-}" || { echo "❌ missing repo var PUBLIC_GITEA_OWNER"; exit 2; }
|
test -n "${PUBLIC_GITEA_OWNER:-}" || { echo "❌ missing repo var PUBLIC_GITEA_OWNER"; exit 2; }
|
||||||
test -n "${PUBLIC_GITEA_REPO:-}" || { echo "❌ missing repo var PUBLIC_GITEA_REPO"; exit 2; }
|
test -n "${PUBLIC_GITEA_REPO:-}" || { echo "❌ missing repo var PUBLIC_GITEA_REPO"; exit 2; }
|
||||||
echo "✅ vars OK"
|
echo "✅ vars OK"
|
||||||
|
|
||||||
- name: Assert deploy files exist
|
- name: Assert deploy files exist — only needed for MODE=full
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ hotpatch mode -> files not required"; exit 0; }
|
||||||
|
|
||||||
test -f docker-compose.yml
|
test -f docker-compose.yml
|
||||||
test -f Dockerfile
|
test -f Dockerfile
|
||||||
test -f nginx.conf
|
test -f nginx.conf
|
||||||
echo "✅ deploy files OK"
|
echo "✅ deploy files OK"
|
||||||
|
|
||||||
- name: Build + deploy staging (blue) then smoke
|
- name: FULL — Build + deploy staging (blue) then warmup+smoke
|
||||||
env:
|
env:
|
||||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||||
@@ -200,27 +198,51 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ MODE=$MODE -> skip full rebuild"; exit 0; }
|
||||||
|
|
||||||
|
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
||||||
|
|
||||||
|
wait_url() {
|
||||||
|
local url="$1"
|
||||||
|
local label="$2"
|
||||||
|
local tries="${3:-60}"
|
||||||
|
for i in $(seq 1 "$tries"); do
|
||||||
|
if curl -fsS --max-time 4 "$url" >/dev/null; then
|
||||||
|
echo "✅ $label OK ($url)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "… warmup $label ($i/$tries)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "❌ timeout $label ($url)"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
TS="$(date -u +%Y%m%d-%H%M%S)"
|
TS="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
echo "TS=$TS" >> /tmp/deploy.env
|
echo "TS='$TS'" >> /tmp/deploy.env
|
||||||
|
|
||||||
docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true
|
docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true
|
||||||
docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true
|
docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true
|
||||||
|
|
||||||
docker compose build --no-cache web_blue
|
docker compose -p "$PROJ" -f docker-compose.yml build web_blue
|
||||||
docker compose up -d --force-recreate web_blue
|
docker rm -f archicratie-web-blue || true
|
||||||
|
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_blue
|
||||||
|
|
||||||
curl -fsS "http://127.0.0.1:8081/para-index.json" >/dev/null
|
# warmup endpoints
|
||||||
curl -fsS "http://127.0.0.1:8081/annotations-index.json" >/dev/null
|
wait_url "http://127.0.0.1:8081/para-index.json" "blue para-index"
|
||||||
curl -fsS "http://127.0.0.1:8081/pagefind/pagefind.js" >/dev/null
|
wait_url "http://127.0.0.1:8081/annotations-index.json" "blue annotations-index"
|
||||||
|
wait_url "http://127.0.0.1:8081/pagefind/pagefind.js" "blue pagefind.js"
|
||||||
|
|
||||||
CANON="$(curl -fsS "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
||||||
echo "canonical(blue)=$CANON"
|
echo "canonical(blue)=$CANON"
|
||||||
echo "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || { echo "❌ staging canonical mismatch"; exit 3; }
|
echo "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || {
|
||||||
|
echo "❌ staging canonical mismatch"
|
||||||
|
docker logs --tail 120 archicratie-web-blue || true
|
||||||
|
exit 3
|
||||||
|
}
|
||||||
|
|
||||||
echo "✅ staging OK"
|
echo "✅ staging OK"
|
||||||
|
|
||||||
- name: Build + deploy live (green) then smoke + rollback if needed
|
- name: FULL — Build + deploy live (green) then warmup+smoke + rollback if needed
|
||||||
env:
|
env:
|
||||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||||
@@ -229,31 +251,253 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ MODE=$MODE -> skip full rebuild"; exit 0; }
|
||||||
|
|
||||||
|
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
||||||
TS="${TS:-$(date -u +%Y%m%d-%H%M%S)}"
|
TS="${TS:-$(date -u +%Y%m%d-%H%M%S)}"
|
||||||
|
|
||||||
|
wait_url() {
|
||||||
|
local url="$1"
|
||||||
|
local label="$2"
|
||||||
|
local tries="${3:-60}"
|
||||||
|
for i in $(seq 1 "$tries"); do
|
||||||
|
if curl -fsS --max-time 4 "$url" >/dev/null; then
|
||||||
|
echo "✅ $label OK ($url)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "… warmup $label ($i/$tries)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "❌ timeout $label ($url)"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
rollback() {
|
rollback() {
|
||||||
echo "⚠️ rollback green -> previous image tag (best effort)"
|
echo "⚠️ rollback green -> previous image tag (best effort)"
|
||||||
docker image tag "archicratie-web:green.BAK.${TS}" archicratie-web:green || true
|
docker image tag "archicratie-web:green.BAK.${TS}" archicratie-web:green || true
|
||||||
docker compose up -d --force-recreate web_green || true
|
docker rm -f archicratie-web-green || true
|
||||||
|
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green || true
|
||||||
}
|
}
|
||||||
|
|
||||||
set +e
|
# build/restart green
|
||||||
docker compose build --no-cache web_green
|
if ! docker compose -p "$PROJ" -f docker-compose.yml build web_green; then
|
||||||
BRC=$?
|
echo "❌ build green failed"; rollback; exit 4
|
||||||
[[ "$BRC" -eq 0 ]] || { echo "❌ build green failed"; rollback; exit 4; }
|
fi
|
||||||
|
|
||||||
docker compose up -d --force-recreate web_green
|
docker rm -f archicratie-web-green || true
|
||||||
URC=$?
|
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green
|
||||||
[[ "$URC" -eq 0 ]] || { echo "❌ up green failed"; rollback; exit 4; }
|
|
||||||
|
|
||||||
curl -fsS "http://127.0.0.1:8082/para-index.json" >/dev/null || { rollback; exit 4; }
|
# warmup endpoints
|
||||||
curl -fsS "http://127.0.0.1:8082/annotations-index.json" >/dev/null || { rollback; exit 4; }
|
if ! wait_url "http://127.0.0.1:8082/para-index.json" "green para-index"; then rollback; exit 4; fi
|
||||||
curl -fsS "http://127.0.0.1:8082/pagefind/pagefind.js" >/dev/null || { rollback; exit 4; }
|
if ! wait_url "http://127.0.0.1:8082/annotations-index.json" "green annotations-index"; then rollback; exit 4; fi
|
||||||
|
if ! wait_url "http://127.0.0.1:8082/pagefind/pagefind.js" "green pagefind.js"; then rollback; exit 4; fi
|
||||||
|
|
||||||
CANON="$(curl -fsS "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
||||||
echo "canonical(green)=$CANON"
|
echo "canonical(green)=$CANON"
|
||||||
echo "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || { echo "❌ live canonical mismatch"; rollback; exit 4; }
|
echo "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || {
|
||||||
|
echo "❌ live canonical mismatch"
|
||||||
|
docker logs --tail 120 archicratie-web-green || true
|
||||||
|
rollback
|
||||||
|
exit 4
|
||||||
|
}
|
||||||
|
|
||||||
echo "✅ live OK"
|
echo "✅ live OK"
|
||||||
set -e
|
|
||||||
|
- name: HOTPATCH — deep merge shards -> annotations-index + copy changed media into blue+green
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source /tmp/deploy.env
|
||||||
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, re, json, glob
|
||||||
|
import yaml
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
ROOT = os.getcwd()
|
||||||
|
ANNO_ROOT = os.path.join(ROOT, "src", "annotations")
|
||||||
|
|
||||||
|
def is_obj(x): return isinstance(x, dict)
|
||||||
|
def is_arr(x): return isinstance(x, list)
|
||||||
|
|
||||||
|
def iso_dt(x):
|
||||||
|
if isinstance(x, dt.datetime):
|
||||||
|
if x.tzinfo is None:
|
||||||
|
return x.isoformat()
|
||||||
|
return x.astimezone(dt.timezone.utc).isoformat().replace("+00:00","Z")
|
||||||
|
if isinstance(x, dt.date):
|
||||||
|
return x.isoformat()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def normalize(x):
|
||||||
|
s = iso_dt(x)
|
||||||
|
if s is not None: return s
|
||||||
|
if isinstance(x, dict):
|
||||||
|
return {str(k): normalize(v) for k, v in x.items()}
|
||||||
|
if isinstance(x, list):
|
||||||
|
return [normalize(v) for v in x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
def key_media(it): return str((it or {}).get("src",""))
|
||||||
|
def key_ref(it):
|
||||||
|
it = it or {}
|
||||||
|
return "||".join([str(it.get("url","")), str(it.get("label","")), str(it.get("kind","")), str(it.get("citation",""))])
|
||||||
|
def key_comment(it): return str((it or {}).get("text","")).strip()
|
||||||
|
|
||||||
|
def dedup_extend(dst_list, src_list, key_fn):
|
||||||
|
seen = set(); out = []
|
||||||
|
for x in (dst_list or []):
|
||||||
|
x = normalize(x); k = key_fn(x)
|
||||||
|
if k and k not in seen: seen.add(k); out.append(x)
|
||||||
|
for x in (src_list or []):
|
||||||
|
x = normalize(x); k = key_fn(x)
|
||||||
|
if k and k not in seen: seen.add(k); out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def deep_merge(dst, src):
|
||||||
|
src = normalize(src)
|
||||||
|
for k, v in (src or {}).items():
|
||||||
|
if k in ("media","refs","comments_editorial") and is_arr(v):
|
||||||
|
if k == "media": dst[k] = dedup_extend(dst.get(k, []), v, key_media)
|
||||||
|
elif k == "refs": dst[k] = dedup_extend(dst.get(k, []), v, key_ref)
|
||||||
|
else: dst[k] = dedup_extend(dst.get(k, []), v, key_comment)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_obj(v):
|
||||||
|
if not is_obj(dst.get(k)): dst[k] = {}
|
||||||
|
deep_merge(dst[k], v)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_arr(v):
|
||||||
|
cur = dst.get(k, [])
|
||||||
|
if not is_arr(cur): cur = []
|
||||||
|
seen = set(); out = []
|
||||||
|
for x in cur:
|
||||||
|
x = normalize(x)
|
||||||
|
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
||||||
|
if s not in seen: seen.add(s); out.append(x)
|
||||||
|
for x in v:
|
||||||
|
x = normalize(x)
|
||||||
|
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
||||||
|
if s not in seen: seen.add(s); out.append(x)
|
||||||
|
dst[k] = out
|
||||||
|
continue
|
||||||
|
|
||||||
|
v = normalize(v)
|
||||||
|
if k not in dst or dst.get(k) in (None, ""):
|
||||||
|
dst[k] = v
|
||||||
|
|
||||||
|
def para_num(pid):
|
||||||
|
m = re.match(r"^p-(\d+)-", str(pid))
|
||||||
|
return int(m.group(1)) if m else 10**9
|
||||||
|
|
||||||
|
def sort_lists(entry):
|
||||||
|
for k in ("media","refs","comments_editorial"):
|
||||||
|
arr = entry.get(k)
|
||||||
|
if not is_arr(arr): continue
|
||||||
|
def ts(x):
|
||||||
|
x = normalize(x)
|
||||||
|
try:
|
||||||
|
s = str((x or {}).get("ts",""))
|
||||||
|
return dt.datetime.fromisoformat(s.replace("Z","+00:00")).timestamp() if s else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
arr = [normalize(x) for x in arr]
|
||||||
|
arr.sort(key=lambda x: (ts(x), json.dumps(x, sort_keys=True, ensure_ascii=False)))
|
||||||
|
entry[k] = arr
|
||||||
|
|
||||||
|
if not os.path.isdir(ANNO_ROOT):
|
||||||
|
raise SystemExit(f"Missing annotations root: {ANNO_ROOT}")
|
||||||
|
|
||||||
|
pages = {}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
files = sorted(glob.glob(os.path.join(ANNO_ROOT, "**", "*.yml"), recursive=True))
|
||||||
|
for fp in files:
|
||||||
|
try:
|
||||||
|
with open(fp, "r", encoding="utf-8") as f:
|
||||||
|
doc = yaml.safe_load(f) or {}
|
||||||
|
doc = normalize(doc)
|
||||||
|
if not isinstance(doc, dict) or doc.get("schema") != 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
page = str(doc.get("page","")).strip().strip("/")
|
||||||
|
paras = doc.get("paras") or {}
|
||||||
|
if not page or not isinstance(paras, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pg = pages.setdefault(page, {"paras": {}})
|
||||||
|
for pid, entry in paras.items():
|
||||||
|
pid = str(pid)
|
||||||
|
if pid not in pg["paras"] or not isinstance(pg["paras"].get(pid), dict):
|
||||||
|
pg["paras"][pid] = {}
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
deep_merge(pg["paras"][pid], entry)
|
||||||
|
sort_lists(pg["paras"][pid])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({"file": os.path.relpath(fp, ROOT), "error": str(e)})
|
||||||
|
|
||||||
|
for page, obj in pages.items():
|
||||||
|
keys = list((obj.get("paras") or {}).keys())
|
||||||
|
keys.sort(key=lambda k: (para_num(k), k))
|
||||||
|
obj["paras"] = {k: obj["paras"][k] for k in keys}
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"schema": 1,
|
||||||
|
"generatedAt": dt.datetime.utcnow().replace(tzinfo=dt.timezone.utc).isoformat().replace("+00:00","Z"),
|
||||||
|
"pages": pages,
|
||||||
|
"stats": {
|
||||||
|
"pages": len(pages),
|
||||||
|
"paras": sum(len(v.get("paras") or {}) for v in pages.values()),
|
||||||
|
"errors": len(errors),
|
||||||
|
},
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open("/tmp/annotations-index.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(out, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
print("OK: wrote /tmp/annotations-index.json pages=", out["stats"]["pages"], "paras=", out["stats"]["paras"], "errors=", out["stats"]["errors"])
|
||||||
|
PY
|
||||||
|
|
||||||
|
# patch JSON into running containers
|
||||||
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
|
echo "== patch annotations-index.json into $c =="
|
||||||
|
docker cp /tmp/annotations-index.json "${c}:/usr/share/nginx/html/annotations-index.json"
|
||||||
|
done
|
||||||
|
|
||||||
|
# copy changed media files into containers (so new media appears without rebuild)
|
||||||
|
if [[ -s /tmp/changed.txt ]]; then
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[[ -n "$f" ]] || continue
|
||||||
|
if [[ "$f" == public/media/* ]]; then
|
||||||
|
dest="/usr/share/nginx/html/${f#public/}" # => /usr/share/nginx/html/media/...
|
||||||
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
|
echo "== copy media into $c: $f -> $dest =="
|
||||||
|
docker exec "$c" sh -lc "mkdir -p \"$(dirname "$dest")\""
|
||||||
|
docker cp "$f" "$c:$dest"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done < /tmp/changed.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# smoke after patch
|
||||||
|
for p in 8081 8082; do
|
||||||
|
echo "== smoke annotations-index on $p =="
|
||||||
|
curl -fsS --max-time 6 "http://127.0.0.1:${p}/annotations-index.json" \
|
||||||
|
| python3 -c 'import sys,json; j=json.load(sys.stdin); print("generatedAt:", j.get("generatedAt")); print("pages:", len(j.get("pages") or {})); print("paras:", j.get("stats",{}).get("paras"))'
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ hotpatch done"
|
||||||
|
|
||||||
|
- name: Debug on failure (containers status/logs)
|
||||||
|
if: ${{ failure() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "== docker ps =="
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | sed -n '1,80p' || true
|
||||||
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
|
echo "== logs $c (tail 200) =="
|
||||||
|
docker logs --tail 200 "$c" || true
|
||||||
|
done
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// scripts/apply-annotation-ticket.mjs
|
// scripts/apply-annotation-ticket.mjs
|
||||||
// Applique un ticket Gitea "type/media | type/reference | type/comment" vers src/annotations + public/media
|
// Applique un ticket Gitea "type/media | type/reference | type/comment" vers:
|
||||||
// Robuste, idempotent, non destructif
|
|
||||||
//
|
//
|
||||||
// DRY RUN par défaut si --dry-run
|
// ✅ src/annotations/<oeuvre>/<chapitre>/<paraId>.yml (sharding par paragraphe)
|
||||||
|
// ✅ public/media/<oeuvre>/<chapitre>/<paraId>/<file>
|
||||||
|
//
|
||||||
|
// Robuste, idempotent, non destructif.
|
||||||
|
//
|
||||||
|
// DRY RUN si --dry-run
|
||||||
// Options: --dry-run --no-download --verify --strict --commit --close
|
// Options: --dry-run --no-download --verify --strict --commit --close
|
||||||
//
|
//
|
||||||
// Env requis:
|
// Env requis:
|
||||||
@@ -36,7 +40,7 @@ import YAML from "yaml";
|
|||||||
|
|
||||||
function usage(exitCode = 0) {
|
function usage(exitCode = 0) {
|
||||||
console.log(`
|
console.log(`
|
||||||
apply-annotation-ticket — applique un ticket SidePanel (media/ref/comment) vers src/annotations/
|
apply-annotation-ticket — applique un ticket SidePanel (media/ref/comment) vers src/annotations/ (shard par paragraphe)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
node scripts/apply-annotation-ticket.mjs <issue_number> [--dry-run] [--no-download] [--verify] [--strict] [--commit] [--close]
|
node scripts/apply-annotation-ticket.mjs <issue_number> [--dry-run] [--no-download] [--verify] [--strict] [--commit] [--close]
|
||||||
@@ -44,7 +48,7 @@ Usage:
|
|||||||
Flags:
|
Flags:
|
||||||
--dry-run : n'écrit rien (affiche un aperçu)
|
--dry-run : n'écrit rien (affiche un aperçu)
|
||||||
--no-download : n'essaie pas de télécharger les pièces jointes (media)
|
--no-download : n'essaie pas de télécharger les pièces jointes (media)
|
||||||
--verify : tente de vérifier que (page, ancre) existent (baseline/dist si dispo)
|
--verify : vérifie que (page, ancre) existent (dist/para-index.json si dispo, sinon baseline)
|
||||||
--strict : refuse si URL ref invalide (http/https) OU caption media vide
|
--strict : refuse si URL ref invalide (http/https) OU caption media vide
|
||||||
--commit : git add + git commit (le script commit dans la branche courante)
|
--commit : git add + git commit (le script commit dans la branche courante)
|
||||||
--close : ferme le ticket (nécessite --commit)
|
--close : ferme le ticket (nécessite --commit)
|
||||||
@@ -57,7 +61,7 @@ Env optionnel:
|
|||||||
GITEA_OWNER / GITEA_REPO (sinon auto-détecté via git remote)
|
GITEA_OWNER / GITEA_REPO (sinon auto-détecté via git remote)
|
||||||
ANNO_DIR (défaut: src/annotations)
|
ANNO_DIR (défaut: src/annotations)
|
||||||
PUBLIC_DIR (défaut: public)
|
PUBLIC_DIR (défaut: public)
|
||||||
MEDIA_ROOT (défaut URL: /media) -> écrit dans public/media/...
|
MEDIA_ROOT (défaut URL: /media)
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 ok
|
0 ok
|
||||||
@@ -102,6 +106,8 @@ const ANNO_DIR = path.join(CWD, process.env.ANNO_DIR || "src", "annotations");
|
|||||||
const PUBLIC_DIR = path.join(CWD, process.env.PUBLIC_DIR || "public");
|
const PUBLIC_DIR = path.join(CWD, process.env.PUBLIC_DIR || "public");
|
||||||
const MEDIA_URL_ROOT = String(process.env.MEDIA_ROOT || "/media").replace(/\/+$/, "");
|
const MEDIA_URL_ROOT = String(process.env.MEDIA_ROOT || "/media").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
/* --------------------------------- helpers -------------------------------- */
|
||||||
|
|
||||||
function getEnv(name, fallback = "") {
|
function getEnv(name, fallback = "") {
|
||||||
return (process.env[name] ?? fallback).trim();
|
return (process.env[name] ?? fallback).trim();
|
||||||
}
|
}
|
||||||
@@ -123,7 +129,12 @@ function runQuiet(cmd, args, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exists(p) {
|
async function exists(p) {
|
||||||
try { await fs.access(p); return true; } catch { return false; }
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferOwnerRepoFromGit() {
|
function inferOwnerRepoFromGit() {
|
||||||
@@ -140,6 +151,317 @@ function gitHasStagedChanges() {
|
|||||||
return r.status === 1;
|
return r.status === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s) {
|
||||||
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLine(body, key) {
|
||||||
|
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||||
|
const m = String(body || "").match(re);
|
||||||
|
return m ? m[1].trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSection(body, markers) {
|
||||||
|
const text = String(body || "").replace(/\r\n/g, "\n");
|
||||||
|
const idx = markers
|
||||||
|
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||||
|
.filter((x) => x.i >= 0)
|
||||||
|
.sort((a, b) => a.i - b.i)[0];
|
||||||
|
if (!idx) return "";
|
||||||
|
|
||||||
|
const start = idx.i + idx.m.length;
|
||||||
|
const tail = text.slice(start);
|
||||||
|
|
||||||
|
const stops = ["\n## ", "\n---", "\nJustification", "\nProposition", "\nSources"];
|
||||||
|
let end = tail.length;
|
||||||
|
for (const s of stops) {
|
||||||
|
const j = tail.toLowerCase().indexOf(s.toLowerCase());
|
||||||
|
if (j >= 0 && j < end) end = j;
|
||||||
|
}
|
||||||
|
return tail.slice(0, end).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChemin(chemin) {
|
||||||
|
let c = String(chemin || "").trim();
|
||||||
|
if (!c) return "";
|
||||||
|
if (!c.startsWith("/")) c = "/" + c;
|
||||||
|
if (!c.endsWith("/")) c = c + "/";
|
||||||
|
c = c.replace(/\/{2,}/g, "/");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePageKeyFromChemin(chemin) {
|
||||||
|
return normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnchorId(s) {
|
||||||
|
let a = String(s || "").trim();
|
||||||
|
if (a.startsWith("#")) a = a.slice(1);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(cond, msg, code = 1) {
|
||||||
|
if (!cond) {
|
||||||
|
const e = new Error(msg);
|
||||||
|
e.__exitCode = code;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(x) {
|
||||||
|
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paraIndexFromId(id) {
|
||||||
|
const m = String(id).match(/^p-(\d+)-/i);
|
||||||
|
return m ? Number(m[1]) : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(u) {
|
||||||
|
try {
|
||||||
|
const x = new URL(String(u));
|
||||||
|
return x.protocol === "http:" || x.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ para-index (verify + sort) ------------------------------ */
|
||||||
|
|
||||||
|
async function loadParaOrderFromDist(pageKey) {
|
||||||
|
const distIdx = path.join(CWD, "dist", "para-index.json");
|
||||||
|
if (!(await exists(distIdx))) return null;
|
||||||
|
|
||||||
|
let j;
|
||||||
|
try {
|
||||||
|
j = JSON.parse(await fs.readFile(distIdx, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support several shapes:
|
||||||
|
// A) { items:[{id,page,...}, ...] }
|
||||||
|
if (Array.isArray(j?.items)) {
|
||||||
|
const ids = [];
|
||||||
|
for (const it of j.items) {
|
||||||
|
const p = String(it?.page || it?.pageKey || "");
|
||||||
|
const id = String(it?.id || it?.paraId || "");
|
||||||
|
if (p === pageKey && id) ids.push(id);
|
||||||
|
}
|
||||||
|
if (ids.length) return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// B) { byId: { "p-...": { page:"archicrat-ia/chapitre-4", ... }, ... } }
|
||||||
|
if (j?.byId && typeof j.byId === "object") {
|
||||||
|
// cannot rebuild full order; but can verify existence
|
||||||
|
// return a pseudo-order map from known ids sorted by p-<n>- then alpha
|
||||||
|
const ids = Object.keys(j.byId).filter((id) => String(j.byId[id]?.page || "") === pageKey);
|
||||||
|
if (ids.length) {
|
||||||
|
ids.sort((a, b) => {
|
||||||
|
const ia = paraIndexFromId(a);
|
||||||
|
const ib = paraIndexFromId(b);
|
||||||
|
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C) { pages: { "archicrat-ia/chapitre-4": { ids:[...]} } }
|
||||||
|
if (j?.pages && typeof j.pages === "object") {
|
||||||
|
const pg = j.pages[pageKey];
|
||||||
|
if (Array.isArray(pg?.ids)) return pg.ids.map(String);
|
||||||
|
if (Array.isArray(pg?.paras)) return pg.paras.map(String);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryVerifyAnchor(pageKey, anchorId) {
|
||||||
|
// 1) dist/para-index.json
|
||||||
|
const order = await loadParaOrderFromDist(pageKey);
|
||||||
|
if (order) return order.includes(anchorId);
|
||||||
|
|
||||||
|
// 2) tests/anchors-baseline.json (fallback)
|
||||||
|
const base = path.join(CWD, "tests", "anchors-baseline.json");
|
||||||
|
if (await exists(base)) {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(await fs.readFile(base, "utf8"));
|
||||||
|
const candidates = [];
|
||||||
|
if (j?.pages && typeof j.pages === "object") {
|
||||||
|
for (const [k, v] of Object.entries(j.pages)) {
|
||||||
|
if (!Array.isArray(v)) continue;
|
||||||
|
if (String(k).includes(pageKey)) candidates.push(...v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(j?.entries)) {
|
||||||
|
for (const it of j.entries) {
|
||||||
|
const p = String(it?.page || "");
|
||||||
|
const ids = it?.ids;
|
||||||
|
if (Array.isArray(ids) && p.includes(pageKey)) candidates.push(...ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length) return candidates.some((x) => String(x) === anchorId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // cannot verify
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- deep merge helpers (non destructive) ----------------------------- */
|
||||||
|
|
||||||
|
function keyMedia(x) {
|
||||||
|
return String(x?.src || "");
|
||||||
|
}
|
||||||
|
function keyRef(x) {
|
||||||
|
return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`;
|
||||||
|
}
|
||||||
|
function keyComment(x) {
|
||||||
|
return String(x?.text || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqUnion(dstArr, srcArr, keyFn) {
|
||||||
|
const out = Array.isArray(dstArr) ? [...dstArr] : [];
|
||||||
|
const seen = new Set(out.map((x) => keyFn(x)));
|
||||||
|
for (const it of (Array.isArray(srcArr) ? srcArr : [])) {
|
||||||
|
const k = keyFn(it);
|
||||||
|
if (!k) continue;
|
||||||
|
if (!seen.has(k)) {
|
||||||
|
seen.add(k);
|
||||||
|
out.push(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMergeEntry(dst, src) {
|
||||||
|
if (!isPlainObject(dst) || !isPlainObject(src)) return;
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(src)) {
|
||||||
|
if (k === "media" && Array.isArray(v)) {
|
||||||
|
dst.media = uniqUnion(dst.media, v, keyMedia);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (k === "refs" && Array.isArray(v)) {
|
||||||
|
dst.refs = uniqUnion(dst.refs, v, keyRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (k === "comments_editorial" && Array.isArray(v)) {
|
||||||
|
dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(v)) {
|
||||||
|
if (!isPlainObject(dst[k])) dst[k] = {};
|
||||||
|
deepMergeEntry(dst[k], v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
// fallback: union by JSON string
|
||||||
|
const cur = Array.isArray(dst[k]) ? dst[k] : [];
|
||||||
|
const seen = new Set(cur.map((x) => JSON.stringify(x)));
|
||||||
|
const out = [...cur];
|
||||||
|
for (const it of v) {
|
||||||
|
const s = JSON.stringify(it);
|
||||||
|
if (!seen.has(s)) {
|
||||||
|
seen.add(s);
|
||||||
|
out.push(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst[k] = out;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scalar: set only if missing/empty
|
||||||
|
if (!(k in dst) || dst[k] == null || dst[k] === "") {
|
||||||
|
dst[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableSortByTs(arr) {
|
||||||
|
if (!Array.isArray(arr)) return;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const ta = Date.parse(a?.ts || "") || 0;
|
||||||
|
const tb = Date.parse(b?.ts || "") || 0;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- annotations I/O ----------------------------- */
|
||||||
|
|
||||||
|
async function loadAnnoDocYaml(fileAbs, pageKey) {
|
||||||
|
if (!(await exists(fileAbs))) {
|
||||||
|
return { schema: 1, page: pageKey, paras: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fs.readFile(fileAbs, "utf8");
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = YAML.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`${path.relative(CWD, fileAbs)}: parse failed: ${String(e?.message ?? e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(isPlainObject(doc), `${path.relative(CWD, fileAbs)}: doc must be an object`, 2);
|
||||||
|
assert(doc.schema === 1, `${path.relative(CWD, fileAbs)}: schema must be 1`, 2);
|
||||||
|
assert(isPlainObject(doc.paras), `${path.relative(CWD, fileAbs)}: missing object key "paras"`, 2);
|
||||||
|
|
||||||
|
if (doc.page != null) {
|
||||||
|
const got = String(doc.page).replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
|
assert(got === pageKey, `${path.relative(CWD, fileAbs)}: page mismatch (page="${doc.page}" vs path="${pageKey}")`, 2);
|
||||||
|
} else {
|
||||||
|
doc.page = pageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortParasObject(paras, order) {
|
||||||
|
const keys = Object.keys(paras || {});
|
||||||
|
const idx = new Map();
|
||||||
|
if (Array.isArray(order)) {
|
||||||
|
order.forEach((id, i) => idx.set(String(id), i));
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.sort((a, b) => {
|
||||||
|
const ha = idx.has(a);
|
||||||
|
const hb = idx.has(b);
|
||||||
|
if (ha && hb) return idx.get(a) - idx.get(b);
|
||||||
|
if (ha && !hb) return -1;
|
||||||
|
if (!ha && hb) return 1;
|
||||||
|
|
||||||
|
const ia = paraIndexFromId(a);
|
||||||
|
const ib = paraIndexFromId(b);
|
||||||
|
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
for (const k of keys) out[k] = paras[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAnnoDocYaml(fileAbs, doc, order = null) {
|
||||||
|
await fs.mkdir(path.dirname(fileAbs), { recursive: true });
|
||||||
|
doc.paras = sortParasObject(doc.paras, order);
|
||||||
|
|
||||||
|
// also sort known lists inside each para for stable diffs
|
||||||
|
for (const e of Object.values(doc.paras || {})) {
|
||||||
|
if (!isPlainObject(e)) continue;
|
||||||
|
stableSortByTs(e.media);
|
||||||
|
stableSortByTs(e.refs);
|
||||||
|
stableSortByTs(e.comments_editorial);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = YAML.stringify(doc);
|
||||||
|
await fs.writeFile(fileAbs, out, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------ gitea helpers ------------------------------ */
|
/* ------------------------------ gitea helpers ------------------------------ */
|
||||||
|
|
||||||
function apiBaseNorm(forgeApiBase) {
|
function apiBaseNorm(forgeApiBase) {
|
||||||
@@ -167,7 +489,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum }) {
|
async function fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum }) {
|
||||||
// ✅ Gitea: /issues/{index}/assets
|
// Gitea: /issues/{index}/assets
|
||||||
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/assets`;
|
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/assets`;
|
||||||
try {
|
try {
|
||||||
const json = await giteaGET(url, token);
|
const json = await giteaGET(url, token);
|
||||||
@@ -215,200 +537,43 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ parsing helpers ---------------------------- */
|
/* ------------------------------ media helpers ------------------------------ */
|
||||||
|
|
||||||
function escapeRegExp(s) {
|
function inferMediaTypeFromFilename(name) {
|
||||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const n = String(name || "").toLowerCase();
|
||||||
|
if (/\.(png|jpe?g|webp|gif|svg)$/.test(n)) return "image";
|
||||||
|
if (/\.(mp4|webm|mov|m4v)$/.test(n)) return "video";
|
||||||
|
if (/\.(mp3|wav|ogg|m4a)$/.test(n)) return "audio";
|
||||||
|
return "link";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickLine(body, key) {
|
function sanitizeFilename(name) {
|
||||||
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
return String(name || "file")
|
||||||
const m = String(body || "").match(re);
|
.replace(/[\/\\]/g, "_")
|
||||||
return m ? m[1].trim() : "";
|
.replace(/[^\w.\-]+/g, "_")
|
||||||
|
.replace(/_+/g, "_")
|
||||||
|
.slice(0, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickSection(body, markers) {
|
async function downloadToFile(url, token, destAbs) {
|
||||||
const text = String(body || "").replace(/\r\n/g, "\n");
|
const res = await fetch(url, {
|
||||||
const idx = markers
|
headers: {
|
||||||
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
Authorization: `token ${token}`,
|
||||||
.filter((x) => x.i >= 0)
|
"User-Agent": "archicratie-apply-annotation/1.0",
|
||||||
.sort((a, b) => a.i - b.i)[0];
|
},
|
||||||
if (!idx) return "";
|
redirect: "follow",
|
||||||
|
|
||||||
const start = idx.i + idx.m.length;
|
|
||||||
const tail = text.slice(start);
|
|
||||||
|
|
||||||
const stops = [
|
|
||||||
"\n## ",
|
|
||||||
"\n---",
|
|
||||||
"\nJustification",
|
|
||||||
"\nProposition",
|
|
||||||
"\nSources",
|
|
||||||
];
|
|
||||||
let end = tail.length;
|
|
||||||
for (const s of stops) {
|
|
||||||
const j = tail.toLowerCase().indexOf(s.toLowerCase());
|
|
||||||
if (j >= 0 && j < end) end = j;
|
|
||||||
}
|
|
||||||
return tail.slice(0, end).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeChemin(chemin) {
|
|
||||||
let c = String(chemin || "").trim();
|
|
||||||
if (!c) return "";
|
|
||||||
if (!c.startsWith("/")) c = "/" + c;
|
|
||||||
if (!c.endsWith("/")) c = c + "/";
|
|
||||||
c = c.replace(/\/{2,}/g, "/");
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePageKeyFromChemin(chemin) {
|
|
||||||
return normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAnchorId(s) {
|
|
||||||
let a = String(s || "").trim();
|
|
||||||
if (a.startsWith("#")) a = a.slice(1);
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(cond, msg, code = 1) {
|
|
||||||
if (!cond) {
|
|
||||||
const e = new Error(msg);
|
|
||||||
e.__exitCode = code;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(x) {
|
|
||||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- verify helpers ------------------------------ */
|
|
||||||
|
|
||||||
function paraIndexFromId(id) {
|
|
||||||
const m = String(id).match(/^p-(\d+)-/i);
|
|
||||||
return m ? Number(m[1]) : Number.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryVerifyAnchor(pageKey, anchorId) {
|
|
||||||
// 1) dist/para-index.json (si build déjà faite)
|
|
||||||
const distIdx = path.join(CWD, "dist", "para-index.json");
|
|
||||||
if (await exists(distIdx)) {
|
|
||||||
const raw = await fs.readFile(distIdx, "utf8");
|
|
||||||
const idx = JSON.parse(raw);
|
|
||||||
const byId = idx?.byId;
|
|
||||||
if (byId && typeof byId === "object" && byId[anchorId] != null) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) tests/anchors-baseline.json (si dispo)
|
|
||||||
const base = path.join(CWD, "tests", "anchors-baseline.json");
|
|
||||||
if (await exists(base)) {
|
|
||||||
const raw = await fs.readFile(base, "utf8");
|
|
||||||
const j = JSON.parse(raw);
|
|
||||||
|
|
||||||
// tolérant: cherche un array d'ids associé à la page
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
// cas 1: j.pages[...]
|
|
||||||
if (j?.pages && typeof j.pages === "object") {
|
|
||||||
for (const [k, v] of Object.entries(j.pages)) {
|
|
||||||
if (!Array.isArray(v)) continue;
|
|
||||||
// on matche large: pageKey inclus dans le path
|
|
||||||
if (String(k).includes(pageKey)) candidates.push(...v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cas 2: j.entries = [{page, ids}]
|
|
||||||
if (Array.isArray(j?.entries)) {
|
|
||||||
for (const it of j.entries) {
|
|
||||||
const p = String(it?.page || "");
|
|
||||||
const ids = it?.ids;
|
|
||||||
if (Array.isArray(ids) && p.includes(pageKey)) candidates.push(...ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidates.length) {
|
|
||||||
return candidates.some((x) => String(x) === anchorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// impossible à vérifier
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- annotations I/O ----------------------------- */
|
|
||||||
|
|
||||||
async function loadAnnoDoc(fileAbs, pageKey) {
|
|
||||||
if (!(await exists(fileAbs))) {
|
|
||||||
return { schema: 1, page: pageKey, paras: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = await fs.readFile(fileAbs, "utf8");
|
|
||||||
let doc;
|
|
||||||
try {
|
|
||||||
doc = YAML.parse(raw);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`${path.relative(CWD, fileAbs)}: parse failed: ${String(e?.message ?? e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(isPlainObject(doc), `${path.relative(CWD, fileAbs)}: doc must be an object`);
|
|
||||||
assert(doc.schema === 1, `${path.relative(CWD, fileAbs)}: schema must be 1`);
|
|
||||||
assert(isPlainObject(doc.paras), `${path.relative(CWD, fileAbs)}: missing object key "paras"`);
|
|
||||||
|
|
||||||
if (doc.page != null) {
|
|
||||||
const got = String(doc.page).replace(/^\/+/, "").replace(/\/+$/, "");
|
|
||||||
assert(got === pageKey, `${path.relative(CWD, fileAbs)}: page mismatch (page="${doc.page}" vs path="${pageKey}")`);
|
|
||||||
} else {
|
|
||||||
doc.page = pageKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortParasObject(paras) {
|
|
||||||
const keys = Object.keys(paras || {});
|
|
||||||
keys.sort((a, b) => {
|
|
||||||
const ia = paraIndexFromId(a);
|
|
||||||
const ib = paraIndexFromId(b);
|
|
||||||
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
|
||||||
return String(a).localeCompare(String(b));
|
|
||||||
});
|
});
|
||||||
const out = {};
|
if (!res.ok) {
|
||||||
for (const k of keys) out[k] = paras[k];
|
const t = await res.text().catch(() => "");
|
||||||
return out;
|
throw new Error(`download failed HTTP ${res.status}: ${url}\n${t}`);
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
||||||
|
await fs.writeFile(destAbs, buf);
|
||||||
|
return buf.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAnnoDocYaml(fileAbs, doc) {
|
/* ------------------------------ type parsers ------------------------------ */
|
||||||
await fs.mkdir(path.dirname(fileAbs), { recursive: true });
|
|
||||||
doc.paras = sortParasObject(doc.paras);
|
|
||||||
const out = YAML.stringify(doc);
|
|
||||||
await fs.writeFile(fileAbs, out, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------ apply per type ----------------------------- */
|
|
||||||
|
|
||||||
function ensureEntry(doc, paraId) {
|
|
||||||
if (!doc.paras[paraId] || !isPlainObject(doc.paras[paraId])) doc.paras[paraId] = {};
|
|
||||||
return doc.paras[paraId];
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqPush(arr, item, keyFn) {
|
|
||||||
const k = keyFn(item);
|
|
||||||
const exists = arr.some((x) => keyFn(x) === k);
|
|
||||||
if (!exists) arr.push(item);
|
|
||||||
return !exists;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stableSortByTs(arr) {
|
|
||||||
if (!Array.isArray(arr)) return;
|
|
||||||
arr.sort((a, b) => {
|
|
||||||
const ta = Date.parse(a?.ts || "") || 0;
|
|
||||||
const tb = Date.parse(b?.ts || "") || 0;
|
|
||||||
if (ta !== tb) return ta - tb;
|
|
||||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReferenceBlock(body) {
|
function parseReferenceBlock(body) {
|
||||||
const block =
|
const block =
|
||||||
@@ -431,50 +596,6 @@ function parseReferenceBlock(body) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMediaTypeFromFilename(name) {
|
|
||||||
const n = String(name || "").toLowerCase();
|
|
||||||
if (/\.(png|jpe?g|webp|gif|svg)$/.test(n)) return "image";
|
|
||||||
if (/\.(mp4|webm|mov|m4v)$/.test(n)) return "video";
|
|
||||||
if (/\.(mp3|wav|ogg|m4a)$/.test(n)) return "audio";
|
|
||||||
return "link";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFilename(name) {
|
|
||||||
return String(name || "file")
|
|
||||||
.replace(/[\/\\]/g, "_")
|
|
||||||
.replace(/[^\w.\-]+/g, "_")
|
|
||||||
.replace(/_+/g, "_")
|
|
||||||
.slice(0, 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHttpUrl(u) {
|
|
||||||
try {
|
|
||||||
const x = new URL(String(u));
|
|
||||||
return x.protocol === "http:" || x.protocol === "https:";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadToFile(url, token, destAbs) {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
// la plupart des /attachments sont publics, mais on garde le token “au cas où”
|
|
||||||
Authorization: `token ${token}`,
|
|
||||||
"User-Agent": "archicratie-apply-annotation/1.0",
|
|
||||||
},
|
|
||||||
redirect: "follow",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text().catch(() => "");
|
|
||||||
throw new Error(`download failed HTTP ${res.status}: ${url}\n${t}`);
|
|
||||||
}
|
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
|
||||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
|
||||||
await fs.writeFile(destAbs, buf);
|
|
||||||
return buf.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------------- main ---------------------------------- */
|
/* ----------------------------------- main ---------------------------------- */
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -511,29 +632,57 @@ async function main() {
|
|||||||
const pageKey = normalizePageKeyFromChemin(chemin);
|
const pageKey = normalizePageKeyFromChemin(chemin);
|
||||||
assert(pageKey, "Ticket: impossible de dériver pageKey.", 2);
|
assert(pageKey, "Ticket: impossible de dériver pageKey.", 2);
|
||||||
|
|
||||||
|
// para order (used for verify + sorting)
|
||||||
|
const paraOrder = DO_VERIFY ? await loadParaOrderFromDist(pageKey) : null;
|
||||||
|
|
||||||
if (DO_VERIFY) {
|
if (DO_VERIFY) {
|
||||||
const ok = await tryVerifyAnchor(pageKey, ancre);
|
const ok = await tryVerifyAnchor(pageKey, ancre);
|
||||||
if (ok === false) {
|
if (ok === false) {
|
||||||
throw Object.assign(new Error(`Ticket verify: ancre introuvable pour page "${pageKey}" => ${ancre}`), { __exitCode: 2 });
|
throw Object.assign(new Error(`Ticket verify: ancre introuvable pour page "${pageKey}" => ${ancre}`), { __exitCode: 2 });
|
||||||
}
|
}
|
||||||
if (ok === null) {
|
if (ok === null) {
|
||||||
// pas de source de vérité dispo
|
if (STRICT) throw Object.assign(new Error(`Ticket verify (strict): impossible de vérifier (pas de dist/para-index.json ou baseline)`), { __exitCode: 2 });
|
||||||
if (STRICT) throw Object.assign(new Error(`Ticket verify (strict): impossible de vérifier (pas de baseline/dist)`), { __exitCode: 2 });
|
console.warn("⚠️ verify: impossible de vérifier (pas de dist/para-index.json ou baseline) — on continue.");
|
||||||
console.warn("⚠️ verify: impossible de vérifier (pas de baseline/dist) — on continue.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const annoFileAbs = path.join(ANNO_DIR, `${pageKey}.yml`);
|
// ✅ SHARD FILE: src/annotations/<pageKey>/<paraId>.yml
|
||||||
const annoFileRel = path.relative(CWD, annoFileAbs).replace(/\\/g, "/");
|
const annoShardFileAbs = path.join(ANNO_DIR, pageKey, `${ancre}.yml`);
|
||||||
|
const annoShardFileRel = path.relative(CWD, annoShardFileAbs).replace(/\\/g, "/");
|
||||||
|
|
||||||
console.log("✅ Parsed:", { type, chemin, ancre: `#${ancre}`, pageKey, annoFile: annoFileRel });
|
// legacy (read-only, used as base to avoid losing previously stored data)
|
||||||
|
const annoLegacyFileAbs = path.join(ANNO_DIR, `${pageKey}.yml`);
|
||||||
|
|
||||||
const doc = await loadAnnoDoc(annoFileAbs, pageKey);
|
console.log("✅ Parsed:", {
|
||||||
const entry = ensureEntry(doc, ancre);
|
type,
|
||||||
|
chemin,
|
||||||
|
ancre: `#${ancre}`,
|
||||||
|
pageKey,
|
||||||
|
annoFile: annoShardFileRel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// load shard doc
|
||||||
|
const doc = await loadAnnoDocYaml(annoShardFileAbs, pageKey);
|
||||||
|
|
||||||
|
// merge legacy para into shard as base (non destructive)
|
||||||
|
if (await exists(annoLegacyFileAbs)) {
|
||||||
|
try {
|
||||||
|
const legacy = await loadAnnoDocYaml(annoLegacyFileAbs, pageKey);
|
||||||
|
const legacyEntry = legacy?.paras?.[ancre];
|
||||||
|
if (isPlainObject(legacyEntry)) {
|
||||||
|
if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {};
|
||||||
|
deepMergeEntry(doc.paras[ancre], legacyEntry);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore legacy parse issues (shard still works)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {};
|
||||||
|
const entry = doc.paras[ancre];
|
||||||
|
|
||||||
const touchedFiles = [];
|
const touchedFiles = [];
|
||||||
const notes = [];
|
const notes = [];
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
@@ -545,16 +694,16 @@ async function main() {
|
|||||||
if (!Array.isArray(entry.comments_editorial)) entry.comments_editorial = [];
|
if (!Array.isArray(entry.comments_editorial)) entry.comments_editorial = [];
|
||||||
const item = { text, status: "new", ts: nowIso, fromIssue: issueNum };
|
const item = { text, status: "new", ts: nowIso, fromIssue: issueNum };
|
||||||
|
|
||||||
const added = uniqPush(entry.comments_editorial, item, (x) => `${(x?.text || "").trim()}`);
|
const before = entry.comments_editorial.length;
|
||||||
if (added) { changed = true; notes.push(`+ comment added (len=${text.length})`); }
|
entry.comments_editorial = uniqUnion(entry.comments_editorial, [item], keyComment);
|
||||||
else notes.push(`~ comment already present (dedup)`);
|
changed = changed || entry.comments_editorial.length !== before;
|
||||||
|
|
||||||
stableSortByTs(entry.comments_editorial);
|
stableSortByTs(entry.comments_editorial);
|
||||||
|
notes.push(changed ? `+ comment added (len=${text.length})` : `~ comment already present (dedup)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (type === "type/reference") {
|
else if (type === "type/reference") {
|
||||||
const ref = parseReferenceBlock(body);
|
const ref = parseReferenceBlock(body);
|
||||||
|
|
||||||
assert(ref.url || ref.label, "Ticket reference: renseigne au moins - URL: ou - Label: dans le ticket.", 2);
|
assert(ref.url || ref.label, "Ticket reference: renseigne au moins - URL: ou - Label: dans le ticket.", 2);
|
||||||
|
|
||||||
if (STRICT && ref.url && !isHttpUrl(ref.url)) {
|
if (STRICT && ref.url && !isHttpUrl(ref.url)) {
|
||||||
@@ -571,32 +720,30 @@ async function main() {
|
|||||||
};
|
};
|
||||||
if (ref.citation) item.citation = ref.citation;
|
if (ref.citation) item.citation = ref.citation;
|
||||||
|
|
||||||
const added = uniqPush(entry.refs, item, (x) => `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`);
|
const before = entry.refs.length;
|
||||||
if (added) { changed = true; notes.push(`+ reference added (${item.url ? "url" : "label"})`); }
|
entry.refs = uniqUnion(entry.refs, [item], keyRef);
|
||||||
else notes.push(`~ reference already present (dedup)`);
|
changed = changed || entry.refs.length !== before;
|
||||||
|
|
||||||
stableSortByTs(entry.refs);
|
stableSortByTs(entry.refs);
|
||||||
|
notes.push(changed ? `+ reference added (${item.url ? "url" : "label"})` : `~ reference already present (dedup)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (type === "type/media") {
|
else if (type === "type/media") {
|
||||||
if (!Array.isArray(entry.media)) entry.media = [];
|
if (!Array.isArray(entry.media)) entry.media = [];
|
||||||
|
|
||||||
const atts = NO_DOWNLOAD ? [] : await fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum });
|
const atts = NO_DOWNLOAD ? [] : await fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum });
|
||||||
|
if (!atts.length) notes.push("! no assets found (nothing to download).");
|
||||||
if (!atts.length) {
|
|
||||||
notes.push("! no assets found (nothing to download).");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const a of atts) {
|
for (const a of atts) {
|
||||||
const name = sanitizeFilename(a?.name || `asset-${a?.id || "x"}`);
|
const name = sanitizeFilename(a?.name || `asset-${a?.id || "x"}`);
|
||||||
const dl = a?.browser_download_url || a?.download_url || "";
|
const dl = a?.browser_download_url || a?.download_url || "";
|
||||||
if (!dl) { notes.push(`! asset missing download url: ${name}`); continue; }
|
if (!dl) { notes.push(`! asset missing download url: ${name}`); continue; }
|
||||||
|
|
||||||
// caption = title du ticket (fallback ".")
|
const caption = (title || "").trim();
|
||||||
const caption = (title || "").trim() || ".";
|
if (STRICT && !caption) {
|
||||||
if (STRICT && !caption.trim()) {
|
throw Object.assign(new Error("Ticket media (strict): caption vide (titre de ticket requis)."), { __exitCode: 2 });
|
||||||
throw Object.assign(new Error("Ticket media (strict): caption vide."), { __exitCode: 2 });
|
|
||||||
}
|
}
|
||||||
|
const captionFinal = caption || ".";
|
||||||
|
|
||||||
const mediaDirAbs = path.join(PUBLIC_DIR, "media", pageKey, ancre);
|
const mediaDirAbs = path.join(PUBLIC_DIR, "media", pageKey, ancre);
|
||||||
const destAbs = path.join(mediaDirAbs, name);
|
const destAbs = path.join(mediaDirAbs, name);
|
||||||
@@ -608,21 +755,24 @@ async function main() {
|
|||||||
const bytes = await downloadToFile(dl, token, destAbs);
|
const bytes = await downloadToFile(dl, token, destAbs);
|
||||||
notes.push(`+ downloaded ${name} (${bytes} bytes) -> ${urlPath}`);
|
notes.push(`+ downloaded ${name} (${bytes} bytes) -> ${urlPath}`);
|
||||||
touchedFiles.push(path.relative(CWD, destAbs).replace(/\\/g, "/"));
|
touchedFiles.push(path.relative(CWD, destAbs).replace(/\\/g, "/"));
|
||||||
|
changed = true;
|
||||||
} else {
|
} else {
|
||||||
notes.push(`(dry) would download ${name} -> ${urlPath}`);
|
notes.push(`(dry) would download ${name} -> ${urlPath}`);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
type: inferMediaTypeFromFilename(name),
|
type: inferMediaTypeFromFilename(name),
|
||||||
src: urlPath,
|
src: urlPath,
|
||||||
caption,
|
caption: captionFinal,
|
||||||
credit: "",
|
credit: "",
|
||||||
ts: nowIso,
|
ts: nowIso,
|
||||||
fromIssue: issueNum,
|
fromIssue: issueNum,
|
||||||
};
|
};
|
||||||
|
|
||||||
const added = uniqPush(entry.media, item, (x) => String(x?.src || ""));
|
const before = entry.media.length;
|
||||||
if (added) changed = true;
|
entry.media = uniqUnion(entry.media, [item], keyMedia);
|
||||||
|
if (entry.media.length !== before) changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
stableSortByTs(entry.media);
|
stableSortByTs(entry.media);
|
||||||
@@ -640,7 +790,7 @@ async function main() {
|
|||||||
|
|
||||||
if (DRY_RUN) {
|
if (DRY_RUN) {
|
||||||
console.log("\n--- DRY RUN (no write) ---");
|
console.log("\n--- DRY RUN (no write) ---");
|
||||||
console.log(`Would update: ${annoFileRel}`);
|
console.log(`Would update: ${annoShardFileRel}`);
|
||||||
for (const n of notes) console.log(" ", n);
|
for (const n of notes) console.log(" ", n);
|
||||||
console.log("\nExcerpt (resulting entry):");
|
console.log("\nExcerpt (resulting entry):");
|
||||||
console.log(YAML.stringify({ [ancre]: doc.paras[ancre] }).trimEnd());
|
console.log(YAML.stringify({ [ancre]: doc.paras[ancre] }).trimEnd());
|
||||||
@@ -648,10 +798,10 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveAnnoDocYaml(annoFileAbs, doc);
|
await saveAnnoDocYaml(annoShardFileAbs, doc, paraOrder);
|
||||||
touchedFiles.unshift(annoFileRel);
|
touchedFiles.unshift(annoShardFileRel);
|
||||||
|
|
||||||
console.log(`✅ Updated: ${annoFileRel}`);
|
console.log(`✅ Updated: ${annoShardFileRel}`);
|
||||||
for (const n of notes) console.log(" ", n);
|
for (const n of notes) console.log(" ", n);
|
||||||
|
|
||||||
if (DO_COMMIT) {
|
if (DO_COMMIT) {
|
||||||
|
|||||||
Reference in New Issue
Block a user