Commit 45606a4c authored by Kourser's avatar Kourser
Browse files

Sync in Settings, render HTML descriptions, fix episodes not loading



- Move the synchronisation configuration into the Réglages screen (SyncView is
  now sections embedded in the Settings form); drop the separate sidebar entry.
- Render feed descriptions as readable text via FeedKit.HTMLText.plain (strip
  tags, decode entities) instead of showing raw HTML. + tests.
- Fix episodes not appearing until a manual pull-to-refresh: the detail used
  `.task` (no id) which didn't re-run when the selected podcast changed in the
  split view. Use `.task(id: podcast.id)` and auto-fetch the feed if the store
  has no episodes yet.

51 package tests green; iOS + macOS build; screens verified in the simulator.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent 1d03cde2
Loading
Loading
Loading
Loading
Loading
+47 −0
Original line number Diff line number Diff line
import Foundation

/// Best-effort conversion of an HTML fragment (feed descriptions are usually
/// HTML) to readable plain text: block tags become line breaks, other tags are
/// stripped, and common entities are decoded. Fast and main-thread-free
/// (unlike `NSAttributedString(html:)`).
public enum HTMLText {
    public static func plain(_ html: String) -> String {
        var text = html

        // Block-level / line-break tags → newlines.
        let breaks = ["<br>", "<br/>", "<br />", "</p>", "</div>", "</li>",
                      "</h1>", "</h2>", "</h3>", "</h4>", "</tr>"]
        for tag in breaks {
            text = text.replacingOccurrences(of: tag, with: "\n", options: .caseInsensitive)
        }

        // Strip any remaining tags.
        text = text.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)

        // Named entities.
        let named = [
            "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
            "&#39;": "'", "&apos;": "'", "&nbsp;": " ", "&hellip;": "…",
            "&mdash;": "—", "&ndash;": "–", "&rsquo;": "’", "&lsquo;": "‘",
            "&laquo;": "«", "&raquo;": "»", "&eacute;": "é", "&egrave;": "è",
        ]
        for (key, value) in named {
            text = text.replacingOccurrences(of: key, with: value)
        }

        // Decimal numeric entities (&#233;).
        while let range = text.range(of: "&#[0-9]+;", options: .regularExpression) {
            let digits = text[range].dropFirst(2).dropLast()
            if let code = UInt32(digits), let scalar = Unicode.Scalar(code) {
                text.replaceSubrange(range, with: String(scalar))
            } else {
                text.replaceSubrange(range, with: "")
            }
        }

        // Tidy whitespace: collapse runs of blank lines and spaces.
        text = text.replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
        text = text.replacingOccurrences(of: "\\n[ \\t]*\\n[ \\t]*\\n+", with: "\n\n", options: .regularExpression)
        return text.trimmingCharacters(in: .whitespacesAndNewlines)
    }
}
+28 −0
Original line number Diff line number Diff line
import Foundation
import Testing
@testable import FeedKit

@Suite struct HTMLTextTests {
    @Test func stripsTagsAndKeepsParagraphs() {
        let html = "<p>Bonjour</p><p>le <strong>monde</strong></p>"
        #expect(HTMLText.plain(html) == "Bonjour\nle monde")
    }

    @Test func keepsLinkTextDropsMarkup() {
        let html = #"Voir <a href="https://x.test">le site</a> svp"#
        #expect(HTMLText.plain(html) == "Voir le site svp")
    }

    @Test func decodesEntities() {
        #expect(HTMLText.plain("Tom &amp; Jerry &#233;") == "Tom & Jerry é")
    }

    @Test func collapsesBlankLines() {
        let html = "<p>A</p><p><br></p><p>B</p>"
        #expect(HTMLText.plain(html) == "A\n\nB")
    }

    @Test func plainTextPassesThrough() {
        #expect(HTMLText.plain("Just text") == "Just text")
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -118,7 +118,7 @@ final class AppModel {
            feedURL: URL(string: "https://feeds.audiomeans.fr/feed/64ee3763-1a46-44c2-8640-3a69405a3ad8.xml")!,
            title: "Le Collimateur",
            author: "Alexandre Jubelin / Binge Audio",
            summary: "Le podcast consacré aux questions militaires et stratégiques : entretiens, récits d'opérations et analyses de l'actualité de la défense.",
            summary: "<p>Le podcast consacré aux questions militaires et stratégiques.</p><p>Entretiens, récits d'opérations et analyses de l'actualité de la défense, animés par Alexandre Jubelin.</p>",
            imageURL: artwork,
            language: "fr"
        )
+10 −2
Original line number Diff line number Diff line
import SwiftUI
import PodcastModel
import FeedKit

struct PodcastDetailView: View {
    @Environment(AppModel.self) private var model
@@ -11,7 +12,7 @@ struct PodcastDetailView: View {
        List {
            if let summary = podcast.summary, !summary.isEmpty {
                Section {
                    Text(summary)
                    Text(HTMLText.plain(summary))
                        .font(.callout)
                        .foregroundStyle(.secondary)
                }
@@ -27,8 +28,15 @@ struct PodcastDetailView: View {
        }
        .navigationTitle(podcast.title)
        .inlineNavigationTitle()
        .task {
        .task(id: podcast.id) {
            loaded = false
            episodes = await model.episodes(for: podcast)
            if episodes.isEmpty {
                // Nothing stored yet for this feed — fetch it so episodes show
                // without a manual pull-to-refresh.
                await model.refresh(podcast)
                episodes = await model.episodes(for: podcast)
            }
            await model.loadPlayStates(for: episodes)
            loaded = true
        }
+1 −7
Original line number Diff line number Diff line
@@ -22,7 +22,6 @@ struct RootView: View {

    enum Destination: Hashable {
        case queue
        case sync
        case settings
        case podcast(UUID)
    }
@@ -33,8 +32,6 @@ struct RootView: View {
                Label("File d'attente", systemImage: "list.bullet")
                    .badge(model.queue.count)
                    .tag(Destination.queue)
                Label("Synchronisation", systemImage: "arrow.triangle.2.circlepath")
                    .tag(Destination.sync)
                Label("Réglages", systemImage: "gearshape")
                    .tag(Destination.settings)

@@ -106,8 +103,7 @@ struct RootView: View {
        }
        switch screen {
        case "queue": selection = .queue
        case "sync": selection = .sync
        case "settings": selection = .settings
        case "sync", "settings": selection = .settings
        case "detail", "player": selection = model.podcasts.first.map { Destination.podcast($0.id) }
        default: selection = nil
        }
@@ -142,8 +138,6 @@ struct RootView: View {
        switch selection {
        case .queue:
            QueueContentView()
        case .sync:
            SyncView()
        case .settings:
            SettingsView()
        case .podcast(let id):
Loading