Modèles de données
Structures du domaine métier.
À définir avec les écrans
Certains modèles référencés dans navigation.md et extensions.md sont volontairement laissés à définir au moment d’implémenter leurs écrans, pour éviter la dette de spec pré-code :
UserProfile— livré en Phase 2 finish (#208) — voir section Profil utilisateur ci-dessous.UserStats— statistiques détaillées utilisateur (posts par cat, activité, topics créés). Nécessaire Phase 4 pour la feature “Stats utilisateur”.
TopicSummary est livré en Phase 1C-A pour le Forum et la liste des topics — voir la section Catégories et browsing ci-dessous.
Ces autres modèles émergeront du premier prototype de chaque écran. Pas de spec préventive à faire maintenant.
Profil utilisateur
Phase 2 finish (#208). Modèle UserProfile dans :core:model, parser dans :core:parser/profile/ProfileParser.kt. Champs fragiles nullables pour tolérance aux variations HFR.
data class UserProfile(
val userId: Int, // clé canonique — toujours non-null
val pseudo: String, // fallback sentinelle "?" documenté si toutes les sources sont vides
val avatarUrl: String?, // CDN HFR reconstruit depuis mesdiscussions-{N}.png
val registeredAt: String?, // format HFR brut "DD/MM/YYYY" — promotion Instant reportée
val postCount: Int?,
val location: String?, // ville HFR, null si vide ou absent
val signatureText: String?, // texte plat (Jsoup.text()) — round-trip BBCode hors scope MVP
val rawFields: Map<String, String> = emptyMap(), // champs non promus (Profession, Loisirs, …)
)
Post.profileId: Int? (Phase 2 finish #208) — id numérique HFR extrait depuis <a href="/hfr/profil-{N}.htm"> dans le toolbar de chaque post. Null pour les posts « Publicité » ou les reads anonymes sans lien profil. Persisté en Room v6 (MIGRATION_5_6). Clé canonique pour la navigation vers le profil — post.author et post.avatarUrl sont des hints d’affichage.
Vue d’ensemble
classDiagram
class Flag {
+Int cat
+Int? subcat
+Int topicId
+String title
+Int totalPages
+Int replyCount
+FlagType type
+Boolean hasUnread
+Int lastReadPage
+Long? lastPostReadId
+String firstPostAuthor
+String lastReplyAuthor
+String lastReplyAt
}
class Topic {
+Int cat
+Int post
+Int subcat
+String title
+List~Post~ posts
+Int page
+Int totalPages
+Boolean isFirstPostOwner
+Poll? poll
+Boolean canReply
}
class Post {
+Int numreponse
+String author
+Instant date
+PostContent content
+String? avatarUrl
+Boolean isEditable
+Boolean isOwnPost
+List~String~ quotedAuthors
+Int? postIndex
+Int? quoteRef
+Int? profileId
+Instant? editedAt
}
class PostContent {
+List~PostBlock~ blocks
}
class Category {
+Int id
+String name
+Boolean forceSubcat
+Int subcategoryCount
}
class SubCategory {
+Int id
+String name
+Int parentCategoryId
}
class TopicSummary {
+Int cat
+Int? subcat
+Int topicId
+String title
+String author
+String lastReplyAuthor
+String lastReplyAt
+Int replyCount
+Int totalPages
+Boolean isSticky
+Boolean isLocked
+Boolean? hasUnread
+Int? lastReadPage
+Int? lastPostReadId
}
class TopicListPage {
+Int cat
+Int? subcat
+Int page
+Int resultsPerPage
+Int totalTopics
+List~TopicSummary~ topics
}
class PrivateMessageSummary {
+Int threadId
+String correspondent
+String subject
+Instant date
+Boolean hasUnread
+Boolean isMultiRecipient
}
class PrivateMessageListPage {
+Int page
+Int totalPages
+List~PrivateMessageSummary~ items
}
class PrivateMessageThread {
+Int threadId
+String subject
+String correspondent
+List~Post~ messages
+Int page
+Int totalPages
+Boolean canReply
+Boolean isMultiRecipient
}
class AuthState {
<<sealed>>
}
class Authenticated {
+String pseudo
}
class Anonymous
Topic --> Post : contient
Post --> PostContent : rend
PrivateMessageListPage --> PrivateMessageSummary : contient
PrivateMessageThread --> Post : contient
Topic --> Poll : optionnel
SubCategory --> Category : enfant de
TopicListPage --> TopicSummary : contient
Flag --> FlagType : type
AuthState <|-- Anonymous
AuthState <|-- Authenticated
Authentification
État global de la session HFR exposé par AuthRepository (:core:domain). Phase 1B : alimenté par les cookies persistés (Option A — DataStore non chiffré + FBE plateforme, cf. ADR-002).
sealed interface AuthState {
data object Anonymous : AuthState
data class Authenticated(val pseudo: String) : AuthState
}
Erreurs typées remontées par AuthRepository.login() (Phase 1B) :
sealed class LoginError : Exception() {
data object InvalidCredentials : LoginError() // mauvais pseudo/password
data object RateLimited : LoginError() // anti-flood HFR
data class Network(override val cause: Throwable) : LoginError() // I/O, DNS, TLS, timeout
data class Unknown(val detail: String) : LoginError() // HTML inattendu, cookie manquant
}
L’UI (:feature:auth) traduit chaque variante en stringResource localisée — les messages techniques côté domain ne sont pas affichés tels quels.
Drapeaux
data class Flag(
val cat: Int,
val subcat: Int?,
val topicId: Int,
val title: String,
val totalPages: Int, // ceil(links.posts.count / posts_results_per_page) côté REST
val replyCount: Int, // max(links.posts.count - 1, 0) côté REST
val type: FlagType, // bucket DEMANDÉ au fetch, jamais dérivé de flag_owntopic (#384)
val isFavorite: Boolean, // décoration étoile : flag_owntopic == 3, indépendante du bucket
val hasUnread: Boolean, // !is_read côté REST ; defensive true quand is_read absent
val lastReadPage: Int, // links.posts.href?page=N côté REST
val lastPostReadId: Long?, // last_post_read_id côté REST — id du DERNIER post lu (≠ premier non lu)
val firstPostAuthor: String,
val lastReplyAuthor: String,
val lastReplyAt: String, // timestamp brut HFR REST ("YYYY-MM-DD HH:mm"), parsing reporté
)
enum class FlagType {
// Le type d'un Flag est TOUJOURS le bucket REST demandé au fetch
// (participated/read/favorites), jamais dérivé de flag_owntopic (#384 :
// le bucket participated renvoie aussi des lignes flag_owntopic=3).
CYAN, // bucket participated (« Mes sujets »)
RED, // bucket read (« Lus uniquement »)
FAVORITE, // bucket favorites (« Favoris »)
}
typevsisFavorite(#384 + suivi) :flag_owntopicdécrit le drapeau le plus fort SUR le sujet (3 = favori/étoile), pas le bucket d’appartenance — vérifié live (fixturerest_cat13_participated_favorites.json) : le bucket participated renvoie des sujets participés-ET-favoris avecflag_owntopic=3. Mapper ce champ verstypecorrompait le cache Room par type (#384).typereste donc le bucket (routage, filtres, clé de cache) ;isFavoritene porte que la décoration : la pastille d’un favori reste jaune dans « Mes sujets », quelle que soit la couleur du bucket (parité site, retour dev v118).
Phase 1D-1 — REST migration :
Flagn’est plus alimenté parforum1f.phpmais par les endpoints RESTforums/hardwarefr/topics/{participated,read,favorites}/(cf. ADR-003 etprotocol-hfr.md). Conséquences sur le modèle :
views(colonne « Lues » du HTML) est retiré : la REST ne l’expose pas. Plutôt queInt?-everywhere, le champ disparaît du modèle ; aucun consommateur UI n’en dépendait.firstUnreadPostId: Longest remplacé parlastPostReadId: Long?: la REST exposelast_post_read_id(id du dernier post lu, pas du premier non lu). Re-ancrer le scroll sur le dernier post lu reste un deep link utile sans inférer un premier-non-lu que la REST ne donne pas.nullquand le payload omet le champ.lastReplyAtest gardé enStringbrut au format REST (YYYY-MM-DD HH:mm) ; promotion enInstantreportée à un cas d’usage UI réel (tri par date, “il y a N minutes”).
Topics et Posts
data class Topic(
val cat: Int,
val post: Int,
val subcat: Int, // #213 : sous-cat de POST, lue sur l'input[name=subcat] du formulaire bddpost. subcat=0 est VALIDE et postable (catégorie sans sous-cat, ex. cat IA — capture live à l'appui). Sentinel SUBCAT_UNKNOWN=-1 quand aucun formulaire reply n'est présent (logged-out / prefetch anon / topic verrouillé / cache pré-MIGRATION_3_4) → lecture seule. Écriture gate sur subcat >= 0. Jamais transmis tel quel à HFR quand =-1.
val title: String,
val posts: List<Post>,
val page: Int,
val totalPages: Int,
val isFirstPostOwner: Boolean, // Phase 1 : figé à false par TopicPageParser tant que parseEditPage n'est pas livrée (Phase 2). Renseigné côté serveur via la page d'édition du FP.
val poll: Poll?,
val canReply: Boolean = false, // #213 : postabilité = présence du formulaire bddpost dans la page topic (rendu uniquement en session authentifiée sur topic non verrouillé). Remplace l'ancien heuristique hasSubcat (subcat > 0), qui excluait à tort les cats IA postables (subcat=0) et faisait confiance au subcat du widget de recherche capturé logged-out. Persisté en Room v7 (MIGRATION_6_7). Défaut false : rows pré-v7 / prefetch anon en lecture seule jusqu'au prochain fetch authentifié. Gate Répondre/Citer/Modifier/Modifier-1er-message.
) {
companion object { const val SUBCAT_UNKNOWN: Int = -1 }
}
data class Post(
val numreponse: Int, // unique par (cat), PAS globalement — clé composite (cat, numreponse) au niveau base
val author: String,
val date: Instant, // parsé depuis "dd-MM-yyyy à HH:mm:ss"
val content: PostContent, // AST sémantique, rendu par PostRenderer (cf. ADR-011)
val avatarUrl: String?,
val isEditable: Boolean, // Phase 2D (#147) : `true` quand la toolbar HFR du post expose un lien `<a href="…message.php?…&numreponse={post.numreponse}…">`. HFR ne le rend que pour les posts du compte authentifié sur un topic non verrouillé — on ne compare pas l'auteur localement. Persisté en Room depuis v1.
val isOwnPost: Boolean, // Phase 2D : équivalent à `isEditable` faute de signal HFR distinct au niveau topic page. Les deux champs restent séparés pour un futur raffinement (modo-can-edit, locked-but-own-post). Persisté en Room depuis v1.
val quotedAuthors: List<String>, // dérivé de PostContent pour recherche, filtres et décorateurs
val postIndex: Int?, // (page-1) * postsPerPage + position — null quand le parser n'a pas le contexte page/postsPerPage (preview, fixtures isolées). postsPerPage vient des préférences HFR de l'utilisateur, PAS une constante (voir UserSettings)
val quoteRef: Int? = null, // Phase 2C (#146/#227) : `ref` opaque parsé depuis le href du lien quote HFR (`message.php?…&numrep=…&ref=N`) quand il est en clair. Null = ref absent/obfusqué/locked/anonyme. Persisté en Room v5 (`MIGRATION_4_5`) pour préserver le `ref` best-effort ; le bouton « Citer » dépend de `Topic.canReply`, pas de ce champ.
val profileId: Int? = null, // Phase 2 finish (#208) : id numérique HFR du lien profil toolbar (cf. note en tête de page). Persisté en Room v6 (`MIGRATION_5_6`).
val editedAt: Instant? = null, // #362 : date de dernière édition parsée depuis le trailer `div.edited` (« Message édité par <auteur> le DD-MM-YYYY à HH:MM:SS »). Null = jamais édité — y compris un div.edited ne portant que le lien « Message cité N fois » (post cité jamais édité). Persisté en Room v8 (`MIGRATION_7_8`). Affiché dans le menu contextuel de post (« Édité le … »).
)
data class PostContent(
val blocks: List<PostBlock>,
)
sealed interface PostBlock {
data class Paragraph(val inlines: List<PostInline>) : PostBlock
data class Quote(
val author: String?,
val numreponse: Int?, // depuis [quotemsg=N,P,auteur], null si la source HTML ne l'expose pas
val page: Int?, // idem, sert à reconstruire un lien vers le post cité quand disponible
val content: PostContent,
) : PostBlock
data class Spoiler(val label: String?, val content: PostContent) : PostBlock
data class Image(val url: String, val description: String?) : PostBlock
data class Fixed(val text: String) : PostBlock // BBCode [fixed], rendu monospace plein largeur
data class CodeBlock(val text: String, val language: String?) : PostBlock // BBCode [code], language depuis <pre class="<lang>"> ; null si [code] sans hint. Phase 1 : aplatissement de la coloration syntaxique HFR (kw3/me1/st0/de1) en texte brut, coloration reportée Phase 2.
}
sealed interface PostInline {
data class Text(val value: String) : PostInline
data object LineBreak : PostInline // <br> nested dans un parent inline
data class Strong(val children: List<PostInline>) : PostInline
data class Emphasis(val children: List<PostInline>) : PostInline
data class Underline(val children: List<PostInline>) : PostInline
data class Strike(val children: List<PostInline>) : PostInline
data class Color(val colorHex: String, val children: List<PostInline>) : PostInline
data class Link(val url: String, val children: List<PostInline>) : PostInline
data class InlineImage(val url: String, val description: String?) : PostInline
data class Smiley(val kind: SmileyKind, val imageUrl: String?) : PostInline
}
sealed interface SmileyKind {
data class Builtin(val code: String) : SmileyKind // syntaxe HFR : :jap:, :o, :D
data class Perso(val name: String) : SmileyKind // syntaxe HFR : [:corran_horn]
}
PostContent est le contrat cible décrit par ADR-011. La dette de fragment HTML brut dans le slice topic fixe est résorbée par #80 ; les blocs monospace [fixed] / [code] sont parsés depuis Phase 1 via #79. PostInline.Color.colorHex conserve la couleur sous forme textuelle normalisée (#RRGGBB ou #AARRGGBB) pour préserver le round-trip BBCode HFR.
Création et édition
data class NewTopic(
val cat: Int,
val subcat: Int,
val subject: String,
val content: String,
val poll: PollData?,
)
data class FirstPostData(
val subject: String,
val content: String,
val poll: PollData?,
)
data class PollData(
val question: String,
val options: List<String>,
val multipleChoice: Boolean,
)
data class Poll(
val question: String,
val options: List<PollOption>,
val multipleChoice: Boolean,
val totalVotes: Int,
val hasVoted: Boolean,
)
data class PollOption(
val text: String,
val votes: Int,
val percentage: Float,
)
EditInfo est retourné par HfrParser.parseEditPage(html) (cf. architecture.md). Il capture l’état pré-rempli du formulaire d’édition HFR et ce qui doit être renvoyé côté bdd.php (cf. protocol-hfr.md).
data class EditInfo(
val cat: Int,
val post: Int, // ID topic
val numreponse: Int, // ID post édité (unique par cat)
val content: String, // BBCode brut pré-rempli dans le textarea
val isFirstPost: Boolean, // édition du premier post (FP) ?
val subject: String?, // non-null uniquement si isFirstPost
val subcat: Int?, // non-null uniquement si isFirstPost (change de sous-cat possible)
val poll: Poll?, // non-null si isFirstPost avec sondage existant
)
Catégories et browsing
Modèles consommés par :feature:forum (Phase 1C-A). Les sources sont REST JSON via HfrApiClient (:core:network) puis mappés depuis les DTO :core:data forum/RestForumDtos.kt (cf. ADR-003).
data class Category(
val id: Int,
val name: String,
val forceSubcat: Boolean, // mirrors REST `force_subcat`
val subcategoryCount: Int, // mirrors REST `number_of_subcategories` (le payload public n'a pas de bloc `links` côté catégorie)
)
data class SubCategory(
val id: Int,
val name: String,
val parentCategoryId: Int, // injecté côté mapper (pas dans le JSON brut)
)
data class TopicSummary(
val cat: Int,
val subcat: Int?, // déduit du endpoint ou de `links.subcategory.href`
val topicId: Int,
val title: String,
val author: String,
val lastReplyAuthor: String,
val lastReplyAt: String, // raw `YYYY-MM-DD HH:mm`, parsing reporté
val replyCount: Int, // max(links.posts.count - 1, 0)
val totalPages: Int, // ceil(links.posts.count / postsResultsPerPage), où postsResultsPerPage vient de `links.posts.href?results_per_page=N`. Pas de constante 40 globale (cf. § "postsPerPage configurable").
val isSticky: Boolean, // mirrors `is_sticky`
val isLocked: Boolean, // mirrors `is_closed`
val hasUnread: Boolean?, // !is_read si présent en auth, null sinon
val lastReadPage: Int?, // page extraite de `links.posts.href?page=N` (auth uniquement). PAS `last_position` qui est l'index intra-page.
val lastPostReadId: Int?, // mirrors `last_post_read_id` si présent — id du dernier post lu, ancre pour le scroll.
val flagType: FlagType?, // dérivé de REST `flag_owntopic` : 1→CYAN, 2→RED, 3→FAVORITE, sinon null. Indépendant de `hasUnread`.
)
data class TopicListPage(
val cat: Int,
val subcat: Int?,
val page: Int,
val resultsPerPage: Int,
val totalTopics: Int, // mirrors `results_count`
val topics: List<TopicSummary>,
)
Champs absents en REST : views n’est pas exposé en JSON — ne pas inventer 0, ne pas l’afficher. Le calcul de pages côté topic (TopicSummary.totalPages) utilise le results_per_page exposé par links.posts.href (typiquement 40 dans les fixtures, mais c’est l’API qui décide — 40 n’est pas une constante globale, cf. protocol-hfr.md § postsPerPage configurable pour la pagination HTML). Le results_per_page du wrapper REST englobe la liste de topics, distinct de celui imbriqué dans links.posts.href qui pagine les posts d’un topic.
Écriture HFR
Types vivant dans :core:model/write/ ; consommés par :core:parser/write/, :core:data/write/ et :feature:editor. Livrés en Phase 2C (#145) ; étendus plus tard pour Edit / Quote / Edit FP / Create.
data class ReplyContext(
val cat: Int,
val subcat: Int, // requis >= 0 ; `ReplyContext.init` refuse seulement le sentinel `SUBCAT_UNKNOWN` (-1). `0` est valide pour une catégorie sans sous-catégorie (cat IA, #213).
val topicId: Int,
val page: Int, // page topic depuis laquelle l'utilisateur a cliqué "Répondre"
val quotedNumreponse: Int? = null, // Phase 2C (#146) : numreponse cité ; null = reply simple, non-null = quote (HFR `numrep` query param + POST field)
val quoteRef: Int? = null, // Phase 2C (#146/#227) : ref opaque parsé depuis le href quote HFR quand disponible ; null = reply simple ou quote sans ref (lien obfusqué), HFR cite via `numrep`
) {
val isQuote: Boolean get() = quotedNumreponse != null
}
data class ReplyForm(
val hashCheck: String, // CSRF token HFR, jamais loggué
val sujet: String,
val hiddenFields: Map<String, String>, // password filtré au parse ; pseudo anonyme filtré
val isAnonymous: Boolean,
val initialContent: String = "", // Phase 2C (#146) : reply → "" ; quote → bloc `[quotemsg=…]` prérempli par HFR (verbatim, jamais reconstruit côté app)
)
data class EditFirstPostContext( // Phase 2D (#148) : édition du premier post d'un topic
val cat: Int,
val subcat: Int, // requis > 0
val topicId: Int, // requis > 0
val page: Int, // require page == 1 ; le FP vit toujours page 1 par définition
val numreponse: Int, // requis > 0 ; numreponse du premier post (≠ topicId)
)
data class TopicForm( // Phase 2D (#148) : forme topic-level du formulaire HFR
val hashCheck: String, // jamais loggué, jamais persisté Compose
val subject: String, // pré-rempli depuis `<input name="sujet">`
val initialContent: String, // BBCode existant via wholeText() de la textarea
val selectedSubcat: Int, // option HFR currently selected du `<select name="subcat">`
val subcategoryChoices: List<TopicFormSubcategoryChoice>,
val hiddenFields: Map<String, String>, // password + delete + champs poll filtrés ; checkboxes/radios suivent `checked`
val options: ReplyFormOptions, // signature / smileyDisabled / emailNotification
val msgIcon: String?, // icône `checked` (defense-in-depth source-of-truth)
val poll: TopicPollForm, // read-only en Phase 2D #148 ; champs sondage préservés verbatim
val isAnonymous: Boolean,
)
data class TopicFormSubcategoryChoice(
val id: Int?, // null pour « Aucune » — jamais soumis
val label: String,
val selected: Boolean,
)
data class TopicPollForm(
val present: Boolean, // `have_sondage` coché côté HFR
val fields: Map<String, String>, // have_sondage, textreponse0..10, allowvisitor, max_votes, jour/mois/annee/heure/minute
val editableInThisVersion: Boolean = false, // false en Phase 2D #148 — UI affiche note read-only
)
sealed interface ReplySubmitResult {
data class Success(
val refreshUrl: String?, // <meta http-equiv="Refresh" content="N; url=…">
val targetPage: Int?, // dérivé du shape sujet_X_Y.htm
val numreponse: Int? = null, // dérivé du fragment #t{N} ; quote / edit / edit-FP exposent
// le post id, reply pur anchor #bas et reste null (issue #200)
) : ReplySubmitResult
data class Failure(val reason: ReplyFailureReason) : ReplySubmitResult
}
sealed interface ReplyFailureReason {
data object EmptyMessage : ReplyFailureReason
data object InvalidHashCheck : ReplyFailureReason
data object AntiFlood : ReplyFailureReason
data object TopicLocked : ReplyFailureReason
data object LoginRequired : ReplyFailureReason // form anonyme servi par HFR
data object Unknown : ReplyFailureReason // réponse non reconnue ; pas de raw body conservé (cf. KDoc)
}
Le contrat HFR sous-jacent est documenté dans protocol-hfr.md § POST bddpost.php. Les ReplyFailureReason mappent un-à-un sur les fixtures write_*_error.html / write_*_response.html capturées en Phase 2A.
Messages privés
data class PrivateMessageSummary(
val threadId: Int, // HFR `post` id de la conversation `cat=prive`
val correspondent: String, // pseudo de l'autre participant
val subject: String,
val date: Instant, // dernière activité
val hasUnread: Boolean, // marker `closedbp.gif`
val isMultiRecipient: Boolean = false, // "Interlocuteurs multiples" (MultiMP / DT)
)
data class PrivateMessageListPage(
val page: Int,
val totalPages: Int,
val items: List<PrivateMessageSummary>,
)
data class PrivateMessageThread(
val threadId: Int,
val subject: String,
val correspondent: String,
val messages: List<Post>, // même structure HTML que les posts de topic
val page: Int,
val totalPages: Int,
val canReply: Boolean = false,
val isMultiRecipient: Boolean = false, // prouvé si ≥2 auteurs non-own distincts sur la page
)
data class NewMP(
val recipient: String,
val subject: String,
val content: String,
)
data class NewMultiMP(
val recipients: List<String>,
val subject: String,
val content: String,
)
Le MVP Phase 3 #298 couvre uniquement PrivateMessageSummary, PrivateMessageListPage et PrivateMessageThread pour lire les MPs classiques. NewMP, NewMultiMP, reply/quote MP, MultiMP et MPStorage restent dans la suite Phase 3.
MPStorage
MPStorage est une bibliothèque cross-plateforme (HFRGMTools/Wiripse, en production depuis ~2019) qui utilise un MP HFR dédié comme backend de stockage : sujet = hash fixe a2bcc09b796b8c6fab77058ff8446c34, destinataire = compte tiers MultiMP. Le premier post de ce MP contient un document JSON partagé par tous les userscripts (DTCloud pour les drapeaux DT, HFR4K, …). Redface 2 adopte l’enveloppe v0.1 de facto telle quelle — décision actée dans ADR-014 (accepté 2026-06-12, cf. exploration #6) : toute extension Redface 2 passe par de nouvelles clés additives dans l’entrée v0.1, jamais par un nouveau format — la compatibilité avec les userscripts existants est non négociable.
Enveloppe réelle (source : MPStorage.user.js + doc Wiripse, confrontées le 2026-06-10) :
{
"data": [
{
"version": "0.1",
"mpFlags": { "list": [ { "uri": "…", "post": 12345, "page": 3, "href": "t1980000001", "p": 2 } ] },
"hfr4k": { "…": "clés d'un autre outil, à préserver verbatim" }
}
],
"sourceName": "DTCloud",
"lastUpdate": 1718064000000
}
Modèles Kotlin (lecture seule, Phase 3) :
/**
* Document du premier post du MP storage. Parsing TOLÉRANT : seules les clés que
* Redface 2 consomme sont projetées ; le JSON intégral est conservé dans
* [rawEnvelope] pour le futur read-modify-write (écriture = full overwrite
* last-write-wins, les clés des autres outils doivent survivre au round-trip).
*/
data class MpStorageDocument(
val sourceName: String?, // dernier OUTIL écrivain (pas par-outil)
val mpFlags: List<MpStorageFlagEntry>, // section DTCloud, vide si absente
val rawEnvelope: String, // JSON intégral, jamais reconstruit champ à champ
)
/**
* Position de REPRISE DE LECTURE d'une conversation DT (≥ 3 pseudos) — ce n'est
* NI un lu/non-lu (le lu/non-lu MP est le dot serveur, cf. #361), NI un pinned.
*/
data class MpStorageFlagEntry(
val threadId: Int, // `post` côté wire
val page: Int,
val numreponse: Int?, // `href` = "t<numreponse>" côté wire
val uri: String?, // format desktop exact, relayé verbatim
)
Règles non négociables (exploration #6) :
- Préserver les clés inconnues : l’écriture MPStorage est un remplacement intégral sans verrou (last-write-wins) — perdre
hfr4kou toute clé tierce casserait les userscripts de l’utilisateur. - Jamais de reset destructif sur contenu invalide (le piège de la bibliothèque d’origine) : un document illisible = lecture en échec explicite, pas un écrasement par le défaut.
- Découverte = recherche authentifiée par sujet :
forum1.php?recherches=1&cat=prive&search=<hash>&titre=1(vérifié live 2026-06-11, fixturesmp_storage_search_*). L’absence de MP storage (compte n’ayant jamais utilisé DTCloud) est le cas nominal premier. - Lecture = GET du formulaire d’édition du premier post (
message.php?cat=prive&post=<mpId>&numreponse=<repId>), textareacontent_form(contenu brut, pas le HTML rendu). - Écriture (différée, opt-in) = POST
bdd.phpcat=priveen read-modify-write juste avant le POST, jamais une édition par page vue.
Paramètres utilisateur
UserSettings capture les réglages du compte HFR qui influencent le rendu côté client. Le parser lit ces valeurs depuis editprofil.php?page=3 à la connexion et les stocke en cache (Room + DataStore). Aucun champ ne doit être hardcodé dans le code applicatif — notamment postsPerPage (cf. protocol-hfr.md).
data class UserSettings(
val postsPerPage: Int, // 20 / 40 / 60 — réglable HFR, défaut 40
val showAvatars: Boolean, // affichage des avatars dans les topics
val showSignatures: Boolean, // affichage des signatures
val timezone: String, // ex: "Europe/Paris"
val language: String, // "fr" | "en"
)
Note : le modèle est volontairement minimaliste pour la Phase 1. Les réglages secondaires (thème CSS HFR, jeu d’icônes, notifications MP, notifications mots-clés, fuseau numérique, réglages de signature) seront ajoutés Phase 2+ lors de l’implémentation de l’écran Paramètres, suivant la règle prototype-first de la méthodologie.
Recherche
data class SearchQuery(
val text: String,
val cat: Int? = null,
val author: String? = null,
val dateFrom: LocalDate? = null,
val dateTo: LocalDate? = null,
)
data class SearchResult(
val cat: Int,
val post: Int, // topic ID
val numreponse: Int, // post ID dans la catégorie
val topicTitle: String,
val author: String,
val date: Instant,
val preview: String,
)
Hébergement d’images
data class HostedImage(
val id: String,
val url: String,
val thumbnailUrl: String?,
val originalUrl: String?,
val providerId: String, // identifiant du provider ayant servi l'upload ou le rehost
val deleteToken: String?, // null si le provider ne supporte pas la suppression (ex : rehost)
val uploadedAt: Instant,
val sizeBytes: Long,
val topicRef: TopicRef?,
)
data class TopicRef(
val cat: Int,
val post: Int,
val title: String,
)
Providers — interfaces séparées
Les capacités varient d’un provider à l’autre : certains supportent l’upload et le rehost, d’autres uniquement le rehost. Une seule interface ImageProvider avec des méthodes qui échouent sur certains providers serait fragile. On sépare en deux interfaces distinctes, implémentées indépendamment :
// :core:domain
interface UploadProvider {
val id: String // "diberie", "superh", "imgur"
val displayName: String
/** Upload une image depuis les octets bruts. Retourne l'image hébergée. */
suspend fun upload(bytes: ByteArray, filename: String?): Result<HostedImage>
/** Supprime une image si le provider le supporte et si deleteToken est valide. */
suspend fun delete(image: HostedImage): Result<Unit>
}
interface RehostProvider {
val id: String // "rehost", "diberie-rehost", "superh-rehost"
val displayName: String
/** Rehost une image déjà en ligne par son URL. Retourne l'image copiée. */
suspend fun rehost(sourceUrl: String): Result<HostedImage>
}
Un provider peut implémenter les deux interfaces si HFR expose les deux flux (exemple : DiberieUploadProvider implémente UploadProvider, DiberieRehostProvider implémente RehostProvider, ils peuvent partager un HttpClient commun).
Providers prévus en Phase 2 :
id | Interface(s) | Notes |
|---|---|---|
diberie | UploadProvider + RehostProvider | Rehost by dib (communauté HFR) |
superh | UploadProvider + RehostProvider | super-h.fr |
imgur | UploadProvider | API Imgur, fallback |
rehost | RehostProvider | reho.st historique (plus d’upload manuel) |
Enregistrement via Hilt @IntoSet (cf. extensions.md) : ajouter un provider ne modifie pas le code existant.