Fusionner la reparation du publish et le correctif NettoyageIsbd

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 02:44:07 +02:00
co-authored by Claude Opus 5
6 changed files with 81 additions and 49 deletions
+22 -16
View File
@@ -107,7 +107,7 @@ de la fonctionnalité.
- `RGBLuminanceSource` accepte directement le buffer RGBA du canvas (`BitmapFormat.RGBA32`) — **aucune bibliothèque d'image nécessaire** (pas de SkiaSharp ni ImageSharp).
- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr). Corollaire vérifié : tester le scan depuis un téléphone en pointant l'IP locale du PC (`http://192.168.x.x`) **échouera toujours** — ce n'est pas un contexte sécurisé. Le composant détecte ce cas et le dit explicitement.
- **Une `DOMException` perd son `name` en traversant le pont JS→C#** : `getUserMedia` refusé remonte en C# sous la forme « Permission denied undefined », sans `NotAllowedError`. Or c'est ce nom qui distingue « permission refusée » de « pas de caméra » de « caméra occupée ». Le JS doit donc **attraper l'erreur et renvoyer un code de statut** ; parser le message côté C# ne marche pas.
- **L'import du module JS doit porter une chaîne de requête** (`./js/scanner-camera.js?m=1`). En développement, l'import map généré par Blazor réécrit le chemin vers un nom empreinté que l'API hôte ne sert pas (elle utilise `UseStaticFiles`, qui ignore les points d'entrée empreintés) : l'import part en 404 et le scan ne démarre jamais. La chaîne de requête empêche cette réécriture ; en publication rien n'est réécrit pour ce fichier, donc le comportement est identique.
- **L'import du module JS se fait sur un chemin nu** (`./js/scanner-camera.js`). Il a longtemps porté une chaîne de requête `?m=1` : l'import map généré par Blazor réécrivait le chemin vers un nom empreinté que l'API hôte ne servait pas (elle utilise `UseStaticFiles`, qui ignore les points d'entrée empreintés), et l'import partait en 404. Depuis que les empreintes WASM sont désactivées (voir « Empreintes WASM désactivées »), **il n'y a plus d'import map du tout** et le contournement a été retiré. Vérifié en développement et sur le publish self-contained : le module se charge dans les deux cas.
### Squelette validé
@@ -259,29 +259,35 @@ dotnet publish MaBibli.Api -c Release -r linux-x64 --self-contained
Le self-contained embarque le runtime .NET dans le binaire : **aucun `dotnet-runtime` à installer** côté serveur, pas de conflit de versions. C'est le modèle de `radarr_ynh`.
### ⚠️ BLOQUANT CONNU : le publish de l'API produit un `index.html` cassé
### Empreintes WASM désactivées — pourquoi (ex-bloquant du publish, résolu le 2026-08-18)
**Constaté le 2026-08-18, non résolu. À corriger impérativement avant tout packaging YunoHost.**
**Ne pas réactiver `OverrideHtmlAssetPlaceholders` ni les empreintes WASM sans relire ce qui suit.**
`dotnet publish MaBibli.Api -c Release` produit un `wwwroot/index.html` dont les placeholders ne sont **pas** substitués :
Le `index.html` de `MaBibli.Client` ne contient **ni import map, ni placeholder `#[.{fingerprint}]`** : il référence directement `_framework/blazor.webassembly.js`. C'est délibéré, et `MaBibli.Client.csproj` porte `<WasmFingerprintAssets>false</WasmFingerprintAssets>` pour que les fichiers servis portent bien ces noms stables.
```html
<script type="importmap"></script> <!-- vide -->
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"> <!-- littéral -->
```
#### Le symptôme d'origine
Or seuls les noms **empreintés** existent sur disque (`blazor.webassembly.958z1vx7fr.js`). Résultat vérifié en exécutant le binaire publié : la racine répond **200**, mais le script de démarrage et `_framework/dotnet.js` renvoient **404**. L'application publiée affiche une **page blanche** — elle ne démarre pas.
`dotnet publish MaBibli.Api -c Release` produisait un `wwwroot/index.html` aux placeholders **non substitués** (`<script type="importmap"></script>` vide, `src="_framework/blazor.webassembly#[.{fingerprint}].js"` littéral), alors que seuls les noms empreintés existaient sur disque. La racine répondait **200**, le script de démarrage **404**, l'application publiée restait **blanche**. `dotnet publish MaBibli.Client` seul, lui, produisait un `index.html` correct.
`dotnet publish MaBibli.Client` seul produit au contraire un `index.html` correct : le défaut vient de l'hébergement du client par l'API, qui embarque le `wwwroot` de *build* du client plutôt que son `wwwroot` *publié*.
#### La cause réelle (SDK .NET 10.0.300)
**Piège de diagnostic** : vérifier que `/` renvoie 200 ne prouve rien — c'est ce qui a fait passer le défaut inaperçu à la phase 1. Il faut vérifier les scripts que `index.html` référence réellement, ou charger la page dans un navigateur.
Elle est dans `Microsoft.NET.Sdk.StaticWebAssets.HtmlAssetPlaceholders.targets`. La cible de **publication** `GenerateHtmlAssetPlaceholdersPublishStaticWebAssets` reçoit la liste des fichiers HTML à réécrire via `HtmlFiles="@(_HtmlStaticWebAssets)"` — or cet item n'est **jamais alimenté par le chemin de publication** : il l'est uniquement par la cible de **build** `ResolveHtmlAssetPlaceholdersBuildConfiguration`.
Pistes déjà essayées **sans succès** (inutile de les retenter à l'identique) :
- `<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>` sur `MaBibli.Api` ;
- `<WasmFingerprintAssets>false</WasmFingerprintAssets>` sur le client — donne bien un `dotnet.js` stable, mais `blazor.webassembly.js` reste empreinté car il provient des assets du package framework ;
- `<StaticWebAssetsFingerprintContent>false</StaticWebAssetsFingerprintContent>` sur les deux projets.
Quand on publie le client seul, le build a tourné dans la même instance MSBuild, l'item est rempli, la réécriture a lieu. Quand c'est l'**API** qui publie, elle demande au projet client ses assets de publication dans une instance où la cible de build n'a **pas** tourné : `@(_HtmlStaticWebAssets)` est vide, la tâche de réécriture ne produit **aucun fichier**, aucun asset HTML calculé n'entre dans `staticwebassets.publish.json` — et c'est alors le fichier source `MaBibli.Client/wwwroot/index.html` qui est recopié tel quel par `ComputeResolvedFilesToPublishList`, placeholders compris. Vérifié : le manifeste de publication de l'API ne contient **aucun** asset `.html`.
Le développement n'est pas affecté : `dotnet run --project MaBibli.Api` fonctionne normalement.
C'est pour cela que `<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>` sur l'API ne changeait rien : la cible s'exécutait bien, mais sur une liste vide.
#### La correction retenue
Supprimer le besoin de réécriture plutôt que réparer la réécriture : sans import map ni placeholder, il n'y a plus rien à substituer, et le fichier source recopié tel quel est déjà le bon.
- `<WasmFingerprintAssets>false</WasmFingerprintAssets>` sur le client ;
- **pas** de `OverrideHtmlAssetPlaceholders` (c'est lui qui, à `true`, force `BlazorFingerprintBlazorJs` et empreinte `blazor.webassembly.js` — la tentative précédente n'avait échoué que parce que les deux propriétés étaient combinées) ;
- `index.html` : `<script src="_framework/blazor.webassembly.js">`, sans `<script type="importmap">` ni `<link rel="preload" id="webassembly">` (deux balises que seule la machinerie de placeholders sait remplir).
Le cache-busting reste assuré par le service worker, qui compare les empreintes de `service-worker-assets.js`.
**Piège de diagnostic à conserver** : vérifier que `/` renvoie 200 ne prouve rien — c'est ce qui a fait passer le défaut inaperçu à la phase 1. Il faut vérifier les scripts que `index.html` référence **réellement**, ou charger la page dans un navigateur.
### Deux dépôts distincts
+34 -16
View File
@@ -128,7 +128,9 @@ public static partial class NettoyageIsbd
var pointRole = IndexDuPointDeRole(s);
if (pointRole >= 0)
{
s = s[..pointRole];
// Quand le rôle suit une initiale (« Cormen, Thomas H. Auteur du texte »), le point
// qui sert de séparateur appartient aussi au nom : on le conserve.
s = EstPointDInitiale(s, pointRole) ? s[..(pointRole + 1)] : s[..pointRole];
}
s = RetirerPonctuationFinale(s);
@@ -174,12 +176,7 @@ public static partial class NettoyageIsbd
while (s.EndsWith('.'))
{
var i = s.Length - 1;
var estInitiale = i >= 1
&& char.IsUpper(s[i - 1])
&& (i == 1 || s[i - 2] == ' ' || s[i - 2] == '-' || s[i - 2] == '\'');
if (estInitiale)
if (EstPointDInitiale(s, s.Length - 1))
{
break;
}
@@ -194,9 +191,15 @@ public static partial class NettoyageIsbd
/// Position du point qui introduit le rôle, ou -1.
/// </summary>
/// <remarks>
/// Un point suivi d'une espace sépare le rôle du nom. On ignore les points d'initiales
/// (« Cormen, Thomas H. Auteur ») : un point précédé d'une majuscule isolée n'est pas
/// un séparateur de rôle.
/// Un point suivi d'une espace sépare le rôle du nom. Le point d'une initiale
/// (« Cormen, Thomas <b>H.</b> ») n'en est pas un — le couper amputerait le prénom.
/// <para>
/// Mais protéger l'initiale ne doit pas protéger le rôle qui la suit : « Cormen, Thomas H.
/// Auteur du texte » renvoyait auparavant « Thomas H. Auteur du texte Cormen ». Ce qui
/// tranche : dans « Nom, Prénom », la seule chose qui puisse suivre une initiale et faire
/// encore partie du nom est <b>une autre initiale</b> (« Tolkien, J. R. R. »). Tout le
/// reste est le rôle.
/// </para>
/// </remarks>
private static int IndexDuPointDeRole(string s)
{
@@ -207,16 +210,31 @@ public static partial class NettoyageIsbd
continue;
}
var estInitiale = i >= 1
&& char.IsUpper(s[i - 1])
&& (i == 1 || s[i - 2] == ' ' || s[i - 2] == '-' || s[i - 2] == '\'');
if (!estInitiale)
if (EstPointDInitiale(s, i) && CommenceParUneInitiale(s, i + 2))
{
return i;
continue;
}
return i;
}
return -1;
}
/// <summary>
/// Le point en <paramref name="i"/> clôt-il une initiale (majuscule isolée) ?
/// </summary>
private static bool EstPointDInitiale(string s, int i)
=> i >= 1
&& char.IsUpper(s[i - 1])
&& (i == 1 || s[i - 2] == ' ' || s[i - 2] == '-' || s[i - 2] == '\'');
/// <summary>
/// Le texte à partir de <paramref name="debut"/> commence-t-il par une initiale
/// (une majuscule suivie d'un point ou de la fin de chaîne) ?
/// </summary>
private static bool CommenceParUneInitiale(string s, int debut)
=> debut < s.Length
&& char.IsUpper(s[debut])
&& (debut + 1 == s.Length || s[debut + 1] == '.');
}
@@ -67,13 +67,11 @@
private enum Etat { Demarrage, Actif, Erreur }
// Le « ?m=1 » n'est pas cosmétique. En développement, l'import map généré par Blazor
// fait pointer « ./js/scanner-camera.js » vers un nom empreinté que l'API hôte ne sert
// pas (elle utilise UseStaticFiles, qui ignore les points d'entrée empreintés) : l'import
// échoue en 404 et le scan ne démarre jamais. Une chaîne de requête empêche l'import map
// de réécrire le chemin ; en publication, aucune réécriture n'est générée pour ce fichier,
// donc le comportement est identique. Vérifié dans les deux modes.
private const string CheminModule = "./js/scanner-camera.js?m=1";
// Chemin nu : il n'y a plus d'import map depuis que les empreintes WASM sont désactivées
// (voir MaBibli.Client.csproj), donc plus de réécriture vers un nom empreinté que l'hôte
// ne servirait pas. Le contournement « ?m=1 » qui neutralisait cette réécriture n'a plus
// lieu d'être. Vérifié en développement et en publication.
private const string CheminModule = "./js/scanner-camera.js";
// ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps.
private const int IntervalleMs = 80;
+8 -1
View File
@@ -4,7 +4,14 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
<!--
Empreintes WASM désactivées, et donc pas d'import map ni de placeholders dans index.html.
Raison : quand c'est MaBibli.Api qui publie ce projet, le SDK .NET 10 n'exécute jamais la
réécriture des placeholders et recopie index.html brut (voir CLAUDE.md). Des noms de
fichiers stables suppriment le problème à la racine. Le cache-busting reste assuré par le
service worker, qui compare les empreintes de service-worker-assets.js.
-->
<WasmFingerprintAssets>false</WasmFingerprintAssets>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
</PropertyGroup>
+1 -3
View File
@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MaBibli</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
@@ -14,7 +13,6 @@
<link href="manifest.webmanifest" rel="manifest" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<script type="importmap"></script>
</head>
<body>
@@ -31,7 +29,7 @@
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
<script src="_framework/blazor.webassembly.js"></script>
<script>navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' });</script>
</body>
+11 -6
View File
@@ -85,12 +85,17 @@ public class NettoyageIsbdTests
public void Auteur_laisse_passer_ce_qui_est_deja_propre(string brut, string attendu)
=> Assert.Equal(attendu, NettoyageIsbd.Auteur(brut));
[Fact]
public void Auteur_ne_coupe_pas_sur_une_initiale()
{
// « H. » est une initiale, pas un séparateur de rôle : la couper amputerait le prénom.
Assert.Equal("Thomas H. Cormen", NettoyageIsbd.Auteur("Cormen, Thomas H."));
}
[Theory]
// Contre-exemples : le nom se termine légitimement par une initiale, il n'y a pas de rôle
// à retirer — couper au point amputerait le prénom.
[InlineData("Cormen, Thomas H.", "Thomas H. Cormen")]
[InlineData("Tolkien, J. R. R.", "J. R. R. Tolkien")]
// Cas réel : le rôle suit une initiale. Protéger le point de l'initiale ne doit pas
// protéger le rôle — sinon on obtient « Thomas H. Auteur du texte Cormen ».
[InlineData("Cormen, Thomas H. Auteur du texte", "Thomas H. Cormen")]
[InlineData("Tolkien, J. R. R. (1892-1973). Auteur du texte", "J. R. R. Tolkien")]
public void Auteur_distingue_une_initiale_d_un_role(string brut, string attendu)
=> Assert.Equal(attendu, NettoyageIsbd.Auteur(brut));
[Theory]
[InlineData("le Livre de poche (Paris)", "le Livre de poche")]