From 4a15edb9e5f33fcb052f28350dbcf31a47562521 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 00:46:51 +0100 Subject: [PATCH 01/18] ci(forgejo): publish release with PostIt APK on tag push Adds .forgejo/workflows/release.yml: triggered by tag push or workflow_dispatch, it validates the tag/CHANGELOG parity (stable / preview / unstable), builds the PostIt Android APK via the existing Dockerfile (--target build-env), and publishes a Forgejo release with the APK as an asset via rasterstate/forgejo-release-action@v1. Mirrors the validate-release logic of .github/workflows/docker-publish-android.yml so the two channels (Forgejo source-of-truth + GitHub mirror) stay consistent. Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo PAT scoped to write:repository configured in the repository's Actions secrets. --- .forgejo/workflows/release.yml | 227 +++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 .forgejo/workflows/release.yml diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 00000000..008ee3f3 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,227 @@ +# Build and publish a release on the Forgejo source-of-truth instance +# with the PostIt Android APK as an attached asset. +# +# Triggered by a push of a git tag. Validates the tag/changelog pair, +# builds the APK using the existing Dockerfile (--target build-env), then +# publishes a Forgejo release via rasterstate/forgejo-release-action and +# uploads the APK as an asset. +# +# Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo PAT scoped +# to `write:repository` configured in the repository's Actions secrets. +# The runner-provided ${{ secrets.GITHUB_TOKEN }} would also work, but +# a dedicated PAT is preferred for least-privilege and revocability. +# +# This workflow complements .github/workflows/docker-publish-android.yml +# which targets the GitHub mirror; the validate-release logic mirrors +# the GitHub-side job so the two channels stay consistent. +name: Forgejo Release + +on: + push: + tags: + - '*' + workflow_dispatch: + inputs: + tag: + description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).' + required: true + type: string + force_unstable: + description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.' + required: false + type: boolean + default: false + +permissions: + contents: write + +jobs: + # Parse le tag, applique la règle de parité du patch + # (pair=stable / impair=preview / suffixe=unstable), fail-fast sur + # instable sauf opt-in, et vérifie que CHANGELOG.md contient une + # section `## [TAG] - ` cohérente. Le body est extrait + # dans un artifact consommé par le job release. + validate-release: + runs-on: docker + steps: + - name: Checkout du code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Valider le tag et la section CHANGELOG + env: + # En push tag : github.ref_name est le tag. + # En workflow_dispatch : on lit l'input 'tag' (obligatoire). + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + FORCE_UNSTABLE: ${{ inputs.force_unstable || 'false' }} + run: | + if [[ -z "$TAG" ]]; then + echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + exit 1 + fi + + # Parse semver : MAJOR.MINOR.PATCH[-SUFFIX] + if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then + echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." + exit 1 + fi + + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + SUFFIX="${BASH_REMATCH[4]}" + + # Classification du canal par parité du patch. + # Patch pair + pas de suffixe -> stable. + # Patch impair + pas de suffixe -> preview. + # Suffixe présent -> instable. + if [[ -n "$SUFFIX" ]]; then + CHANNEL="unstable" + elif (( PATCH % 2 == 0 )); then + CHANNEL="stable" + else + CHANNEL="preview" + fi + + echo "Tag $TAG classifié comme channel=$CHANNEL" + + # Fail-fast sur instable sauf opt-in explicite. + if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then + echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish." + echo "Set force_unstable=true via workflow_dispatch to override." + exit 1 + fi + + # Lecture du CHANGELOG.md (doit exister à la racine du repo). + if [[ ! -f CHANGELOG.md ]]; then + echo "::error::CHANGELOG.md not found at repo root." + exit 1 + fi + + # Extraction de la section [TAG]. On cherche la première ligne + # commençant par '## [' qui contient '[TAG]' (entre '## [' et + # la prochaine ligne '## [' ou fin de fichier). awk en mode + # paragraphe suffit et reste POSIX. + BODY=$(awk -v tag="[$TAG]" ' + /^## \[/ { + if (in_section) exit + if (index($0, tag) > 0) in_section=1 + next + } + in_section { print } + ' CHANGELOG.md) + + if [[ -z "$BODY" ]]; then + echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md." + echo "Add a '## [$TAG] - $CHANNEL' section before tagging." + exit 1 + fi + + # Vérification cohérence du canal déclaré dans le suffixe. + # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". + if [[ "$BODY" != *" - $CHANNEL"* ]]; then + echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." + echo "Current section body (first 5 lines):" + echo "$BODY" | head -5 + exit 1 + fi + + echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" + + # Écrit le body dans un fichier pour transmission via artifact. + # Le body est multi-ligne, donc artifact > heredoc $GITHUB_ENV. + mkdir -p release-body + printf '%s\n' "$BODY" > release-body/body.md + + - name: Uploader le body de la release comme artifact + uses: actions/upload-artifact@v7 + with: + name: release-body + path: release-body/body.md + retention-days: 1 + + # Construit l'APK via le Dockerfile (stage build-env), puis publie + # la release Forgejo avec le body validé et l'APK en asset. + release: + needs: validate-release + runs-on: docker + steps: + - name: Checkout du code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Checkout du tag (workflow_dispatch uniquement) + # En push tag, le runner checkout déjà au bon commit. + # En workflow_dispatch, on checkout explicitement le tag demandé + # pour que l'APK soit bien construit depuis ce commit. + if: github.event_name == 'workflow_dispatch' + env: + TAG: ${{ inputs.tag }} + run: | + if [[ -z "$TAG" ]]; then + echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + exit 1 + fi + git checkout "$TAG" + + - name: Build de l'image Docker (stage build-env uniquement) + run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . + + - name: Extraire l'APK signé du conteneur + run: | + docker create --name extractor postit-android + docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk ./PostIt.Android.apk + docker rm extractor + + - name: Récupérer le body validé + uses: actions/download-artifact@v7 + with: + name: release-body + path: release-body + + - name: Calculer le canal (stable / preview / unstable) depuis le tag + # On re-parse le tag ici plutôt que de transporter le channel + # via artifact. Le calcul est trivial (parité du patch + suffixe) + # et reste ainsi explicite. + id: set-channel + env: + # En push tag : github.ref_name est le tag. + # En workflow_dispatch : on lit l'input 'tag'. + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + run: | + if [[ -z "$TAG" ]]; then + echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + exit 1 + fi + if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then + echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." + exit 1 + fi + PATCH="${BASH_REMATCH[3]}" + SUFFIX="${BASH_REMATCH[4]}" + if [[ -n "$SUFFIX" ]]; then + CHANNEL="unstable" + elif (( PATCH % 2 == 0 )); then + CHANNEL="stable" + else + CHANNEL="preview" + fi + echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$([[ $CHANNEL != stable ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Publier la release Forgejo et uploader l'APK + uses: https://rasterhub.com/rasterstate/forgejo-release-action@v1 + with: + # tag_name defaults to the pushed tag (GITHUB_REF_NAME). + body_path: release-body/body.md + # Stable -> Latest (false). + # Preview et Unstable -> prerelease (true). + prerelease: ${{ steps.set-channel.outputs.is_prerelease }} + files: | + PostIt.Android.apk + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} \ No newline at end of file From c4695dc2546619a302a50a6e60451205c318cff6 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:23:44 +0100 Subject: [PATCH 02/18] ci(forgejo): use runner-provided GITHUB_TOKEN for release workflow Repo-level secrets creation is broken on this Forgejo instance (InsertEncryptedSecret fails with UTF-8 byte-sequence error, likely a text-vs-bytea column type on the secret table). The fix is in upstream Forgejo v16; until then, ${{ secrets.GITHUB_TOKEN }} (auto- provided by the runner, scoped to contents: write for the current repo) keeps the release workflow operational without any UI setup. When the instance is upgraded and the secret table is migrated, revert this commit to switch back to ${{ secrets.RELEASE_TOKEN }} for least-privilege. --- .forgejo/workflows/release.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 008ee3f3..3dd00e7a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,10 +6,14 @@ # publishes a Forgejo release via rasterstate/forgejo-release-action and # uploads the APK as an asset. # -# Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo PAT scoped -# to `write:repository` configured in the repository's Actions secrets. -# The runner-provided ${{ secrets.GITHUB_TOKEN }} would also work, but -# a dedicated PAT is preferred for least-privilege and revocability. +# Authentication uses ${{ secrets.GITHUB_TOKEN }} (auto-provided by the +# Forgejo runner, scoped to contents: write for the current repo). A +# dedicated PAT (${{ secrets.RELEASE_TOKEN }}) was the preferred option +# for least-privilege, but creating repo-level secrets is currently +# broken on this Forgejo instance (InsertEncryptedSecret fails with a +# UTF-8 byte-sequence error, probably a text-vs-bytea column type on +# the secret table). Bumping to Forgejo v16 should fix it; until then, +# the runner-provided token keeps the workflow operational. # # This workflow complements .github/workflows/docker-publish-android.yml # which targets the GitHub mirror; the validate-release logic mirrors @@ -224,4 +228,4 @@ jobs: files: | PostIt.Android.apk env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From fd99260bc7b3e19e1cb4021007416cbefec85424 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:45:22 +0100 Subject: [PATCH 03/18] ci(forgejo): replace all Node-based actions with bash + curl The runner's docker label points at pazof/yavsc-build-env, a Debian image without Node.js. Any action like actions/checkout@v7, actions/upload-artifact@v7, rasterstate/forgejo-release-action, etc. fails at container start with 'executable file not found in /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home/paul/.dotnet/tools:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/home/paul/.nvm/versions/node/v22.23.0/bin:/home/paul/.local/bin:/home/paul/.npm-global/bin:/home/paul/bin:/home/paul/.nix-profile/bin'. This workflow is rewritten in pure bash: - replace actions/checkout with explicit git clone + checkout (full history + tags so GitVersion.MsBuild is happy); - merge the two jobs into one (no inter-job artifacts needed since everything shares the runner's filesystem); - replace rasterstate/forgejo-release-action with direct calls to the Forgejo REST API (/api/v1/repos/.../releases, .../assets), with python3 used to build and parse JSON bodies (jq not guaranteed in the runner image). Auth: ${{ secrets.GITHUB_TOKEN }} (runner-provided). The rasterstate action or any other Node-based action can be reinstated later if the runner image is swapped for one with Node installed. --- .forgejo/workflows/release.yml | 229 +++++++++++++++++++-------------- 1 file changed, 132 insertions(+), 97 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3dd00e7a..05def45d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -3,8 +3,8 @@ # # Triggered by a push of a git tag. Validates the tag/changelog pair, # builds the APK using the existing Dockerfile (--target build-env), then -# publishes a Forgejo release via rasterstate/forgejo-release-action and -# uploads the APK as an asset. +# publishes a Forgejo release via the Forgejo REST API and uploads the +# APK as an asset. # # Authentication uses ${{ secrets.GITHUB_TOKEN }} (auto-provided by the # Forgejo runner, scoped to contents: write for the current repo). A @@ -15,6 +15,12 @@ # the secret table). Bumping to Forgejo v16 should fix it; until then, # the runner-provided token keeps the workflow operational. # +# Why bash + curl, no third-party actions: the runner's docker label +# points at pazof/yavsc-build-env, a Debian image without Node.js. Any +# action like actions/checkout, rasterstate/forgejo-release-action, etc. +# fails with "executable file not found in $PATH". Same constraint as +# .forgejo/workflows/buildAndTest.yml. +# # This workflow complements .github/workflows/docker-publish-android.yml # which targets the GitHub mirror; the validate-release logic mirrors # the GitHub-side job so the two channels stay consistent. @@ -40,24 +46,15 @@ permissions: contents: write jobs: - # Parse le tag, applique la règle de parité du patch - # (pair=stable / impair=preview / suffixe=unstable), fail-fast sur - # instable sauf opt-in, et vérifie que CHANGELOG.md contient une - # section `## [TAG] - ` cohérente. Le body est extrait - # dans un artifact consommé par le job release. - validate-release: + # Job unique : validation tag/CHANGELOG + build APK + publication + # via l'API REST Forgejo (pas d'actions tierces Node). + release: runs-on: docker steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Valider le tag et la section CHANGELOG + - name: Clone du repo au tag demandé env: # En push tag : github.ref_name est le tag. - # En workflow_dispatch : on lit l'input 'tag' (obligatoire). + # En workflow_dispatch : on lit l'input 'tag'. TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} FORCE_UNSTABLE: ${{ inputs.force_unstable || 'false' }} run: | @@ -66,6 +63,27 @@ jobs: exit 1 fi + # WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile). + cd /src + + # Clone unshallow pour que GitVersion.MsBuild ait l'historique + # et les tags (sinon MSB3073 sur la cible Android cf. PR #21). + if [[ ! -d _src/.git ]]; then + git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src + fi + + cd _src + git fetch --tags --force --prune origin + git checkout "$TAG" + + echo "Checked out at $(git rev-parse HEAD) on $(git describe --tags --always 2>/dev/null || echo unknown)" + + - name: Valider le tag et la section CHANGELOG + run: | + cd /src/_src + TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || git rev-parse --short HEAD)" + echo "Validating tag $TAG" + # Parse semver : MAJOR.MINOR.PATCH[-SUFFIX] if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." @@ -92,7 +110,7 @@ jobs: echo "Tag $TAG classifié comme channel=$CHANNEL" # Fail-fast sur instable sauf opt-in explicite. - if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then + if [[ "$CHANNEL" == "unstable" && "${FORCE_UNSTABLE:-false}" != "true" ]]; then echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish." echo "Set force_unstable=true via workflow_dispatch to override." exit 1 @@ -134,98 +152,115 @@ jobs: echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" - # Écrit le body dans un fichier pour transmission via artifact. - # Le body est multi-ligne, donc artifact > heredoc $GITHUB_ENV. - mkdir -p release-body - printf '%s\n' "$BODY" > release-body/body.md - - - name: Uploader le body de la release comme artifact - uses: actions/upload-artifact@v7 - with: - name: release-body - path: release-body/body.md - retention-days: 1 - - # Construit l'APK via le Dockerfile (stage build-env), puis publie - # la release Forgejo avec le body validé et l'APK en asset. - release: - needs: validate-release - runs-on: docker - steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Checkout du tag (workflow_dispatch uniquement) - # En push tag, le runner checkout déjà au bon commit. - # En workflow_dispatch, on checkout explicitement le tag demandé - # pour que l'APK soit bien construit depuis ce commit. - if: github.event_name == 'workflow_dispatch' - env: - TAG: ${{ inputs.tag }} - run: | - if [[ -z "$TAG" ]]; then - echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." - exit 1 - fi - git checkout "$TAG" + # Expose channel + body pour les étapes suivantes via $GITHUB_ENV. + echo "RELEASE_CHANNEL=$CHANNEL" >> "$GITHUB_ENV" + echo "RELEASE_BODY<> "$GITHUB_ENV" + echo "$BODY" >> "$GITHUB_ENV" + echo "EOF" >> "$GITHUB_ENV" + echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - name: Build de l'image Docker (stage build-env uniquement) - run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . + run: cd /src/_src && docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . - name: Extraire l'APK signé du conteneur run: | docker create --name extractor postit-android - docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk ./PostIt.Android.apk + docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk /src/_src/PostIt.Android.apk docker rm extractor - - name: Récupérer le body validé - uses: actions/download-artifact@v7 - with: - name: release-body - path: release-body - - - name: Calculer le canal (stable / preview / unstable) depuis le tag - # On re-parse le tag ici plutôt que de transporter le channel - # via artifact. Le calcul est trivial (parité du patch + suffixe) - # et reste ainsi explicite. - id: set-channel + - name: Publier la release Forgejo via l'API REST + # Pas d'action tierce (pas de Node dans l'image runner). + # On parle à l'API Forgejo directement via curl. + # Docs : https://forgejo.pschneider.fr/api/swagger#/repository/release env: - # En push tag : github.ref_name est le tag. - # En workflow_dispatch : on lit l'input 'tag'. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_REPOSITORY: ${{ github.repository }} TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + RELEASE_BODY: ${{ env.RELEASE_BODY }} + IS_PRERELEASE: ${{ env.IS_PRERELEASE }} run: | if [[ -z "$TAG" ]]; then - echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + echo "::error::No tag resolved for the API call." exit 1 fi - if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then - echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." - exit 1 - fi - PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" - if [[ -n "$SUFFIX" ]]; then - CHANNEL="unstable" - elif (( PATCH % 2 == 0 )); then - CHANNEL="stable" - else - CHANNEL="preview" - fi - echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" - echo "is_prerelease=$([[ $CHANNEL != stable ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - name: Publier la release Forgejo et uploader l'APK - uses: https://rasterhub.com/rasterstate/forgejo-release-action@v1 - with: - # tag_name defaults to the pushed tag (GITHUB_REF_NAME). - body_path: release-body/body.md - # Stable -> Latest (false). - # Preview et Unstable -> prerelease (true). - prerelease: ${{ steps.set-channel.outputs.is_prerelease }} - files: | - PostIt.Android.apk - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + # Le runner Forgejo expose l'API sur github.api_url (par + # défaut http://…/api/v1). On retire le suffixe /api/v1 s'il + # est présent pour dériver la base du serveur, puis on + # reconstruit l'URL de l'API proprement. + API_BASE="${GITHUB_API_URL%/}" + API_BASE="${API_BASE%/api/v1}" + + # 1. Vérifier si la release existe déjà pour ce tag. + echo "::group::Check existing release for tag $TAG" + HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Accept: application/json" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG") + echo "GET releases/tags/$TAG -> HTTP $HTTP" + EXISTING_ID="" + if [[ "$HTTP" == "200" ]]; then + EXISTING_ID=$(python3 -c "import json,sys; print(json.load(open('/tmp/existing.json')).get('id',''))" 2>/dev/null || true) + echo "Existing release id: ${EXISTING_ID:-none}" + fi + echo "::endgroup::" + + # 2. Créer ou mettre à jour la release. + # On utilise python3 pour générer le body JSON proprement + # (jq n'est pas garanti dans l'image runner). + if [[ -n "$EXISTING_ID" ]]; then + echo "::group::Update release id=$EXISTING_ID" + BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" python3 -c 'import json,os; print(json.dumps({"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ + -X PATCH \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data-binary "$BODY" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$EXISTING_ID") + echo "PATCH release -> HTTP $HTTP" + echo "::endgroup::" + else + echo "::group::Create release" + BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" TAG="$TAG" python3 -c 'import json,os; print(json.dumps({"tag_name":os.environ["TAG"],"name":os.environ["TAG"],"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data-binary "$BODY" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases") + echo "POST release -> HTTP $HTTP" + echo "::endgroup::" + fi + + if [[ "$HTTP" != "200" && "$HTTP" != "201" ]]; then + echo "::error::Release creation/update failed (HTTP $HTTP):" + cat /tmp/release.json + exit 1 + fi + + RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['id'])") + echo "Release id=$RELEASE_ID" + + # 3. Upload l'APK en asset. + echo "::group::Upload APK asset" + HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -H "Accept: application/json" \ + --data-binary "@/src/_src/PostIt.Android.apk" \ + "?name=PostIt.Android.apk" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets") + echo "POST asset -> HTTP $HTTP" + echo "::endgroup::" + + if [[ "$HTTP" != "201" ]]; then + echo "::error::Asset upload failed (HTTP $HTTP):" + cat /tmp/asset.json + exit 1 + fi + + echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" \ No newline at end of file From e07c536e1f5e4cdf56d2821d3967ed158dc3b925 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:49:45 +0100 Subject: [PATCH 04/18] ci(forgejo): check CHANGELOG channel suffix on the section title The previous awk extracted the section body but excluded the title line (## [TAG] - channel), so the '* - $CHANNEL*' pattern never matched. Fix: include the title line in the extracted body, verify the channel suffix on the title, then strip the title before passing the body to the release API. --- .forgejo/workflows/release.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 05def45d..59aafb61 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -125,12 +125,16 @@ jobs: # Extraction de la section [TAG]. On cherche la première ligne # commençant par '## [' qui contient '[TAG]' (entre '## [' et # la prochaine ligne '## [' ou fin de fichier). awk en mode - # paragraphe suffit et reste POSIX. + # paragraphe suffit et reste POSIX. On garde aussi le titre + # (ligne `## [TAG] - channel`) pour la vérification du canal. BODY=$(awk -v tag="[$TAG]" ' /^## \[/ { if (in_section) exit - if (index($0, tag) > 0) in_section=1 - next + if (index($0, tag) > 0) { + in_section=1 + print + next + } } in_section { print } ' CHANGELOG.md) @@ -143,13 +147,16 @@ jobs: # Vérification cohérence du canal déclaré dans le suffixe. # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - if [[ "$BODY" != *" - $CHANNEL"* ]]; then - echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." - echo "Current section body (first 5 lines):" - echo "$BODY" | head -5 + # On lit la première ligne du body qui contient le titre. + TITLE=$(echo "$BODY" | head -1) + if [[ "$TITLE" != *" - $CHANNEL"* ]]; then + echo "::error::Section title '$TITLE' must declare suffix '- $CHANNEL' to match tag parity." exit 1 fi + # Body pour la release : retire la première ligne (titre). + BODY=$(echo "$BODY" | tail -n +2) + echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" # Expose channel + body pour les étapes suivantes via $GITHUB_ENV. From 64d25bb2f18a1ee52c167e7acce7cfa53a219e14 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:56:01 +0100 Subject: [PATCH 05/18] ci(forgejo): build .NET projects directly, skip docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'image runner pazof/yavsc-build-env a le SDK .NET 10 et le workload Android, mais PAS le binaire 'docker' ni de daemon Docker. Le 'Build de l'image Docker' du workflow plantait avec 'docker: command not found'. Fix : on exécute directement les commandes dotnet du Dockerfile (restore + build Yavsc.Org/Api/Blogs + build PostIt.Android -r android-arm64), puis on copie l'APK depuis le chemin de sortie standard bin/Release/net10.0-android/android-arm64/. Note : le Dockerfile reste la voie canonique pour les builds en local et via GitHub Actions (qui a docker). Ce fix concerne uniquement le workflow Forgejo Actions où le runner n'a pas Docker. --- .forgejo/workflows/release.yml | 37 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 59aafb61..dc813fbe 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -166,14 +166,37 @@ jobs: echo "EOF" >> "$GITHUB_ENV" echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - - name: Build de l'image Docker (stage build-env uniquement) - run: cd /src/_src && docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . - - - name: Extraire l'APK signé du conteneur + - name: Build des projets .NET (sans docker) + # L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le + # workload Android, mais PAS le binaire `docker` ni de daemon + # Docker. On exécute donc les commandes dotnet directement + # au lieu de passer par `docker build`. + # Equivalent des stages build-env du Dockerfile (lignes + # restore + build Yavsc.Org + build Yavsc.Api + build + # Yavsc.Blogs + build PostIt.Android -r android-arm64). run: | - docker create --name extractor postit-android - docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk /src/_src/PostIt.Android.apk - docker rm extractor + cd /src/_src + dotnet restore + dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ + -c Release --no-restore -clp:ErrorsOnly -r android-arm64 + + - name: Copier l'APK signé vers un emplacement connu + # Le build Android avec -r android-arm64 produit l'APK dans + # bin/Release/net10.0-android/android-arm64/. On le copie à + # la racine du checkout pour que l'étape d'upload le trouve. + run: | + cd /src/_src + APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk + if [[ ! -f "$APK" ]]; then + echo "::error::APK not found at $APK" + ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true + exit 1 + fi + cp "$APK" /src/_src/PostIt.Android.apk + ls -la /src/_src/PostIt.Android.apk - name: Publier la release Forgejo via l'API REST # Pas d'action tierce (pas de Node dans l'image runner). From 5c20c0bc04220307cc6ab54d0359c49ebd60f2b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:00:12 +0000 Subject: [PATCH 06/18] Initial plan From 8960ce7d93c51c92699474ac682a38925059bc6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:02:14 +0000 Subject: [PATCH 07/18] fix(ci): fix validate-release CHANGELOG channel check to inspect heading line Co-authored-by: pazof <3072814+pazof@users.noreply.github.com> --- .github/workflows/docker-publish-android.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml index 94f190c2..b9ee364c 100644 --- a/.github/workflows/docker-publish-android.yml +++ b/.github/workflows/docker-publish-android.yml @@ -129,12 +129,12 @@ jobs: exit 1 fi - # Vérification cohérence du canal déclaré dans le suffixe. + # Vérification cohérence du canal déclaré dans le titre de section. # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - if [[ "$BODY" != *" - $CHANNEL"* ]]; then + HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md) + if [[ "$HEADER" != *" - $CHANNEL"* ]]; then echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." - echo "Current section body (first 5 lines):" - echo "$BODY" | head -5 + echo "Current section header: $HEADER" exit 1 fi From 2df364aa1ec2ea6f164920195d706fe18abe170f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 02:16:46 +0100 Subject: [PATCH 08/18] ci(forgejo): build JSON bodies in pure bash, no python3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'image runner pazof/yavsc-build-env n'a pas python3 (ni jq, ni node). Le step de publication Forgejo utilisait python3 pour générer les bodies JSON (POST /releases, PATCH /releases/{id}) et pour extraire le 'id' de la réponse. Fix : deux fonctions bash : - json_escape : escaping JSON des chaînes (\\, \", \n, \r, \t) - json_field : extraction d'un champ scalaire d'un fichier JSON via sed Suffisant pour les bodies qu'on envoie (tag_name, name, body, prerelease) et les champs qu'on lit (id). --- .forgejo/workflows/release.yml | 37 ++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index dc813fbe..9306d087 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -222,6 +222,28 @@ jobs: API_BASE="${GITHUB_API_URL%/}" API_BASE="${API_BASE%/api/v1}" + # Pas de python3, pas de jq dans l'image runner. On génère + # le JSON à la main : escaping minimal des caractères + # spéciaux JSON dans les chaînes (\\, \", \n, \r, \t). + # Suffisant pour un CHANGELOG.md bien formé. + json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + printf '%s' "$s" + } + + # Extraction d'un champ JSON scalaire (string ou number) depuis + # un fichier. Utilise sed basique, suffisant pour les champs + # id / tag_name que l'API renvoie en clair. + json_field() { + local file="$1" field="$2" + sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([^\",}]*\)\"\?.*/\1/p" "$file" | head -1 + } + # 1. Vérifier si la release existe déjà pour ce tag. echo "::group::Check existing release for tag $TAG" HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \ @@ -231,17 +253,16 @@ jobs: echo "GET releases/tags/$TAG -> HTTP $HTTP" EXISTING_ID="" if [[ "$HTTP" == "200" ]]; then - EXISTING_ID=$(python3 -c "import json,sys; print(json.load(open('/tmp/existing.json')).get('id',''))" 2>/dev/null || true) + EXISTING_ID=$(json_field /tmp/existing.json id) echo "Existing release id: ${EXISTING_ID:-none}" fi echo "::endgroup::" # 2. Créer ou mettre à jour la release. - # On utilise python3 pour générer le body JSON proprement - # (jq n'est pas garanti dans l'image runner). if [[ -n "$EXISTING_ID" ]]; then echo "::group::Update release id=$EXISTING_ID" - BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" python3 -c 'import json,os; print(json.dumps({"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + BODY=$(printf '{"body":"%s","prerelease":%s}' \ + "$(json_escape "$RELEASE_BODY")" "$IS_PRERELEASE") HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X PATCH \ -H "Authorization: token $GITHUB_TOKEN" \ @@ -253,7 +274,11 @@ jobs: echo "::endgroup::" else echo "::group::Create release" - BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" TAG="$TAG" python3 -c 'import json,os; print(json.dumps({"tag_name":os.environ["TAG"],"name":os.environ["TAG"],"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + BODY=$(printf '{"tag_name":"%s","name":"%s","body":"%s","prerelease":%s}' \ + "$(json_escape "$TAG")" \ + "$(json_escape "$TAG")" \ + "$(json_escape "$RELEASE_BODY")" \ + "$IS_PRERELEASE") HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X POST \ -H "Authorization: token $GITHUB_TOKEN" \ @@ -271,7 +296,7 @@ jobs: exit 1 fi - RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['id'])") + RELEASE_ID=$(json_field /tmp/release.json id) echo "Release id=$RELEASE_ID" # 3. Upload l'APK en asset. From 24fede0bd0fccfd743d0a07e7e63acdc08b0e4cf Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 03:26:11 +0100 Subject: [PATCH 09/18] ci(forgejo): limit json_field extraction to top-level keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'API Forgejo renvoie pour /releases/tags/ un objet JSON pretty-printed où l'id racine (release.id, ex. 10706) est sur la première ligne, mais l'objet author contient aussi un id (souvent 1 pour le premier user du repo). L'ancienne regex sed matchait la première occurrence globale de "id" dans le fichier, donc elle retombait sur author.id=1 et le PATCH /releases/1 tombait en 404 'The target couldn't be found'. Fix : on pipe le fichier dans 'head -3' pour ne matcher que les premières lignes (couvre largement le préambule de l'objet release). Si Forgejo renvoie du JSON minifié (une seule ligne), head -3 renvoie toute la ligne et la regex matche le premier id (la racine, parce que les champs auteur sont après les champs racine). --- .forgejo/workflows/release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 9306d087..137148a2 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -236,12 +236,16 @@ jobs: printf '%s' "$s" } - # Extraction d'un champ JSON scalaire (string ou number) depuis - # un fichier. Utilise sed basique, suffisant pour les champs - # id / tag_name que l'API renvoie en clair. + # Extraction d'un champ JSON scalaire de premier niveau depuis un + # fichier. On ne lit que les premières lignes pour éviter de + # matcher un champ homonyme dans un objet imbriqué (par ex. + # le champ "id" de l'auteur d'une release Forgejo, qui vaut + # typiquement 1 pour le premier user du repo). Sans cette + # restriction, le PATCH sur /releases/ tombe en + # 404 "The target couldn't be found". json_field() { local file="$1" field="$2" - sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([^\",}]*\)\"\?.*/\1/p" "$file" | head -1 + head -3 "$file" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([0-9][0-9]*\)\"\?.*/\1/p" | head -1 } # 1. Vérifier si la release existe déjà pour ce tag. From c2d55317ab146dc17a57e625f0eacb0b18177e0b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 04:35:00 +0100 Subject: [PATCH 10/18] ci(forgejo): build JSON bodies with jq instead of hand-rolled sed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'image runner pazof/yavsc-build-env installe jq (>= 1.7) à partir de debian12-dotnet10-android36-v2 (Dockerfile du repo dotnet-android-build-image, commit e06f096 "adds jq"). On en profite pour supprimer json_escape et json_field à base de sed, qui étaient fragiles : * sed est greedy par défaut : sur du JSON minifié d'une seule ligne (ce que renvoie l'API Forgejo de cette instance pour /releases/tags/), la regex s/.*"id".../\1/p attrape la DERNIÈRE occurrence de "id": sur la ligne, qui est l'id de l'auteur de la release (1, premier user du repo), pas l'id de la release (10706). * Le head -3 ajouté en PR #30 ne tient pas sur du JSON minifié : il n'isole rien et le sed greedy continue à capturer l'id de l'auteur. * PATCH /releases/1 tombait alors en 404 "The target couldn't be found" (cf. run échoué du 2026-08-17 04:05 sur le tag 1.0.6). jq résout les deux problèmes en une fois : * jq -r '.id' retourne le champ id racine, pas l'id imbriqué dans author. * jq -n --arg body "$RELEASE_BODY" '{body: $body, prerelease: $prerelease}' construit un body JSON proprement échappé (backslashes, guillemets, newlines, caractères de contrôle Unicode) sans avoir à le reproduire à la main. Effet de bord : les bodies PATCH et POST sont écrits dans /tmp/patch.json et /tmp/post.json puis passés à curl via --data-binary @ au lieu d'une variable shell. Plus de problème de quoting en chaîne shell, plus de collision avec les espaces ou les caractères spéciaux du body. Pré-requis côté runner : image pazof/yavsc-build-env:debian12- dotnet10-android36-v2 (avec jq) + maj du label correspondant dans la config du runner Forgejo. --- .forgejo/workflows/release.yml | 82 +++++++++++++++++----------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 137148a2..cf3aaff7 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -15,10 +15,16 @@ # the secret table). Bumping to Forgejo v16 should fix it; until then, # the runner-provided token keeps the workflow operational. # -# Why bash + curl, no third-party actions: the runner's docker label -# points at pazof/yavsc-build-env, a Debian image without Node.js. Any -# action like actions/checkout, rasterstate/forgejo-release-action, etc. -# fails with "executable file not found in $PATH". Same constraint as +# Why bash + jq + curl, no third-party actions: the runner's docker +# label points at pazof/yavsc-build-env, a Debian image with jq but +# without Node.js or python3. Any action like actions/checkout, +# rasterstate/forgejo-release-action, etc. fails with "executable +# file not found in $PATH". jq is shipped in the image from +# debian12-dotnet10-android36-v2 onward; earlier tags fell back to +# hand-rolled JSON building via sed, which was fragile (cf. PR #30: +# sed greedy + head -3 still matched author.id instead of the +# release id on the minified JSON this instance returns, PATCH +# /releases/1 → 404). Same constraint as # .forgejo/workflows/buildAndTest.yml. # # This workflow complements .github/workflows/docker-publish-android.yml @@ -222,31 +228,22 @@ jobs: API_BASE="${GITHUB_API_URL%/}" API_BASE="${API_BASE%/api/v1}" - # Pas de python3, pas de jq dans l'image runner. On génère - # le JSON à la main : escaping minimal des caractères - # spéciaux JSON dans les chaînes (\\, \", \n, \r, \t). - # Suffisant pour un CHANGELOG.md bien formé. - json_escape() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\"/\\\"}" - s="${s//$'\n'/\\n}" - s="${s//$'\r'/\\r}" - s="${s//$'\t'/\\t}" - printf '%s' "$s" - } - - # Extraction d'un champ JSON scalaire de premier niveau depuis un - # fichier. On ne lit que les premières lignes pour éviter de - # matcher un champ homonyme dans un objet imbriqué (par ex. - # le champ "id" de l'auteur d'une release Forgejo, qui vaut - # typiquement 1 pour le premier user du repo). Sans cette - # restriction, le PATCH sur /releases/ tombe en - # 404 "The target couldn't be found". - json_field() { - local file="$1" field="$2" - head -3 "$file" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([0-9][0-9]*\)\"\?.*/\1/p" | head -1 - } + # Construction des bodies JSON et extraction de champs via + # jq. L'image runner pazof/yavsc-build-env installe jq + # (>= 1.7) depuis debian12-dotnet10-android36-v2. La + # chaîne de construction --arg/--argjson garantit un + # escaping correct (backslashes, guillemets, newlines, + # caractères de contrôle Unicode) sans avoir à le + # reproduire à la main. + # + # json_escape et json_field à base de sed ont vécu : le + # sed greedy matche la dernière occurrence d'un champ + # dans la ligne, et l'API renvoie sur cette instance un + # JSON minifié d'une seule ligne où l'id de l'auteur + # (1, premier user du repo) suit l'id de la release + # (10706). PATCH /releases/ tombait + # alors en 404 "The target couldn't be found". jq + # résout les deux problèmes en une fois. # 1. Vérifier si la release existe déjà pour ce tag. echo "::group::Check existing release for tag $TAG" @@ -257,7 +254,7 @@ jobs: echo "GET releases/tags/$TAG -> HTTP $HTTP" EXISTING_ID="" if [[ "$HTTP" == "200" ]]; then - EXISTING_ID=$(json_field /tmp/existing.json id) + EXISTING_ID=$(jq -r '.id // empty' /tmp/existing.json) echo "Existing release id: ${EXISTING_ID:-none}" fi echo "::endgroup::" @@ -265,30 +262,35 @@ jobs: # 2. Créer ou mettre à jour la release. if [[ -n "$EXISTING_ID" ]]; then echo "::group::Update release id=$EXISTING_ID" - BODY=$(printf '{"body":"%s","prerelease":%s}' \ - "$(json_escape "$RELEASE_BODY")" "$IS_PRERELEASE") + jq -n \ + --arg body "$RELEASE_BODY" \ + --argjson prerelease "$IS_PRERELEASE" \ + '{body: $body, prerelease: $prerelease}' \ + > /tmp/patch.json HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X PATCH \ -H "Authorization: token $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ - --data-binary "$BODY" \ + --data-binary @/tmp/patch.json \ "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$EXISTING_ID") echo "PATCH release -> HTTP $HTTP" echo "::endgroup::" else echo "::group::Create release" - BODY=$(printf '{"tag_name":"%s","name":"%s","body":"%s","prerelease":%s}' \ - "$(json_escape "$TAG")" \ - "$(json_escape "$TAG")" \ - "$(json_escape "$RELEASE_BODY")" \ - "$IS_PRERELEASE") + jq -n \ + --arg tag "$TAG" \ + --arg name "$TAG" \ + --arg body "$RELEASE_BODY" \ + --argjson prerelease "$IS_PRERELEASE" \ + '{tag_name: $tag, name: $name, body: $body, prerelease: $prerelease}' \ + > /tmp/post.json HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X POST \ -H "Authorization: token $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ - --data-binary "$BODY" \ + --data-binary @/tmp/post.json \ "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases") echo "POST release -> HTTP $HTTP" echo "::endgroup::" @@ -300,7 +302,7 @@ jobs: exit 1 fi - RELEASE_ID=$(json_field /tmp/release.json id) + RELEASE_ID=$(jq -r '.id' /tmp/release.json) echo "Release id=$RELEASE_ID" # 3. Upload l'APK en asset. From bea2e35bb4b96b8e597b4035498aaf8250da95a1 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 05:25:20 +0100 Subject: [PATCH 11/18] chore(release): update 1.0.6 CHANGELOG section (image v2, jq fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La section [1.0.6] - stable du CHANGELOG mentionnait encore debian12-dotnet10-android36-v1 et ne décrivait pas le fix du PATCH release qui tombait en 404 à cause du sed greedy + JSON minifié. Mets à jour avant de relancer la publication de la release 1.0.6 (workflow_dispatch), pour que le body publié reflète l'état réel de l'infra (image v2 avec jq) et du workflow. --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ec2b56..c855b249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,13 @@ pour la production des paquets `.deb`. ### Added - Self-hosted Forgejo Actions runner now drives the CI build for the yavsc repository, using the - `pazof/yavsc-build-env:debian12-dotnet10-android36-v1` image pulled + `pazof/yavsc-build-env:debian12-dotnet10-android36-v2` image pulled from Docker Hub. Workflow runs end-to-end: clone, restore, build, test, with NuGet.config picking up the `isn.pschneider.fr` feed. +- The build-env image now ships `jq` (Debian package, ≥ 1.7), so the + release workflow can build JSON bodies and parse API responses + without a hand-rolled `sed`-based extractor that was matching the + wrong `id` field on minified responses. ### Changed - CI workflow `.forgejo/workflows/buildAndTest.yml` no longer relies on @@ -47,6 +51,12 @@ pour la production des paquets `.deb`. Actions APK build (`--allow-insecure-connections` on an HTTPS endpoint, exit 1). `NuGet.config` at the repo root supplies the `isn.pschneider.fr` feed for every restore, including inside Docker. +- `.forgejo/workflows/release.yml`: PATCH on `/releases/{id}` no longer + 404s on existing releases. The previous `sed`-based `json_field` + matched the last `id` on the line (the author's), so it tried to + PATCH `/releases/1` (the first user of the instance) instead of the + actual release id. Switched to `jq` for both body construction and + field extraction. [Unreleased]: https://github.com/pazof/yavsc/compare/HEAD [1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 From ab8e77279bfc61db41c83139bd291aba2f11b6b5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 05:44:12 +0100 Subject: [PATCH 12/18] ci(forgejo): put asset name in URL query string, not as curl arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le run #102 (re-publication du tag 1.0.6 après le fix jq + bump image v2) a passé le PATCH /releases/10706 (jq a bien extrait l'id racine, plus de 404), mais l'upload d'asset a planté avec un 400 "Missing 'name' parameter". Cause : sur l'appel curl de l'upload d'asset, l'argument `?name=...` était passé en argument positionnel entre `--data-binary @file` et l'URL. curl l'interprète comme un second fichier d'input (un fichier nommé '?name=...'), pas comme un query param, et l'API Forgejo ne voit jamais le name. Fix : concaténer `?name=PostIt.Android.apk` à l'URL directement. L'API Forgejo accepte le name en query string sur POST /releases/{id}/assets. --- .forgejo/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index cf3aaff7..ef518037 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -306,6 +306,11 @@ jobs: echo "Release id=$RELEASE_ID" # 3. Upload l'APK en asset. + # Le nom du fichier passe en query string (?name=...), pas + # en argument positionnel entre --data-binary et l'URL : + # sinon curl l'interprète comme un second fichier d'input + # (un fichier nommé '?name=PostIt.Android.apk') et l'API + # Forgejo renvoie 400 "Missing 'name' parameter". echo "::group::Upload APK asset" HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ -X POST \ @@ -313,8 +318,7 @@ jobs: -H "Content-Type: application/octet-stream" \ -H "Accept: application/json" \ --data-binary "@/src/_src/PostIt.Android.apk" \ - "?name=PostIt.Android.apk" \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets") + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk") echo "POST asset -> HTTP $HTTP" echo "::endgroup::" From a8c219e0fac35290d1b3cc8bef6e2571b9ebee7f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:00:04 +0100 Subject: [PATCH 13/18] WIP app invite: scaffold MAUI Essentials dependency in shared PostIt Adds Microsoft.Maui.Essentials package and true to src/PostIt/PostIt/PostIt.csproj so the shared project can compile code that calls MAUI Essentials APIs (Microsoft.Maui.ApplicationModel.*). Also adds a draft ContactService that wraps Contacts.Default.GetAllAsync() behind a runtime platform check and permission request. WIP caveats: - The portable MAUI Essentials facade compiles on net10.0 but throws NotImplementedInReferenceAssemblyException at runtime when no platform-specific MAUI Essentials binary is loaded. A PostIt.Android project (or equivalent) must reference the Android MAUI Essentials implementation for Contacts.Default.GetAllAsync() to actually work. - On desktop (Linux/macOS/Windows) the API is unsupported by design; ContactService currently throws PlatformNotSupportedException. A desktop stub returning Array.Empty() is the likely next step. - No tests yet. The scaffold is unverified at runtime; build passes. --- src/PostIt/Directory.Packages.props | 1 + src/PostIt/PostIt/PostIt.csproj | 5 ++- src/PostIt/PostIt/Services/ContactService.cs | 39 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/PostIt/PostIt/Services/ContactService.cs diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 900f1428..62b3a343 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index e4d51a88..23bb4fd9 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -3,11 +3,13 @@ net10.0 enable latest + true true 1.0.1.0 1.0.1.0 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f 1.0.1-5 + @@ -24,6 +26,7 @@ + @@ -40,4 +43,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt/Services/ContactService.cs b/src/PostIt/PostIt/Services/ContactService.cs new file mode 100644 index 00000000..bc71d11b --- /dev/null +++ b/src/PostIt/PostIt/Services/ContactService.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Maui.ApplicationModel.Communication; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Devices; + +public class ContactService +{ + public async Task> GetDeviceContactsAsync() + { + // 1. Ensure the platform supports MAUI Essentials APIs + if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) + { + throw new PlatformNotSupportedException("MAUI Essentials is not available on this platform."); + } + + try + { + // 2. Request runtime permission (Required for Android & iOS) + var status = await Permissions.RequestAsync(); + if (status != PermissionStatus.Granted) + { + // Permission denied by user + return Array.Empty(); + } + + // 3. Fetch all contacts + var contactsEnumerable = await Contacts.Default.GetAllAsync(); + return contactsEnumerable ?? Array.Empty(); + } + catch (Exception ex) + { + // Handle cross-platform exceptions or logs here + System.Diagnostics.Debug.WriteLine($"Error fetching contacts: {ex.Message}"); + return Array.Empty(); + } + } +} From 69a660cafbbb0fbab421cd0311e21ea9365e77fb Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:13:35 +0100 Subject: [PATCH 14/18] feat(app-invite): isolate ContactService to mobile targets Splits the single ContactService class (which threw PlatformNotSupportedException on non-Android/iOS targets) into a platform-conditional structure: - IContactService + ContactDto: shared abstraction in src/PostIt/PostIt/Services/IContactService.cs. ViewModels depend on this; concrete providers map their native shapes to ContactDto. - ContactService.Mobile.cs: MAUI Essentials implementation, compiled only when ANDROID or IOS is defined. Wraps Contacts.Default.GetAllAsync() with permission handling and a NotImplementedInReferenceAssemblyException safety net. - ContactService.Desktop.cs: stub returning an empty list, compiled when neither ANDROID nor IOS is defined. Replaces the 'throw PlatformNotSupportedException' path so desktop targets (PostIt.Desktop, PostIt.Browser) build and run cleanly. The Microsoft.Maui.Essentials portable facade is referenced from PostIt.csproj, but it only becomes functional when the host application project (PostIt.Android, future PostIt.iOS) also references the platform-specific implementation. No tests added: per AGENTS.md, a 'stub returns empty list' test on PostIt.Tests (net10.0 desktop target) would be cosmetic and not detect the real failure mode. Android-side tests require a working PostIt.Android project, which doesn't exist yet. Future providers (Google Contacts API, Exchange, CardDAV) plug in as additional IContactService implementations selected by DI configuration. --- .../PostIt/Services/ContactService.Desktop.cs | 27 ++++++++ .../PostIt/Services/ContactService.Mobile.cs | 64 +++++++++++++++++++ src/PostIt/PostIt/Services/ContactService.cs | 39 ----------- src/PostIt/PostIt/Services/IContactService.cs | 29 +++++++++ 4 files changed, 120 insertions(+), 39 deletions(-) create mode 100644 src/PostIt/PostIt/Services/ContactService.Desktop.cs create mode 100644 src/PostIt/PostIt/Services/ContactService.Mobile.cs delete mode 100644 src/PostIt/PostIt/Services/ContactService.cs create mode 100644 src/PostIt/PostIt/Services/IContactService.cs diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs new file mode 100644 index 00000000..82746c49 --- /dev/null +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -0,0 +1,27 @@ +#if !ANDROID && !IOS +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Desktop stub for IContactService. +/// +/// On desktop targets (Linux, macOS, Windows) MAUI Essentials +/// Contacts.Default throws NotImplementedInReferenceAssemblyException, +/// so we short-circuit with an empty list rather than trying to +/// call into the portable facade at runtime. +/// +/// Future provider plug-ins (Google Contacts API, Exchange EWS, +/// CardDAV) can either replace this stub on a per-OS basis or +/// live behind their own IContactService implementation that the +/// DI container selects by configuration. +/// +public sealed class ContactService : IContactService +{ + public Task> GetDeviceContactsAsync(CancellationToken ct = default) + => Task.FromResult>(Array.Empty()); +} +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs new file mode 100644 index 00000000..744fb9a6 --- /dev/null +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -0,0 +1,64 @@ +#if ANDROID || IOS +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Maui.ApplicationModel.Communication; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Devices; + +namespace PostIt.Services; + +/// +/// Mobile implementation backed by MAUI Essentials Contacts.Default. +/// +/// Compiled only for ANDROID and IOS. On desktop targets, see +/// ContactService.Desktop.cs (the stub that wins at compile time). +/// +/// Note: at runtime, this class throws +/// NotImplementedInReferenceAssemblyException unless the host +/// application project also references the platform-specific +/// Microsoft.Maui.Essentials implementation (typically the +/// PostIt.Android project). On iOS the same is required via +/// PostIt.iOS. On desktop the stub is used and this file is excluded. +/// +public sealed class ContactService : IContactService +{ + public async Task> GetDeviceContactsAsync(CancellationToken ct = default) + { + if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) + return Array.Empty(); + + try + { + var status = await Permissions.RequestAsync(); + if (status != PermissionStatus.Granted) + return Array.Empty(); + + var contacts = await Contacts.Default.GetAllAsync(); + if (contacts is null) return Array.Empty(); + + var result = new List(); + foreach (var c in contacts) + { + var emails = new List(); + if (c.Emails is not null) + { + foreach (var e in c.Emails) + { + if (!string.IsNullOrEmpty(e.EmailAddress)) + emails.Add(e.EmailAddress); + } + } + result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails)); + } + return result; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}"); + return Array.Empty(); + } + } +} +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.cs b/src/PostIt/PostIt/Services/ContactService.cs deleted file mode 100644 index bc71d11b..00000000 --- a/src/PostIt/PostIt/Services/ContactService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Maui.ApplicationModel.Communication; -using Microsoft.Maui.ApplicationModel; -using Microsoft.Maui.Devices; - -public class ContactService -{ - public async Task> GetDeviceContactsAsync() - { - // 1. Ensure the platform supports MAUI Essentials APIs - if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) - { - throw new PlatformNotSupportedException("MAUI Essentials is not available on this platform."); - } - - try - { - // 2. Request runtime permission (Required for Android & iOS) - var status = await Permissions.RequestAsync(); - if (status != PermissionStatus.Granted) - { - // Permission denied by user - return Array.Empty(); - } - - // 3. Fetch all contacts - var contactsEnumerable = await Contacts.Default.GetAllAsync(); - return contactsEnumerable ?? Array.Empty(); - } - catch (Exception ex) - { - // Handle cross-platform exceptions or logs here - System.Diagnostics.Debug.WriteLine($"Error fetching contacts: {ex.Message}"); - return Array.Empty(); - } - } -} diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs new file mode 100644 index 00000000..8c6da44a --- /dev/null +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Abstraction over device contact providers (MAUI Essentials on mobile, +/// future Google/Exchange/IMAP providers). +/// +/// Implementations live next to this file in platform-conditional +/// source files: ContactService.Mobile.cs (ANDROID/IOS) and +/// ContactService.Desktop.cs (everything else). +/// +public interface IContactService +{ + Task> GetDeviceContactsAsync(CancellationToken ct = default); +} + +/// +/// Platform-neutral contact DTO. Source-of-truth shape for the UI layer; +/// concrete providers (MAUI Essentials today, Google Contacts API later) +/// map to this type. +/// +public sealed record ContactDto( + string Id, + string DisplayName, + IReadOnlyList Emails); From 6e7e04141b7a155d829c2fd6e4e2048eb1e4b216 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 00:34:29 +0100 Subject: [PATCH 15/18] feat(api-client): add UserSearchClient for /api/user-search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the client-side half of the user-search endpoint landed on the server in b3056f1c (commit 6 on this branch). The client mirrors the server's filter contract: - query: substring match on FullName or UserName - email: exact match on Email - take: 1..100, default 25 Empty (query + email) short-circuits to an empty list client-side rather than letting the server return the first `take` users alphabetically — the address-book UX is "type to search", not "show me a directory". The DTO (Yavsc.Api.Client.Dtos.UserSearchResultDto) is a flat shape (Id, UserName, FullName, Avatar, Email) with no navigation properties; field names match the JSON the server emits so deserialisation is a no-op. PostIt wiring: - App.axaml.cs constructs a UserSearchClient singleton and registers it alongside CircleApiClient and BlogAclApiClient. - The PostIt.csproj ProjectReference to Yavsc.Api.Client was in place before this commit on feat/postit-acl; the rebase of feat/app-invite on top of feat/postit-acl dropped it. This commit re-adds it. --- src/PostIt/PostIt/App.axaml.cs | 2 + src/PostIt/PostIt/PostIt.csproj | 5 +- .../Dtos/UserSearchResultDto.cs | 23 ++++++ src/Yavsc.Api.Client/UserSearchClient.cs | 79 +++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs create mode 100644 src/Yavsc.Api.Client/UserSearchClient.cs diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index e59e0d33..c4b81fe3 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -59,6 +59,7 @@ public partial class App : Application var client = new BlogApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var services = new ServiceCollection(); @@ -87,6 +88,7 @@ public partial class App : Application services.AddSingleton(client); services.AddSingleton(circleClient); services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 23bb4fd9..e4d51a88 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -3,13 +3,11 @@ net10.0 enable latest - true true 1.0.1.0 1.0.1.0 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f 1.0.1-5 - @@ -26,7 +24,6 @@ - @@ -43,4 +40,4 @@ - + \ No newline at end of file diff --git a/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs b/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs new file mode 100644 index 00000000..d77f50e7 --- /dev/null +++ b/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs @@ -0,0 +1,23 @@ +namespace Yavsc.Api.Client.Dtos; + +/// +/// Wire format for GET /api/user-search. +/// +/// Mirrors the server-side +/// Yavsc.Blogs.Controllers.UserSearchResultDto but stops +/// short of any entity navigation properties. Only the fields +/// a client address book needs (id, name, avatar, email) are +/// included. +/// +/// Field names match the JSON the server emits (camelCase +/// via the default policy), so +/// no [JsonPropertyName] attributes are required. +/// +public sealed class UserSearchResultDto +{ + public string Id { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string? FullName { get; set; } + public string? Avatar { get; set; } + public string? Email { get; set; } +} \ No newline at end of file diff --git a/src/Yavsc.Api.Client/UserSearchClient.cs b/src/Yavsc.Api.Client/UserSearchClient.cs new file mode 100644 index 00000000..d1aef6be --- /dev/null +++ b/src/Yavsc.Api.Client/UserSearchClient.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client.Dtos; + +namespace Yavsc.Api.Client; + +/// +/// HTTP client for /api/user-search on the Yavsc Blogs +/// server. Used by client-side address books (PostIt.Desktop, +/// future PostIt.Browser CLI, …) to look up Yavsc users by +/// display name or email. +/// +/// The server scopes every endpoint to the authenticated +/// caller; any authenticated user can search the user table of +/// the instance. There is no per-user filtering on the response +/// side — this is by design on single-tenant deployments +/// (closed community). Multi-tenant deployments should gate +/// this controller behind a tenant-scoped policy before +/// exposing it; see the server-side +/// UserSearchApiController doc for details. +/// +public sealed class UserSearchClient +{ + private const string Path = "user-search"; + + private readonly IYavscApiClient _api; + + public UserSearchClient(IYavscApiClient api, string blogsBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); + + if (api.Http.BaseAddress is null) + api.Http.BaseAddress = new Uri(blogsBaseAddress); + } + + /// + /// Search users by display name (substring) or email (exact). + /// + /// Substring filter on FullName or + /// UserName. Empty or null returns an empty list (the server + /// would return all users, which we don't want by + /// default). + /// Optional exact-match filter on + /// Email. + /// Maximum results, capped at 100. + /// Default 25. + public Task> SearchAsync( + string? query = null, + string? email = null, + int take = 25, + CancellationToken ct = default) + { + // Match the server's contract: at least one filter is + // expected. The server doesn't enforce this (an empty + // query + empty email returns the first `take` users + // alphabetically), but the address-book UX is "type + // something to search", so we short-circuit empty + // queries client-side. + if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(email)) + return Task.FromResult(new List()); + + var qs = new List(); + if (!string.IsNullOrWhiteSpace(query)) + qs.Add($"q={Uri.EscapeDataString(query)}"); + if (!string.IsNullOrWhiteSpace(email)) + qs.Add($"e={Uri.EscapeDataString(email)}"); + qs.Add($"take={Math.Clamp(take, 1, 100)}"); + + return _api.CallAsync>( + HttpMethod.Get, + $"{Path}?{string.Join('&', qs)}", + ct: ct); + } +} \ No newline at end of file From d0e0f4c17520d0a483163785e4c804e6b48c0c45 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 00:36:36 +0100 Subject: [PATCH 16/18] feat(postit): wire Desktop address book to /api/user-search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the empty ContactService.Desktop stub with a real implementation backed by UserSearchClient. Closes the loop between the server-side /api/user-search endpoint (b3056f1c), the client wrapper (6e7e0414), and the platform abstraction. IContactService gains: - SearchAsync(string query, CancellationToken): on desktop, hits /api/user-search and appends results to an in-memory cache. On mobile, throws PlatformNotSupportedException — mobile providers use the device-local address book (GetDeviceContactsAsync) and don't talk to a network search. - Contacts (ObservableCollection): live view of the cache; UI binds directly to it. Mobile populates it inside GetDeviceContactsAsync (eager load); desktop populates it via SearchAsync (lazy, on-demand). ContactDto shape changes: - Emails (IReadOnlyList) -> Email (string?). The /api/user-search endpoint returns one email per user. The use case ('invite / add to a circle') only needs one. - Mobile provider flattens its per-contact email list down to the first non-empty entry (a small functional loss that matches the wire shape). App.axaml.cs constructs a ContactService from the UserSearchClient singleton and registers it as IContactService so future ViewModels can take the interface by constructor injection. Build + 51/51 tests green. The mobile provider is still gated by #if ANDROID || IOS and not exercised by the Desktop test target — runtime behaviour on Android will need a smoke test on device when PostIt.Android lands. --- src/PostIt/PostIt/App.axaml.cs | 2 + .../PostIt/Services/ContactService.Desktop.cs | 67 ++++++++++++++++--- .../PostIt/Services/ContactService.Mobile.cs | 43 ++++++++---- src/PostIt/PostIt/Services/IContactService.cs | 44 ++++++++++-- 4 files changed, 126 insertions(+), 30 deletions(-) diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index c4b81fe3..c5ab68e2 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,6 +60,7 @@ public partial class App : Application var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var contactService = new ContactService(userSearchClient); var services = new ServiceCollection(); @@ -89,6 +90,7 @@ public partial class App : Application services.AddSingleton(circleClient); services.AddSingleton(blogAclClient); services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs index 82746c49..9da4a685 100644 --- a/src/PostIt/PostIt/Services/ContactService.Desktop.cs +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -1,27 +1,72 @@ #if !ANDROID && !IOS using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; namespace PostIt.Services; /// -/// Desktop stub for IContactService. +/// Desktop implementation of backed +/// by the central /api/user-search endpoint +/// (). /// -/// On desktop targets (Linux, macOS, Windows) MAUI Essentials -/// Contacts.Default throws NotImplementedInReferenceAssemblyException, -/// so we short-circuit with an empty list rather than trying to -/// call into the portable facade at runtime. +/// Desktop has no equivalent of the mobile address book +/// (no Contacts.Default, no CardDAV out of the box), so the +/// address book is built on demand from the Yavsc user table. +/// Results are accumulated in an in-memory cache exposed as +/// ; the cache is process-lifetime only +/// — there's no persistence layer. /// -/// Future provider plug-ins (Google Contacts API, Exchange EWS, -/// CardDAV) can either replace this stub on a per-OS basis or -/// live behind their own IContactService implementation that the -/// DI container selects by configuration. +/// This is the consumer that closes the loop with the +/// user-search endpoint landed on the server in commit 6 +/// (b3056f1c) and the client in commit 7 +/// (6e7e0414). /// public sealed class ContactService : IContactService { + private readonly UserSearchClient _client; + + public ObservableCollection Contacts { get; } = new(); + + public ContactService(UserSearchClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + public Task> GetDeviceContactsAsync(CancellationToken ct = default) - => Task.FromResult>(Array.Empty()); + => Task.FromResult>(Contacts.ToArray()); + + public async Task SearchAsync(string query, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) + { + // Clear the cache to mirror an empty result. The + // address-book UX treats an empty query as "start + // over". + Contacts.Clear(); + return; + } + + var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); + if (results is null) return; + + // Append the search results to the cache. We don't + // de-dupe across searches — the simplest behaviour, and + // matches what users expect from a search panel ("show + // me what came back"). Callers wanting a single list + // can re-render Contacts on the next query. + foreach (var u in results) + { + Contacts.Add(new ContactDto( + Id: u.Id, + DisplayName: u.FullName ?? u.UserName, + Email: u.Email)); + } + } } -#endif +#endif \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs index 744fb9a6..d3eb8a10 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -1,6 +1,7 @@ #if ANDROID || IOS using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; @@ -24,6 +25,8 @@ namespace PostIt.Services; /// public sealed class ContactService : IContactService { + public ObservableCollection Contacts { get; } = new(); + public async Task> GetDeviceContactsAsync(CancellationToken ct = default) { if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) @@ -38,21 +41,18 @@ public sealed class ContactService : IContactService var contacts = await Contacts.Default.GetAllAsync(); if (contacts is null) return Array.Empty(); - var result = new List(); + // Flatten the per-contact email list down to one + // primary email. The platform-neutral ContactDto only + // carries one; the use case ("invite / add to a + // circle") only needs one. The first non-empty entry + // wins. + Contacts.Clear(); foreach (var c in contacts) { - var emails = new List(); - if (c.Emails is not null) - { - foreach (var e in c.Emails) - { - if (!string.IsNullOrEmpty(e.EmailAddress)) - emails.Add(e.EmailAddress); - } - } - result.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, emails)); + var email = FlattenPrimaryEmail(c.Emails); + Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email)); } - return result; + return Contacts.ToArray(); } catch (Exception ex) { @@ -60,5 +60,22 @@ public sealed class ContactService : IContactService return Array.Empty(); } } + + public Task SearchAsync(string query, CancellationToken ct = default) + => throw new PlatformNotSupportedException( + "SearchAsync is not supported on mobile — use GetDeviceContactsAsync " + + "to load the local address book. The network search lives on the " + + "desktop service, which queries the central user-search endpoint."); + + private static string? FlattenPrimaryEmail(IEnumerable? emails) + { + if (emails is null) return null; + foreach (var e in emails) + { + if (!string.IsNullOrEmpty(e.EmailAddress)) + return e.EmailAddress; + } + return null; + } } -#endif +#endif \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs index 8c6da44a..4f0ba102 100644 --- a/src/PostIt/PostIt/Services/IContactService.cs +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; namespace PostIt.Services; /// -/// Abstraction over device contact providers (MAUI Essentials on mobile, -/// future Google/Exchange/IMAP providers). +/// Abstraction over device contact providers (MAUI Essentials on +/// mobile, the central /api/user-search endpoint on desktop). /// /// Implementations live next to this file in platform-conditional /// source files: ContactService.Mobile.cs (ANDROID/IOS) and @@ -15,15 +16,46 @@ namespace PostIt.Services; /// public interface IContactService { + /// + /// Returns the contacts known so far. On mobile this is the + /// full device address book (after permission grant); on + /// desktop this is the in-memory cache populated by previous + /// calls — empty until the user + /// has searched for something. + /// Task> GetDeviceContactsAsync(CancellationToken ct = default); + + /// + /// On desktop: hits GET /api/user-search?q=… and + /// appends matching users to the in-memory cache exposed via + /// . On mobile: throws + /// — the mobile + /// provider uses the device-local address book, not a + /// network search. + /// + Task SearchAsync(string query, CancellationToken ct = default); + + /// + /// Live view of the in-memory contact cache. UI binds to + /// this directly for a \"search results\" panel; on mobile + /// implementations this is populated eagerly by + /// . + /// + ObservableCollection Contacts { get; } } /// -/// Platform-neutral contact DTO. Source-of-truth shape for the UI layer; -/// concrete providers (MAUI Essentials today, Google Contacts API later) -/// map to this type. +/// Platform-neutral contact DTO. Source-of-truth shape for the UI +/// layer; concrete providers (MAUI Essentials on mobile, +/// UserSearchClient on desktop) map to this type. +/// +/// Email is a single string on purpose: the central +/// search endpoint returns one email per user, and the UI use +/// case is \"pick someone to invite / add to a circle\", which +/// never needs more than one. Multi-email contacts on mobile +/// flatten to the primary address (first non-empty). /// public sealed record ContactDto( string Id, string DisplayName, - IReadOnlyList Emails); + string? Email); \ No newline at end of file From dd8cb60fb4861e4b453f876460b78b9c69fb591b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 01:01:22 +0100 Subject: [PATCH 17/18] Forgejo badges --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index d1ed912a..9747c9af 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé. +# Statut actuel des actions Forgejo + +![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg) + +![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg) + # Statut actuel des actions GitHub * [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) From 04a31709a2d562c32134134b079cdfd5ae2dc16f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 13:22:54 +0100 Subject: [PATCH 18/18] refactor(postit): split IContactService from IUserDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IContactService used to be the catch-all for "people you can reach from PostIt": on mobile it read the device-local address book, on desktop it queried the central /api/user-search endpoint and merged both worlds into a single ContactDto (a flat Email field, an ObservableCollection cache, a SearchAsync method). Two unrelated flows under the same name, with a wire shape (Email) silently flattening the mobile provider's multi-email list. Split into two interfaces, each with a single responsibility: - IContactService: device-local address book only. Mobile provider reads MAUI Essentials Contacts.Default and carries the full email list per contact. Desktop provider is an honest stub returning an empty list — the desktop has no local address book, and inviting external people from desktop is a separate flow (manual email entry + invitation endpoint) that doesn't belong here. - IUserDirectory: central Yavsc user directory, the only consumer of /api/user-search. Both Desktop and Mobile providers delegate to UserSearchClient; the platform split exists so future platform-specific sources (offline cache, directory-scoped providers) can plug in without disturbing consumers. ContactDto restores IReadOnlyList Emails (the flat Email from d0e0f4c1 was a regression that matched the wire shape of /api/user-search at the cost of the mobile provider's per-contact list). UserSummary is a separate platform-neutral record that mirrors the server's UserSearchResultDto without leaking transport concerns. App.axaml.cs registers both interfaces as singletons. Build + 51/51 PostIt.Tests green. No UI consumer yet — these interfaces are still plomberie; the ViewModel that joins them for the "add to a circle" / "invite someone" flows is a follow-up. --- src/PostIt/PostIt/App.axaml.cs | 4 +- .../PostIt/Services/ContactService.Desktop.cs | 76 +++++-------------- .../PostIt/Services/ContactService.Mobile.cs | 65 ++++++++-------- src/PostIt/PostIt/Services/IContactService.cs | 74 +++++++++--------- src/PostIt/PostIt/Services/IUserDirectory.cs | 67 ++++++++++++++++ .../PostIt/Services/UserDirectory.Desktop.cs | 52 +++++++++++++ .../PostIt/Services/UserDirectory.Mobile.cs | 49 ++++++++++++ 7 files changed, 259 insertions(+), 128 deletions(-) create mode 100644 src/PostIt/PostIt/Services/IUserDirectory.cs create mode 100644 src/PostIt/PostIt/Services/UserDirectory.Desktop.cs create mode 100644 src/PostIt/PostIt/Services/UserDirectory.Mobile.cs diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index c5ab68e2..6f93edf9 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,7 +60,8 @@ public partial class App : Application var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(userSearchClient); + var contactService = new ContactService(); + var userDirectory = new UserDirectory(userSearchClient); var services = new ServiceCollection(); @@ -91,6 +92,7 @@ public partial class App : Application services.AddSingleton(blogAclClient); services.AddSingleton(userSearchClient); services.AddSingleton(contactService); + services.AddSingleton(userDirectory); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/PostIt/PostIt/Services/ContactService.Desktop.cs b/src/PostIt/PostIt/Services/ContactService.Desktop.cs index 9da4a685..fa7d37f6 100644 --- a/src/PostIt/PostIt/Services/ContactService.Desktop.cs +++ b/src/PostIt/PostIt/Services/ContactService.Desktop.cs @@ -1,72 +1,36 @@ #if !ANDROID && !IOS using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; namespace PostIt.Services; /// -/// Desktop implementation of backed -/// by the central /api/user-search endpoint -/// (). +/// Desktop stub for . /// -/// Desktop has no equivalent of the mobile address book -/// (no Contacts.Default, no CardDAV out of the box), so the -/// address book is built on demand from the Yavsc user table. -/// Results are accumulated in an in-memory cache exposed as -/// ; the cache is process-lifetime only -/// — there's no persistence layer. +/// The desktop has no equivalent of the mobile address +/// book (no Contacts.Default, no CardDAV out of the +/// box). Rather than synthesise a list from a different +/// source, this provider returns an empty list and lets the +/// UI render an honest "no local contacts on this platform" +/// message. /// -/// This is the consumer that closes the loop with the -/// user-search endpoint landed on the server in commit 6 -/// (b3056f1c) and the client in commit 7 -/// (6e7e0414). +/// If desktop users want to invite people who aren't +/// Yavsc members, that flow goes through a separate path +/// (manual email entry + invitation endpoint) — not through +/// . Finding existing Yavsc +/// members is 's job, not this +/// one's. +/// +/// Future CardDAV / Google Contacts / Exchange +/// providers can plug in here as additional +/// implementations selected +/// from DI by configuration. /// public sealed class ContactService : IContactService { - private readonly UserSearchClient _client; - - public ObservableCollection Contacts { get; } = new(); - - public ContactService(UserSearchClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - public Task> GetDeviceContactsAsync(CancellationToken ct = default) - => Task.FromResult>(Contacts.ToArray()); - - public async Task SearchAsync(string query, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(query)) - { - // Clear the cache to mirror an empty result. The - // address-book UX treats an empty query as "start - // over". - Contacts.Clear(); - return; - } - - var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); - if (results is null) return; - - // Append the search results to the cache. We don't - // de-dupe across searches — the simplest behaviour, and - // matches what users expect from a search panel ("show - // me what came back"). Callers wanting a single list - // can re-render Contacts on the next query. - foreach (var u in results) - { - Contacts.Add(new ContactDto( - Id: u.Id, - DisplayName: u.FullName ?? u.UserName, - Email: u.Email)); - } - } + => Task.FromResult>(Array.Empty()); } -#endif \ No newline at end of file +#endif diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs index d3eb8a10..8dbd134d 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -1,7 +1,6 @@ #if ANDROID || IOS using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; @@ -11,22 +10,23 @@ using Microsoft.Maui.Devices; namespace PostIt.Services; /// -/// Mobile implementation backed by MAUI Essentials Contacts.Default. +/// Mobile implementation backed by MAUI Essentials +/// Contacts.Default. /// -/// Compiled only for ANDROID and IOS. On desktop targets, see -/// ContactService.Desktop.cs (the stub that wins at compile time). +/// Compiled only for ANDROID and IOS. On desktop targets, +/// see ContactService.Desktop.cs (the stub that wins at +/// compile time). /// -/// Note: at runtime, this class throws -/// NotImplementedInReferenceAssemblyException unless the host -/// application project also references the platform-specific -/// Microsoft.Maui.Essentials implementation (typically the -/// PostIt.Android project). On iOS the same is required via -/// PostIt.iOS. On desktop the stub is used and this file is excluded. +/// Note: at runtime, this class throws +/// NotImplementedInReferenceAssemblyException unless +/// the host application project also references the +/// platform-specific Microsoft.Maui.Essentials implementation +/// (typically PostIt.Android). On iOS the same is +/// required via PostIt.iOS. On desktop the stub is used +/// and this file is excluded. /// public sealed class ContactService : IContactService { - public ObservableCollection Contacts { get; } = new(); - public async Task> GetDeviceContactsAsync(CancellationToken ct = default) { if (DeviceInfo.Current.Platform == DevicePlatform.Unknown) @@ -41,18 +41,24 @@ public sealed class ContactService : IContactService var contacts = await Contacts.Default.GetAllAsync(); if (contacts is null) return Array.Empty(); - // Flatten the per-contact email list down to one - // primary email. The platform-neutral ContactDto only - // carries one; the use case ("invite / add to a - // circle") only needs one. The first non-empty entry - // wins. - Contacts.Clear(); + // Carry the per-contact email list as-is. A real + // device contact can carry several addresses (home / + // work / other); the UI use case ("invite / add to a + // circle") can then decide which address to use, or + // let the user pick. The platform-neutral ContactDto + // shape is intentionally richer than the Yavsc + // directory's single-Email shape — the two flows + // answer different questions. + var result = new List(contacts.Count); foreach (var c in contacts) { - var email = FlattenPrimaryEmail(c.Emails); - Contacts.Add(new ContactDto(c.Id, c.DisplayName ?? string.Empty, email)); + var emails = ExtractEmails(c.Emails); + result.Add(new ContactDto( + c.Id, + c.DisplayName ?? string.Empty, + emails)); } - return Contacts.ToArray(); + return result; } catch (Exception ex) { @@ -61,21 +67,16 @@ public sealed class ContactService : IContactService } } - public Task SearchAsync(string query, CancellationToken ct = default) - => throw new PlatformNotSupportedException( - "SearchAsync is not supported on mobile — use GetDeviceContactsAsync " + - "to load the local address book. The network search lives on the " + - "desktop service, which queries the central user-search endpoint."); - - private static string? FlattenPrimaryEmail(IEnumerable? emails) + private static IReadOnlyList ExtractEmails(IEnumerable? emails) { - if (emails is null) return null; + if (emails is null) return Array.Empty(); + var list = new List(); foreach (var e in emails) { if (!string.IsNullOrEmpty(e.EmailAddress)) - return e.EmailAddress; + list.Add(e.EmailAddress); } - return null; + return list; } } -#endif \ No newline at end of file +#endif diff --git a/src/PostIt/PostIt/Services/IContactService.cs b/src/PostIt/PostIt/Services/IContactService.cs index 4f0ba102..49ca3064 100644 --- a/src/PostIt/PostIt/Services/IContactService.cs +++ b/src/PostIt/PostIt/Services/IContactService.cs @@ -1,61 +1,57 @@ -using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; namespace PostIt.Services; /// -/// Abstraction over device contact providers (MAUI Essentials on -/// mobile, the central /api/user-search endpoint on desktop). +/// Abstraction over the device-local address book. Used by +/// the "invite someone" flow to enumerate people the user +/// already has in their phone — including people who have +/// never heard of Yavsc. /// -/// Implementations live next to this file in platform-conditional -/// source files: ContactService.Mobile.cs (ANDROID/IOS) and -/// ContactService.Desktop.cs (everything else). +/// Distinct from , which +/// reads the central Yavsc user table. A device contact may +/// not have a Yavsc account; a directory entry always does. +/// The two are exposed as separate interfaces so a UI that +/// needs both can take both by constructor injection and +/// present them under separate sections (e.g. "Contacts from +/// your phone" vs "Yavsc members"). +/// +/// Implementations live next to this file in +/// platform-conditional source files: +/// ContactService.Mobile.cs (ANDROID/IOS) and +/// ContactService.Desktop.cs (everything else). On +/// desktop the implementation is a stub that returns an +/// empty list: the desktop has no equivalent of the mobile +/// address book, and inviting from a desktop is a separate +/// flow. /// public interface IContactService { /// - /// Returns the contacts known so far. On mobile this is the - /// full device address book (after permission grant); on - /// desktop this is the in-memory cache populated by previous - /// calls — empty until the user - /// has searched for something. + /// Read the device address book. Returns the contacts + /// known to the local provider; on desktop (no local + /// provider) this is always an empty list. /// Task> GetDeviceContactsAsync(CancellationToken ct = default); - - /// - /// On desktop: hits GET /api/user-search?q=… and - /// appends matching users to the in-memory cache exposed via - /// . On mobile: throws - /// — the mobile - /// provider uses the device-local address book, not a - /// network search. - /// - Task SearchAsync(string query, CancellationToken ct = default); - - /// - /// Live view of the in-memory contact cache. UI binds to - /// this directly for a \"search results\" panel; on mobile - /// implementations this is populated eagerly by - /// . - /// - ObservableCollection Contacts { get; } } /// -/// Platform-neutral contact DTO. Source-of-truth shape for the UI -/// layer; concrete providers (MAUI Essentials on mobile, -/// UserSearchClient on desktop) map to this type. +/// Platform-neutral contact DTO. Source-of-truth shape for +/// the UI layer; concrete providers (MAUI Essentials on +/// mobile) map to this type. /// -/// Email is a single string on purpose: the central -/// search endpoint returns one email per user, and the UI use -/// case is \"pick someone to invite / add to a circle\", which -/// never needs more than one. Multi-email contacts on mobile -/// flatten to the primary address (first non-empty). +/// Emails is a list on purpose: a real device +/// contact may carry several addresses (home / work / other). +/// The UI use case ("invite / add to a circle") can then +/// decide which address to use, or let the user pick. This +/// is intentionally richer than the Yavsc directory's +/// single-Email shape — the two flows answer different +/// questions and shouldn't be flattened onto the same +/// wire. /// public sealed record ContactDto( string Id, string DisplayName, - string? Email); \ No newline at end of file + IReadOnlyList Emails); diff --git a/src/PostIt/PostIt/Services/IUserDirectory.cs b/src/PostIt/PostIt/Services/IUserDirectory.cs new file mode 100644 index 00000000..7d4c1eb4 --- /dev/null +++ b/src/PostIt/PostIt/Services/IUserDirectory.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +/// +/// Abstraction over the central Yavsc user directory. Used by +/// the "add to a circle" flow to find Yavsc users by display +/// name or email. +/// +/// Distinct from , which +/// reads the device-local address book. A Yavsc user +/// directory entry is always a registered account; a device +/// contact may be anyone in the user's phone — including +/// people who have never heard of Yavsc. +/// +/// Implementations live next to this file in +/// platform-conditional source files: +/// UserDirectory.Desktop.cs and +/// UserDirectory.Mobile.cs. Both currently delegate to +/// UserSearchClient (the central /api/user-search +/// endpoint); the split exists so future platform-specific +/// sources (offline cache, directory-scoped providers) can be +/// plugged in without disturbing the consumer. +/// +public interface IUserDirectory +{ + /// + /// Search the directory by display name (substring) and/or + /// email (exact). + /// + /// Substring filter on the user's + /// display name. Empty or whitespace short-circuits to an + /// empty list (matches the client UX of "type to search", + /// not "show me a directory"). + /// Cancellation token. + /// A flat list of matching directory entries. + /// Never null; may be empty. + Task> SearchAsync(string query, CancellationToken ct = default); +} + +/// +/// Platform-neutral summary of a Yavsc directory entry. Mirrors +/// the wire shape of /api/user-search (see +/// UserSearchResultDto) but expressed in terms that +/// don't leak transport concerns. +/// +/// Kept as a record on purpose: directory entries are +/// immutable snapshots from the server, so structural equality +/// makes "did the user already pick this one?" trivial. +/// +public sealed record UserSummary( + string Id, + string UserName, + string? FullName, + string? Avatar, + string? Email) +{ + /// + /// Convenience for "what to show in a picker". Falls back + /// to when + /// is null or empty. + /// + public string DisplayName => + string.IsNullOrWhiteSpace(FullName) ? UserName : FullName; +} diff --git a/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs b/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs new file mode 100644 index 00000000..c821bb87 --- /dev/null +++ b/src/PostIt/PostIt/Services/UserDirectory.Desktop.cs @@ -0,0 +1,52 @@ +#if !ANDROID && !IOS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client; + +namespace PostIt.Services; + +/// +/// Desktop implementation of . +/// Delegates to the central /api/user-search endpoint +/// via . +/// +/// The desktop has no device-local address book, so the +/// "add to a circle" flow on desktop is Yavsc-users-only. +/// Inviting someone who doesn't have a Yavsc account from +/// desktop is a separate feature (manual email entry + +/// invitation endpoint) and lives outside this interface. +/// +public sealed class UserDirectory : IUserDirectory +{ + private readonly UserSearchClient _client; + + public UserDirectory(UserSearchClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public async Task> SearchAsync( + string query, CancellationToken ct = default) + { + // UserSearchClient already short-circuits on empty + // queries, but do it here too so the contract is + // obvious to anyone reading IUserDirectory alone + // without having to chase the client wrapper. + if (string.IsNullOrWhiteSpace(query)) + return Array.Empty(); + + var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); + if (results is null) return Array.Empty(); + + return results.Select(u => new UserSummary( + Id: u.Id, + UserName: u.UserName, + FullName: u.FullName, + Avatar: u.Avatar, + Email: u.Email)).ToList(); + } +} +#endif diff --git a/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs b/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs new file mode 100644 index 00000000..5cba6e4a --- /dev/null +++ b/src/PostIt/PostIt/Services/UserDirectory.Mobile.cs @@ -0,0 +1,49 @@ +#if ANDROID || IOS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client; + +namespace PostIt.Services; + +/// +/// Mobile implementation of . +/// Same backing as the desktop provider (the central +/// /api/user-search endpoint via +/// ) — mobile devices have the +/// network too, and "add to a circle" needs the same directory +/// regardless of platform. +/// +/// The split exists so a future mobile-only provider +/// (offline cache, device-local mirror of the user's own +/// circles) can be plugged in without touching consumers. +/// +public sealed class UserDirectory : IUserDirectory +{ + private readonly UserSearchClient _client; + + public UserDirectory(UserSearchClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public async Task> SearchAsync( + string query, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) + return Array.Empty(); + + var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false); + if (results is null) return Array.Empty(); + + return results.Select(u => new UserSummary( + Id: u.Id, + UserName: u.UserName, + FullName: u.FullName, + Avatar: u.Avatar, + Email: u.Email)).ToList(); + } +} +#endif