// lib/models/content/content_item.dart // // مدل ساده‌ی یک content از content-service بک‌اند جدید. // با pagination معمول nestjs-paginate دریافت می‌شود. // ignore_for_file: deprecated_member_use import 'dart:convert' as _jsonc; class ContentCategory { final String? id; final String? name; const ContentCategory({this.id, this.name}); factory ContentCategory.fromJson(Map json) => ContentCategory( id: json['id'] as String?, // بک‌اند اسم دسته را در فیلد `title` می‌گذارد (نه `name`). name: (json['title'] ?? json['name']) as String?, ); } class ContentItem { final String id; final String title; final String? summary; final String? status; final String? author; final DateTime? publishedAt; final DateTime? createdAt; /// تگ‌های محتوا (نام تگ‌ها از Tag entity در content-service). final List tagNames; final List keywords; final ContentCategory? category; final int likeCount; final int readCount; final bool isLiked; final bool isBookmarked; final bool isRead; /// body خام بک‌اند (آرایه‌ای از sections). فعلا برای main page فقط /// از summary استفاده می‌کنیم اما کل body را نگه می‌داریم تا صفحه‌ی /// جزئیات بعدا بتواند نمایش بدهد. final dynamic body; /// آدرس تصویری که در body به عنوان media اول دیده می‌شود (اگر باشد). /// در محتوای جدید معمولا خالی است؛ مقدارِ نهایی توسط ContentService از /// `metadata.cover_image_id` به URL signed ـ شده resolve می‌گردد. final String? coverImage; /// شناسه‌ی فایل کاور در s3-service (از `metadata.cover_image_id`). /// با این id، URL signed-شده‌ی تصویر گرفته می‌شود. final String? coverImageId; /// «دسته‌ی» انتخابیِ نویسنده در metadata (مثلا "auto-tech", "fintech"). /// مستقل از `category` که خود دسته‌ی اصلی content-serviceاست. final String? metaCategory; /// محلِ مرکز سازمان از metadata (برای استارت‌آپ‌ها)، مثل "iran" یا "france". final String? metaHeadquarter; /// نوعِ آیتم در دسته‌ی فرصت‌وتهدید: "opportunity" یا "threat". final String? metaType; /// نوعِ آیتم در دسته‌ی «داستان» (story): مقدارهایی مثل /// `industry-environment`, `macro-environment`, `definition`, /// `macro-trends`, `investment`. اگر چند مقدار باشد، اولی برداشته می‌شود. final String? metaStoryType; /// آدرس اولین فایل صوتی (mimeType: audio/*) در body — برای پادکست. final String? audioUrl; /// آدرس اولین فایل ویدیویی (mimeType: video/*) در body — برای ویدیوکست. final String? videoUrl; /// شناسه‌ی S3 فایل صوتی (اگر در body به شکل UUID ذخیره شده باشد). /// با این id باید از s3-service URL signed-شده گرفته شود. final String? audioId; /// شناسه‌ی S3 فایل ویدیویی. final String? videoId; /// مدت‌زمان مدیا (ثانیه) اگر در بلوک media موجود باشد. final int? mediaDuration; const ContentItem({ required this.id, required this.title, this.summary, this.status, this.author, this.publishedAt, this.createdAt, this.tagNames = const [], this.keywords = const [], this.category, this.likeCount = 0, this.readCount = 0, this.isLiked = false, this.isBookmarked = false, this.isRead = false, this.body, this.coverImage, this.coverImageId, this.metaCategory, this.metaHeadquarter, this.metaType, this.metaStoryType, this.audioUrl, this.videoUrl, this.audioId, this.videoId, this.mediaDuration, }); factory ContentItem.fromJson(Map json) { DateTime? publishedAt; final pa = json['publishedAt']; if (pa is String && pa.isNotEmpty) { publishedAt = DateTime.tryParse(pa); } DateTime? createdAt; final ca = json['createdAt']; if (ca is String && ca.isNotEmpty) { createdAt = DateTime.tryParse(ca); } var body = json['body']; // اگر body به‌صورت رشته‌ی JSON آمد (مثلاً در fetchِ لیست از سرور)، // آن را parse می‌کنیم تا parser بتواند blocks را بخواند. if (body is String && body.isNotEmpty) { try { body = _jsonc.json.decode(body); } catch (_) {} } String? cover; String? audio; String? video; String? audioIdLocal; String? videoIdLocal; int? mediaDuration; // helper: تشخیصِ شناسه‌ی UUID از یک URL واقعی. bool isHttpUrl(String s) => s.startsWith('http://') || s.startsWith('https://'); bool looksLikeUuid(String s) => RegExp( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') .hasMatch(s); // === فرمت جدید Editor.js: body = {time, blocks: [...], version} === // بلاک‌های `mediaLibrary` با data.id حاوی UUID فایل S3 هستند. چون // نوع mime را در body نداریم، همان id را در هر سه فیلد audio/video/cover // ذخیره می‌کنیم تا هر consumer (پادکست، ویدیوکست، استوری) از خودش // مناسب را برگزیند. حلِ نهاییِ URL از طریق s3-service انجام می‌شود. if (body is Map && body['blocks'] is List) { String? primaryMediaId; for (final block in (body['blocks'] as List)) { if (block is! Map) continue; final type = block['type']?.toString().toLowerCase(); final data = block['data']; if (data is! Map) continue; if ((type == 'medialibrary' || type == 'media' || type == 'image') && data['id'] is String) { final id = data['id'] as String; if (id.isNotEmpty) { primaryMediaId ??= id; } } } if (primaryMediaId != null) { audioIdLocal = primaryMediaId; videoIdLocal = primaryMediaId; } } // اسکن body (آرایه‌ای از sections) برای اولین تصویر/صوت/ویدیو. if (body is List) { for (final section in body) { if (section is Map && section['blocks'] is List) { for (final block in (section['blocks'] as List)) { if (block is! Map) continue; final value = block['value']; // value می‌تواند Map با {url, mimeType} باشد یا فقط String. String? rawUrl; String? rawId; String? mime; int? duration; if (value is Map) { if (value['url'] is String) rawUrl = value['url'] as String; if (value['id'] is String) rawId = value['id'] as String; if (value['key'] is String) rawId ??= value['key'] as String; if (value['mimeType'] is String) mime = value['mimeType'] as String; if (value['mime'] is String) mime ??= value['mime'] as String; final d = value['duration']; if (d is num) duration = d.toInt(); } else if (value is String) { rawUrl = value; } if (rawUrl == null && rawId == null) continue; // اگر url خودش UUID بود، آن را به جای id بگیر. String? directUrl; String? mediaId; if (rawUrl != null && isHttpUrl(rawUrl)) { directUrl = rawUrl; } else if (rawUrl != null && looksLikeUuid(rawUrl)) { mediaId = rawUrl; } else if (rawId != null) { mediaId = rawId; } else if (rawUrl != null) { // مسیر نسبی یا چیز دیگر؛ همان را به‌عنوان URL در نظر بگیر. directUrl = rawUrl; } // تشخیصِ نوع مدیا از طریق mime یا blockId. final blockId = block['blockId']?.toString().toLowerCase() ?? ''; final isAudio = (mime != null && mime.startsWith('audio/')) || blockId.contains('podcast') || blockId.contains('audio'); final isVideo = (mime != null && mime.startsWith('video/')) || blockId.contains('video'); final isImage = (mime != null && mime.startsWith('image/')) || blockId.contains('image') || blockId.contains('cover') || blockId.contains('story_media'); if (isAudio && audio == null && audioIdLocal == null) { if (directUrl != null) audio = directUrl; if (mediaId != null) audioIdLocal = mediaId; if (duration != null) mediaDuration = duration; } else if (isVideo && video == null && videoIdLocal == null) { if (directUrl != null) video = directUrl; if (mediaId != null) videoIdLocal = mediaId; if (duration != null) mediaDuration = duration; } else if (isImage && cover == null) { if (directUrl != null) cover = directUrl; // برای تصویر، id داخل metadata.cover_image_id خواندنی است، // اینجا فقط URL مستقیم را نگه می‌داریم. } } } if (cover != null && (audio != null || audioIdLocal != null) && (video != null || videoIdLocal != null)) { break; } } } final keywordsRaw = json['keywords']; final keywords = keywordsRaw is List ? keywordsRaw.map((e) => e.toString()).toList() : []; // tags از relation Tag در content-service: [{id, name, color, ...}, ...] final tagsRaw = json['tags']; final tagNames = []; if (tagsRaw is List) { for (final t in tagsRaw) { if (t is Map) { final n = t['name'] ?? t['label'] ?? t['title']; if (n is String && n.isNotEmpty) tagNames.add(n); } else if (t is String && t.isNotEmpty) { tagNames.add(t); } } } // متادیتای محتوا — تصویرِ کاور با شناسه (`cover_image_id`) می‌آید. final metadataRaw = json['metadata']; String? coverImageId; String? metaCategory; String? metaHeadquarter; String? metaType; String? metaStoryType; if (metadataRaw is Map) { final cid = metadataRaw['cover_image_id']; if (cid is String && cid.isNotEmpty) coverImageId = cid; final cat = metadataRaw['category']; if (cat is String && cat.isNotEmpty) metaCategory = cat; final hq = metadataRaw['headquarter']; if (hq is String && hq.isNotEmpty) metaHeadquarter = hq; final t = metadataRaw['type']; if (t is String && t.isNotEmpty) metaType = t; // اگر duration در metadata باشد (بر حسب ثانیه). final dur = metadataRaw['duration'] ?? metadataRaw['mediaDuration'] ?? metadataRaw['audio_duration']; if (dur is num) { mediaDuration ??= dur.toInt(); } else if (dur is String) { final parsed = int.tryParse(dur); if (parsed != null) mediaDuration ??= parsed; } // story-type ممکن است string یا array باشد ($multi). final st = metadataRaw['story-type'] ?? metadataRaw['story_type']; if (st is String && st.isNotEmpty) { metaStoryType = st; } else if (st is List && st.isNotEmpty) { final first = st.first; if (first is String && first.isNotEmpty) metaStoryType = first; } } return ContentItem( id: json['id'] as String, title: (json['title'] ?? '') as String, summary: json['summary'] as String?, status: json['status'] as String?, author: json['author'] as String?, publishedAt: publishedAt, createdAt: createdAt, keywords: keywords, tagNames: tagNames, category: json['category'] is Map ? ContentCategory.fromJson( Map.from(json['category'] as Map)) : null, likeCount: (json['likeCount'] as num?)?.toInt() ?? 0, readCount: (json['readCount'] as num?)?.toInt() ?? 0, isLiked: json['isLiked'] == true, isBookmarked: json['isBookmarked'] == true, isRead: json['isRead'] == true, body: body, coverImage: cover, coverImageId: coverImageId, metaCategory: metaCategory, metaHeadquarter: metaHeadquarter, metaType: metaType, metaStoryType: metaStoryType, audioUrl: audio, videoUrl: video, audioId: audioIdLocal, videoId: videoIdLocal, mediaDuration: mediaDuration, ); } ContentItem copyWith({ bool? isLiked, bool? isBookmarked, bool? isRead, int? likeCount, int? readCount, String? coverImage, String? audioUrl, String? videoUrl, int? mediaDuration, }) { return ContentItem( id: id, title: title, summary: summary, status: status, author: author, publishedAt: publishedAt, createdAt: createdAt, keywords: keywords, tagNames: tagNames, category: category, likeCount: likeCount ?? this.likeCount, readCount: readCount ?? this.readCount, isLiked: isLiked ?? this.isLiked, isBookmarked: isBookmarked ?? this.isBookmarked, isRead: isRead ?? this.isRead, body: body, coverImage: coverImage ?? this.coverImage, coverImageId: coverImageId, metaCategory: metaCategory, metaHeadquarter: metaHeadquarter, metaType: metaType, metaStoryType: metaStoryType, audioUrl: audioUrl ?? this.audioUrl, videoUrl: videoUrl ?? this.videoUrl, audioId: audioId, videoId: videoId, mediaDuration: mediaDuration ?? this.mediaDuration, ); } } /// نتیجه‌ی paginated از nestjs-paginate. class ContentPage { final List data; final int currentPage; final int totalPages; final int totalItems; final int itemsPerPage; const ContentPage({ required this.data, required this.currentPage, required this.totalPages, required this.totalItems, required this.itemsPerPage, }); factory ContentPage.fromJson(Map json) { final dataRaw = json['data']; final items = dataRaw is List ? dataRaw .map((e) => ContentItem.fromJson(Map.from(e))) .toList() : []; final meta = (json['meta'] as Map?) ?? const {}; return ContentPage( data: items, currentPage: (meta['currentPage'] as num?)?.toInt() ?? 1, totalPages: (meta['totalPages'] as num?)?.toInt() ?? 1, totalItems: (meta['totalItems'] as num?)?.toInt() ?? items.length, itemsPerPage: (meta['itemsPerPage'] as num?)?.toInt() ?? items.length, ); } bool get hasMore => currentPage < totalPages; }