Main coves client
at main 299 lines 8.1 kB view raw
1// Rich text facet models for Coves 2// 3// Facets represent structured metadata about text segments, such as links, 4// mentions, or hashtags. They use byte indices (UTF-8) rather than character 5// indices (UTF-16) to ensure cross-platform compatibility with the backend. 6 7import 'package:flutter/foundation.dart'; 8 9/// Byte range for a text segment 10/// 11/// Uses UTF-8 byte offsets, not UTF-16 character positions. 12/// This is crucial for proper alignment with the backend, especially 13/// when text contains emoji or other multi-byte characters. 14class ByteSlice { 15 const ByteSlice({ 16 required this.byteStart, 17 required this.byteEnd, 18 }) : assert(byteStart >= 0, 'byteStart must be non-negative'), 19 assert(byteEnd >= byteStart, 'byteEnd must be >= byteStart'); 20 21 factory ByteSlice.fromJson(Map<String, dynamic> json) { 22 final start = json['byteStart']; 23 final end = json['byteEnd']; 24 25 if (start == null || start is! int) { 26 throw const FormatException( 27 'ByteSlice: Required field "byteStart" is missing or invalid', 28 ); 29 } 30 31 if (end == null || end is! int) { 32 throw const FormatException( 33 'ByteSlice: Required field "byteEnd" is missing or invalid', 34 ); 35 } 36 37 if (start < 0 || end < 0 || end < start) { 38 throw FormatException( 39 'ByteSlice: Invalid byte range [$start, $end)', 40 ); 41 } 42 43 return ByteSlice( 44 byteStart: start, 45 byteEnd: end, 46 ); 47 } 48 49 /// Start byte position (inclusive) 50 final int byteStart; 51 52 /// End byte position (exclusive) 53 final int byteEnd; 54 55 /// Convert to JSON 56 Map<String, dynamic> toJson() { 57 return { 58 'byteStart': byteStart, 59 'byteEnd': byteEnd, 60 }; 61 } 62 63 @override 64 String toString() => 'ByteSlice($byteStart, $byteEnd)'; 65 66 @override 67 bool operator ==(Object other) => 68 identical(this, other) || 69 other is ByteSlice && 70 runtimeType == other.runtimeType && 71 byteStart == other.byteStart && 72 byteEnd == other.byteEnd; 73 74 @override 75 int get hashCode => Object.hash(byteStart, byteEnd); 76} 77 78/// Base class for facet features 79/// 80/// A facet feature describes the semantic meaning of a text segment, 81/// such as a link, mention, or hashtag. 82sealed class FacetFeature { 83 const FacetFeature(); 84 85 /// The type identifier for this feature (e.g., "social.coves.richtext.facet#link") 86 String get type; 87 88 /// Convert to JSON 89 Map<String, dynamic> toJson(); 90 91 /// Create a FacetFeature from JSON 92 factory FacetFeature.fromJson(Map<String, dynamic> json) { 93 final type = json[r'$type'] as String?; 94 95 if (type == null || type.isEmpty) { 96 return UnknownFacetFeature(data: json); 97 } 98 99 switch (type) { 100 case 'social.coves.richtext.facet#link': 101 final uri = json['uri']; 102 if (uri == null || uri is! String || uri.isEmpty) { 103 throw const FormatException( 104 'LinkFacetFeature: Required field "uri" is missing or invalid', 105 ); 106 } 107 return LinkFacetFeature(uri: uri); 108 109 default: 110 // Unknown feature type - preserve for forward compatibility 111 return UnknownFacetFeature(data: json); 112 } 113 } 114} 115 116/// Link facet feature 117class LinkFacetFeature extends FacetFeature { 118 const LinkFacetFeature({required this.uri}); 119 120 /// The URI/URL this link points to 121 final String uri; 122 123 @override 124 String get type => 'social.coves.richtext.facet#link'; 125 126 @override 127 Map<String, dynamic> toJson() { 128 return { 129 r'$type': type, 130 'uri': uri, 131 }; 132 } 133 134 @override 135 String toString() => 'LinkFacetFeature($uri)'; 136 137 @override 138 bool operator ==(Object other) => 139 identical(this, other) || 140 other is LinkFacetFeature && 141 runtimeType == other.runtimeType && 142 uri == other.uri; 143 144 @override 145 int get hashCode => uri.hashCode; 146} 147 148/// Unknown facet feature for forward compatibility 149/// 150/// Preserves unknown feature types so they can be round-tripped 151/// through the client without data loss. 152class UnknownFacetFeature extends FacetFeature { 153 const UnknownFacetFeature({required this.data}); 154 155 /// Raw JSON data 156 final Map<String, dynamic> data; 157 158 @override 159 String get type => data[r'$type'] as String? ?? 'unknown'; 160 161 @override 162 Map<String, dynamic> toJson() => data; 163 164 @override 165 String toString() => 'UnknownFacetFeature($type)'; 166 167 @override 168 bool operator ==(Object other) => 169 identical(this, other) || 170 other is UnknownFacetFeature && 171 runtimeType == other.runtimeType && 172 _mapEquals(data, other.data); 173 174 @override 175 int get hashCode => Object.hashAll(data.entries); 176 177 static bool _mapEquals(Map<String, dynamic> a, Map<String, dynamic> b) { 178 if (a.length != b.length) return false; 179 for (final key in a.keys) { 180 if (!b.containsKey(key) || a[key] != b[key]) return false; 181 } 182 return true; 183 } 184} 185 186/// A rich text facet - metadata about a text segment 187class RichTextFacet { 188 const RichTextFacet({ 189 required this.index, 190 required this.features, 191 }); 192 193 factory RichTextFacet.fromJson(Map<String, dynamic> json) { 194 final indexData = json['index']; 195 if (indexData == null || indexData is! Map<String, dynamic>) { 196 throw const FormatException( 197 'RichTextFacet: Required field "index" is missing or invalid', 198 ); 199 } 200 201 final featuresData = json['features']; 202 if (featuresData == null || featuresData is! List) { 203 throw const FormatException( 204 'RichTextFacet: Required field "features" is missing or invalid', 205 ); 206 } 207 208 return RichTextFacet( 209 index: ByteSlice.fromJson(indexData), 210 features: List.unmodifiable( 211 featuresData 212 .whereType<Map<String, dynamic>>() 213 .map(FacetFeature.fromJson) 214 .toList(), 215 ), 216 ); 217 } 218 219 /// The byte range this facet applies to 220 final ByteSlice index; 221 222 /// The semantic features of this text segment 223 final List<FacetFeature> features; 224 225 /// Check if this facet contains a link feature 226 bool get hasLink => 227 features.any((feature) => feature is LinkFacetFeature); 228 229 /// Get the link URI if this facet has a link feature 230 String? get linkUri { 231 for (final feature in features) { 232 if (feature is LinkFacetFeature) { 233 return feature.uri; 234 } 235 } 236 return null; 237 } 238 239 /// Convert to JSON 240 Map<String, dynamic> toJson() { 241 return { 242 'index': index.toJson(), 243 'features': features.map((f) => f.toJson()).toList(), 244 }; 245 } 246 247 @override 248 String toString() => 'RichTextFacet($index, ${features.length} features)'; 249 250 @override 251 bool operator ==(Object other) => 252 identical(this, other) || 253 other is RichTextFacet && 254 runtimeType == other.runtimeType && 255 index == other.index && 256 _listEquals(features, other.features); 257 258 @override 259 int get hashCode => Object.hash(index, Object.hashAll(features)); 260 261 static bool _listEquals(List<FacetFeature> a, List<FacetFeature> b) { 262 if (a.length != b.length) return false; 263 for (var i = 0; i < a.length; i++) { 264 if (a[i] != b[i]) return false; 265 } 266 return true; 267 } 268} 269 270/// Parse facets from a record's 'facets' field 271/// 272/// Backend returns facets inside `record['facets']` rather than at the top level. 273/// This helper safely extracts and parses them, returning null if missing/invalid. 274/// 275/// Note: Parsing failures are logged in debug mode but return null to prevent 276/// a single malformed facet from breaking the entire content. Users will see 277/// plain text instead of rich links when facet parsing fails. 278List<RichTextFacet>? parseFacetsFromRecord(Object? record) { 279 if (record == null || record is! Map<String, dynamic>) { 280 return null; 281 } 282 final facets = record['facets']; 283 if (facets == null || facets is! List) { 284 return null; 285 } 286 try { 287 return List.unmodifiable( 288 facets 289 .whereType<Map<String, dynamic>>() 290 .map(RichTextFacet.fromJson) 291 .toList(), 292 ); 293 } on Exception catch (e) { 294 if (kDebugMode) { 295 debugPrint('⚠️ Facet parsing failed: $e'); 296 } 297 return null; 298 } 299}