SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
21.2 KB · 392 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Ecosystem.swift — la source de vérité des 13 univers Groupe-KA dans l'app :3// identité (nom, accent, symbole, tagline) + configuration d'API (endpoint de4// liste, clé des items, mapping JSON → KAItem). Ajouter un univers = ajouter5// une entrée ici, rien d'autre.6// Repris quasi tel quel de l'app iOS KA (~/Desktop/KA/KA/Core/Ecosystem.swift).7import SwiftUI89// MARK: - JSON générique (les API des univers ont chacune leur forme)1011enum JSONValue: Decodable {12    case string(String), number(Double), bool(Bool)13    case object([String: JSONValue]), array([JSONValue]), null1415    init(from decoder: Decoder) throws {16        let c = try decoder.singleValueContainer()17        if c.decodeNil() { self = .null }18        else if let b = try? c.decode(Bool.self) { self = .bool(b) }19        else if let n = try? c.decode(Double.self) { self = .number(n) }20        else if let s = try? c.decode(String.self) { self = .string(s) }21        else if let a = try? c.decode([JSONValue].self) { self = .array(a) }22        else { self = .object(try c.decode([String: JSONValue].self)) }23    }2425    var string: String? { if case .string(let s) = self { return s }; return nil }26    var number: Double? { if case .number(let n) = self { return n }; return nil }27    var array: [JSONValue]? { if case .array(let a) = self { return a }; return nil }28    var object: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil }29    /// Texte « au mieux » (string, nombre formaté, liste jointe)30    var text: String? {31        switch self {32        case .string(let s): return s.isEmpty ? nil : s33        case .number(let n): return n == n.rounded() ? String(Int(n)) : String(n)34        case .array(let a): let parts = a.compactMap(\.text); return parts.isEmpty ? nil : parts.joined(separator: ", ")35        default: return nil36        }37    }38}3940extension [String: JSONValue] {41    func str(_ keys: String...) -> String? {42        for k in keys { if let v = self[k]?.text { return v } }43        return nil44    }45    func num(_ keys: String...) -> Double? {46        for k in keys { if let v = self[k]?.number { return v } }47        return nil48    }49}5051// MARK: - L'élément universel5253struct KAItem: Identifiable, Hashable, Codable {54    var id: String55    var universeID: String56    var title: String57    var subtitle: String?58    var priceLabel: String?59    var city: String?60    var url: URL?61    var imageURL: URL?62    /// Galerie complète (les sites en ont souvent plusieurs)63    var imageURLs: [URL] = []64    var latitude: Double?65    var longitude: Double?66    /// Description longue (fiche)67    var detail: String?68    /// Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…)69    var facts: [Fact]70    /// Liens riches (boutons) — ex. les comptes d'un créateur71    var links: [Fact] = []7273    struct Fact: Hashable, Codable { var label: String; var value: String }74}7576// MARK: - Univers7778struct Universe: Identifiable {79    let id: String80    let wordmark: String       // « Lou·Ka »81    let name: String           // « Lou-KA »82    let tagline: String83    let accentHex: String84    let symbol: String         // SF Symbol85    let domain: String86    let unit: String           // « logements », « offres »…87    /// Chemin de la liste (nil = univers sans liste native, ex. vrai-prix)88    let listPath: String?89    let itemsKey: String90    let searchParam: String91    let statTotalKeys: [String]     // clés du total dans /api/stats92    let map: (@Sendable ([String: JSONValue]) -> KAItem?)?9394    var accent: Color { Color(hex: accentHex) }95    var baseURL: URL { URL(string: "https://\(domain)")! }96}9798enum Ecosystem {99    static let hubURL = URL(string: "https://www.groupe-ka.com")!100    static let hubDomain = "www.groupe-ka.com"101    static let signupURL = URL(string: "https://www.groupe-ka.com/connexion")!102    static let statusURL = URL(string: "https://www.groupe-ka.com/status")!103    static let contacts: [(email: String, role: String)] = [104        ("contact@groupe-ka.com", "Projets, partenariats & données"),105        ("info@groupe-ka.com", "Médias & questions générales"),106        ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),107    ]108    static let legal: [(label: String, path: String)] = [109        ("Conditions d'utilisation", "/conditions"),110        ("Politique de confidentialité", "/confidentialite"),111        ("Renseignements personnels (Loi 25)", "/loi-25"),112        ("Transparence des robots", "/bots"),113    ]114    static let disclaimer = "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Données lues à la source — rien d'inventé, tout est traçable."115116    static func universe(_ id: String) -> Universe? { all.first { $0.id == id } }117118    // Aides de mapping communes119    private static func base(_ o: [String: JSONValue], universe: String, idKeys: [String] = ["uid"]) -> KAItem {120        var id: String?121        for k in idKeys { if let v = o[k]?.text { id = v; break } }122        // galerie : champ `images` (liste) ou `image` — jusqu'à 10, http seulement123        var gallery: [URL] = (o["images"]?.array ?? [])124            .compactMap { $0.text }125            .filter { $0.hasPrefix("http") }126            .prefix(10)127            .compactMap(URL.init(string:))128        if gallery.isEmpty,129           let single = o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)) {130            gallery = [single]131        }132        let image = gallery.first133        return KAItem(134            id: "\(universe):\(id ?? UUID().uuidString)",135            universeID: universe,136            title: o.str("title", "name", "display_name") ?? "Sans titre",137            subtitle: nil, priceLabel: nil,138            city: o.str("city"),139            url: o.str("url").flatMap(URL.init(string:)),140            imageURL: image,141            imageURLs: gallery,142            latitude: o.num("lat", "latitude"),143            longitude: o.num("lng", "lon", "longitude"),144            detail: o.str("description", "menu_summary", "bio").map { $0.strippingHTML.trimmingCharacters(in: .whitespacesAndNewlines) },145            facts: []146        )147    }148149    // MARK: les 12 univers (+ le hub, sans liste)150    static let all: [Universe] = [151        Universe(id: "lou-ka", wordmark: "Lou·Ka", name: "Lou-KA",152                 tagline: "Tous les logements à louer", accentHex: "#ff6a00",153                 symbol: "key.fill", domain: "www.lou-ka.com", unit: "logements",154                 listPath: "/api/listings", itemsKey: "listings", searchParam: "q",155                 statTotalKeys: ["total"],156                 map: { o in157                     var it = base(o, universe: "lou-ka")158                     it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ")159                     if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois" }160                     it.facts = [161                         o.str("unit_type").map { .init(label: "Taille", value: $0) },162                         o.str("address").map { .init(label: "Adresse", value: $0) },163                         o.str("available_by").map { .init(label: "Disponible", value: $0) },164                         o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil },165                         o.str("source").map { .init(label: "Gestionnaire", value: $0) },166                     ].compactMap { $0 }167                     return it168                 }),169        Universe(id: "immo-ka", wordmark: "Immo·Ka", name: "Immo-KA",170                 tagline: "Toutes les propriétés à vendre", accentHex: "#e23744",171                 symbol: "house.fill", domain: "www.immo-ka.com", unit: "propriétés",172                 listPath: "/api/listings", itemsKey: "listings", searchParam: "q",173                 statTotalKeys: ["total"],174                 map: { o in175                     var it = base(o, universe: "immo-ka")176                     it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ")177                     if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0 }178                     it.facts = [179                         o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) },180                         o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) },181                         o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil },182                         o.str("address").map { .init(label: "Adresse", value: $0) },183                         o.str("source").map { .init(label: "Source", value: $0) },184                     ].compactMap { $0 }185                     return it186                 }),187        Universe(id: "vrai-prix", wordmark: "Vrai-Prix", name: "Vrai-Prix",188                 tagline: "La valeur réelle de chaque propriété", accentHex: "#ff5148",189                 symbol: "chart.line.uptrend.xyaxis", domain: "www.vrai-prix.com", unit: "propriétés estimées",190                 listPath: nil, itemsKey: "", searchParam: "q",191                 statTotalKeys: ["units_total"], map: nil),192        Universe(id: "auto-ka", wordmark: "Auto·Ka", name: "Auto-KA",193                 tagline: "Les voitures usagées du Québec", accentHex: "#ff5a2a",194                 symbol: "car.fill", domain: "www.auto-ka.com", unit: "véhicules",195                 listPath: "/api/vehicles", itemsKey: "vehicles", searchParam: "q",196                 statTotalKeys: ["total"],197                 map: { o in198                     var it = base(o, universe: "auto-ka")199                     it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ")200                     it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0)201                     it.facts = [202                         o.str("make").map { .init(label: "Marque", value: $0) },203                         o.str("model").map { .init(label: "Modèle", value: $0) },204                         o.str("transmission").map { .init(label: "Boîte", value: $0) },205                         o.str("fuel").map { .init(label: "Carburant", value: $0) },206                         o.str("drivetrain").map { .init(label: "Rouage", value: $0) },207                         o.str("body_type").map { .init(label: "Carrosserie", value: $0) },208                         o.str("dealer_name").map { .init(label: "Concessionnaire", value: $0) },209                         o.str("mileage_label").map { .init(label: "Kilométrage", value: $0) },210                     ].compactMap { $0 }211                     return it212                 }),213        Universe(id: "fabri-ka", wordmark: "Fabri·Ka", name: "Fabri-KA",214                 tagline: "Les produits fabriqués au Québec", accentHex: "#c4532e",215                 symbol: "shippingbox.fill", domain: "www.fabri-ka.com", unit: "produits",216                 listPath: "/api/products", itemsKey: "items", searchParam: "q",217                 statTotalKeys: ["totals.products", "total"],218                 map: { o in219                     var it = base(o, universe: "fabri-ka")220                     it.subtitle = o.str("store_id", "store")221                     it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2)222                     it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 }223                     return it224                 }),225        Universe(id: "food-ka", wordmark: "Food·Ka", name: "Food-KA",226                 tagline: "Les prix d'épicerie, suivis à la source", accentHex: "#1f9d55",227                 symbol: "cart.fill", domain: "www.food-ka.com", unit: "produits",228                 listPath: "/api/products", itemsKey: "products", searchParam: "q",229                 statTotalKeys: ["total"],230                 map: { o in231                     var it = base(o, universe: "food-ka")232                     it.subtitle = [o.str("brand"), o.str("size_label")].compactMap { $0 }.joined(separator: " · ")233                     let onSale: Bool = { if case .bool(true) = o["on_sale"] ?? .null { return true }; return false }()234                     if let p = o.num("price"), p > 0.2 {235                         if onSale, let reg = o.num("regular_price"), reg > p {236                             it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))"237                         } else { it.priceLabel = p.money2 }238                     }239                     it.facts = [240                         o.str("category").map { .init(label: "Catégorie", value: $0) },241                         o.num("unit_price").flatMap { $0 > 0.001 ? KAItem.Fact(label: "Prix unitaire", value: $0.money2) : nil },242                         onSale ? KAItem.Fact(label: "Solde", value: "Oui 🏷️") : nil,243                     ].compactMap { $0 }244                     return it245                 }),246        Universe(id: "resto-ka", wordmark: "Resto·Ka", name: "Resto-KA",247                 tagline: "Chaque resto, chaque plat, chaque prix", accentHex: "#f08c00",248                 symbol: "fork.knife", domain: "www.resto-ka.com", unit: "restaurants",249                 listPath: "/api/restaurants", itemsKey: "restaurants", searchParam: "q",250                 statTotalKeys: ["restaurants", "total"],251                 map: { o in252                     var it = base(o, universe: "resto-ka")253                     let cuisines = o["cuisines"]?.text254                     it.subtitle = [cuisines, o.str("price_range")].compactMap { $0 }.joined(separator: " · ")255                     it.facts = [256                         o.str("address").map { .init(label: "Adresse", value: $0) },257                         o.str("chain").map { .init(label: "Chaîne", value: $0) },258                     ].compactMap { $0 }259                     return it260                 }),261        Universe(id: "sorti-ka", wordmark: "Sorti·Ka", name: "Sorti-KA",262                 tagline: "Toutes les sorties, dans les 17 régions", accentHex: "#d6336c",263                 symbol: "ticket.fill", domain: "www.sorti-ka.com", unit: "événements",264                 listPath: "/api/events?upcoming=true", itemsKey: "events", searchParam: "q",265                 statTotalKeys: ["total_active", "total"],266                 map: { o in267                     var it = base(o, universe: "sorti-ka")268                     it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ")269                     it.facts = [270                         o.str("start_date").map { .init(label: "Début", value: $0) },271                         o.str("end_date").map { .init(label: "Fin", value: $0) },272                         o.str("venue").map { .init(label: "Lieu", value: $0) },273                         o.str("region").map { .init(label: "Région", value: $0) },274                         (o["is_free"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Entrée", value: "Gratuite 🎉") } ; return nil }),275                         o.num("price_min").flatMap { $0 > 1 ? KAItem.Fact(label: "Billets dès", value: $0.money0) : nil },276                     ].compactMap { $0 }277                     return it278                 }),279        Universe(id: "crea-ka", wordmark: "Créa·Ka", name: "Créa-KA",280                 tagline: "Les créateurs d'ici, tous leurs liens", accentHex: "#7048e8",281                 symbol: "sparkles", domain: "www.crea-ka.com", unit: "créateurs",282                 listPath: "/api/creators", itemsKey: "items", searchParam: "q",283                 statTotalKeys: ["creators", "total"],284                 map: { o in285                     var it = base(o, universe: "crea-ka", idKeys: ["cid", "uid", "display_name"])286                     let plats = o["platforms"]?.array?.compactMap(\.object) ?? []287                     let totalFollowers = plats.compactMap { $0.num("followers") }.reduce(0, +)288                     it.subtitle = [o["niches"]?.text,289                                    totalFollowers > 0 ? totalFollowers.compact + " abonnés" : nil]290                         .compactMap { $0 }.joined(separator: " · ")291                     if it.url == nil { it.url = plats.first?.str("url").flatMap(URL.init(string:)) }292                     it.facts = plats.prefix(6).compactMap { pl in293                         guard let name = pl.str("platform") else { return nil }294                         let f = pl.num("followers").map { $0.compact } ?? ""295                         return KAItem.Fact(label: name.capitalized, value: f.isEmpty ? "@" + (pl.str("handle") ?? "") : f + " abonnés")296                     }297                     it.links = plats.prefix(6).compactMap { pl in298                         guard let name = pl.str("platform"), let url = pl.str("url") else { return nil }299                         return KAItem.Fact(label: name.capitalized, value: url)300                     }301                     return it302                 }),303        Universe(id: "job-ka", wordmark: "Job·Ka", name: "Job-KA",304                 tagline: "Tous les emplois des employeurs québécois", accentHex: "#0c8599",305                 symbol: "briefcase.fill", domain: "www.job-ka.com", unit: "offres d'emploi",306                 listPath: "/api/jobs", itemsKey: "jobs", searchParam: "q",307                 statTotalKeys: ["total"],308                 map: { o in309                     var it = base(o, universe: "job-ka")310                     it.subtitle = [o.str("employer"), o.str("city")].compactMap { $0 }.joined(separator: " · ")311                     let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max")312                     if let a = sMin, a > 20000 {313                         it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)–\(sMax!.money0)/an" : a.money0 + "/an"314                     } else if let h = o.num("salary_hour_min"), h > 10 {315                         it.priceLabel = h.money2 + "/h"316                     }317                     it.facts = [318                         o.str("employer").map { .init(label: "Employeur", value: $0) },319                         o.str("work_mode").map { .init(label: "Mode de travail", value: $0) },320                         o.str("employment_type").map { .init(label: "Type", value: $0) },321                         o.str("ats").map { .init(label: "Plateforme carrière", value: $0) },322                         o.str("date_posted").map { .init(label: "Publiée le", value: String($0.prefix(10))) },323                     ].compactMap { $0 }324                     return it325                 }),326        Universe(id: "trouve-ka", wordmark: "Trouve·Ka", name: "Trouve-KA",327                 tagline: "Le moteur de recherche du web québécois", accentHex: "#1c7ed6",328                 symbol: "magnifyingglass", domain: "www.trouve-ka.com", unit: "pages indexées",329                 listPath: "/api/search", itemsKey: "results", searchParam: "q",330                 statTotalKeys: ["pages_indexed"],331                 map: { o in332                     var it = base(o, universe: "trouve-ka", idKeys: ["url"])333                     it.title = (o.str("title") ?? "Page web").strippingHTML334                     it.subtitle = o.str("snippet")?.strippingHTML335                     it.facts = [o.str("domain").map { .init(label: "Domaine", value: $0) }].compactMap { $0 }336                     return it337                 }),338        Universe(id: "api-ka", wordmark: "API·Ka", name: "API-KA",339                 tagline: "La donnée de l'écosystème, par API", accentHex: "#3b5bdb",340                 symbol: "terminal.fill", domain: "www.api-ka.com", unit: "appels API",341                 listPath: nil, itemsKey: "", searchParam: "q",342                 statTotalKeys: ["total"], map: nil),343    ]344}345346// MARK: - petites extensions347348extension Double {349    /// 17 100 000 → « 17,1 M », 5 200 → « 5,2 k »350    var compact: String {351        if self >= 1_000_000 { return String(format: "%.1f M", self / 1_000_000).replacingOccurrences(of: ".", with: ",") }352        if self >= 10_000 { return String(format: "%.0f k", self / 1_000) }353        if self >= 1_000 { return String(format: "%.1f k", self / 1_000).replacingOccurrences(of: ".", with: ",") }354        return String(Int(self))355    }356    var money0: String {357        let f = NumberFormatter(); f.numberStyle = .currency358        f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 0359        return f.string(from: NSNumber(value: self)) ?? "\(Int(self)) $"360    }361    var money2: String {362        let f = NumberFormatter(); f.numberStyle = .currency363        f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 2364        return f.string(from: NSNumber(value: self)) ?? "\(self) $"365    }366}367368extension Int {369    /// « 12 345 » à la québécoise370    var fr: String { formatted(.number.locale(Locale(identifier: "fr_CA"))) }371}372373extension String {374    var strippingHTML: String {375        replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)376            .replacingOccurrences(of: "&amp;", with: "&")377            .replacingOccurrences(of: "&nbsp;", with: " ")378    }379}380381extension Color {382    init(hex: String) {383        var h = hex.trimmingCharacters(in: .alphanumerics.inverted)384        if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() }385        let v = UInt64(h, radix: 16) ?? 0386        self.init(.sRGB,387                  red: Double((v >> 16) & 0xFF) / 255,388                  green: Double((v >> 8) & 0xFF) / 255,389                  blue: Double(v & 0xFF) / 255)390    }391}392