Swift 98.3%
Shell 1.7%
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: "&", with: "&")377 .replacingOccurrences(of: " ", 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