// 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; /// منبعِ خبر در metadata (مثلا "مهر"، "ایسنا"). برای کارت‌های اخبار. final String? metaSource; /// لیست دسته‌ها در metadata.categories (پنل multi-select). final List metaCategories; /// آدرس اولین فایل صوتی (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; /// لینک خارجی (در حال حاضر برای بنر/اینفوگرافی) که از `metadata.link` /// پنل می‌آید. اگر null یا خالی باشد، اپ دکمه‌ی «مشاهده منبع» را نمایش /// نمی‌دهد. final String? metaLink; /// امتیاز نهایی محور X / Y برای فرصت یا تهدید (افق ۱ ساله). /// از `metadata.x1` / `metadata.y1` خوانده می‌شود. در محتوای غیر-SWOT صفر. final double metaScoreX1; final double metaScoreY1; final double metaScoreX3; final double metaScoreY3; final double metaScoreX7; final double metaScoreY7; 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.metaSource, this.metaCategories = const [], this.audioUrl, this.videoUrl, this.audioId, this.videoId, this.mediaDuration, this.metaLink, this.metaScoreX1 = 0, this.metaScoreY1 = 0, this.metaScoreX3 = 0, this.metaScoreY3 = 0, this.metaScoreX7 = 0, this.metaScoreY7 = 0, }); 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; String? metaSource; String? metaLinkLocal; final List metaCategoriesList = []; double metaScoreX1Local = 0; double metaScoreY1Local = 0; double metaScoreX3Local = 0; double metaScoreY3Local = 0; double metaScoreX7Local = 0; double metaScoreY7Local = 0; if (metadataRaw is Map) { final cid = metadataRaw['cover_image_id']; if (cid is String && cid.isNotEmpty) coverImageId = cid; // `category` می‌تواند string (legacy) یا List (multi-select از پنل) // باشد. علاوه بر کلید `category`، با پیشوند schema هم چک می‌شود. dynamic cat = metadataRaw['category']; if (cat == null) { for (final k in metadataRaw.keys) { if (k is String && k.contains(':category:')) { cat = metadataRaw[k]; break; } } } if (cat is String && cat.isNotEmpty) { metaCategory = cat; } else if (cat is List) { for (final v in cat) { if (v is String && v.isNotEmpty) metaCategoriesList.add(v); } } // headquarter می‌تواند کلید `headquarter` یا با پیشوند schema // (`$single:headquarter:...`) ذخیره شده باشد. dynamic hq = metadataRaw['headquarter']; if (hq == null) { for (final k in metadataRaw.keys) { if (k is String && k.contains(':headquarter:')) { hq = metadataRaw[k]; break; } } } if (hq is String && hq.isNotEmpty) metaHeadquarter = hq; final t = metadataRaw['type']; if (t is String && t.isNotEmpty) metaType = t; final src = metadataRaw['source'] ?? metadataRaw['reference'] ?? metadataRaw['publisher']; if (src is String && src.isNotEmpty) metaSource = src; // لینک خارجی (برای بنر/اینفوگرافی). از کلید ساده 'link' یا کلید // schema-prefixed مثل '$input:link:' خوانده می‌شود. dynamic lnk = metadataRaw['link']; if (lnk == null) { for (final k in metadataRaw.keys) { if (k is String && k.contains(':link:')) { lnk = metadataRaw[k]; break; } } } if (lnk is String && lnk.trim().isNotEmpty) metaLinkLocal = lnk.trim(); // multi-select categories از پنل (آرایه) final catsRaw = metadataRaw['categories']; if (catsRaw is List) { for (final v in catsRaw) { if (v is String && v.isNotEmpty) metaCategoriesList.add(v); } } // اگر 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; } // امتیازهای SWOT (محاسبه‌شده‌ی وزن‌دار). در دسته‌ی غیر-SWOT وجود ندارند. double parseNum(dynamic v) { if (v is num) return v.toDouble(); if (v is String) return double.tryParse(v) ?? 0; return 0; } metaScoreX1Local = parseNum(metadataRaw['x1']); metaScoreY1Local = parseNum(metadataRaw['y1']); metaScoreX3Local = parseNum(metadataRaw['x3']); metaScoreY3Local = parseNum(metadataRaw['y3']); metaScoreX7Local = parseNum(metadataRaw['x7']); metaScoreY7Local = parseNum(metadataRaw['y7']); // fallback به ساختار تو در تو `axisScores.oneYear.x` و غیره final axisScoresRaw = metadataRaw['axisScores']; if (axisScoresRaw is Map) { final oneYear = axisScoresRaw['oneYear']; if (oneYear is Map) { if (metaScoreX1Local == 0) metaScoreX1Local = parseNum(oneYear['x']); if (metaScoreY1Local == 0) metaScoreY1Local = parseNum(oneYear['y']); } final threeYear = axisScoresRaw['threeYear']; if (threeYear is Map) { if (metaScoreX3Local == 0) metaScoreX3Local = parseNum(threeYear['x']); if (metaScoreY3Local == 0) metaScoreY3Local = parseNum(threeYear['y']); } final sevenYear = axisScoresRaw['sevenYear']; if (sevenYear is Map) { if (metaScoreX7Local == 0) metaScoreX7Local = parseNum(sevenYear['x']); if (metaScoreY7Local == 0) metaScoreY7Local = parseNum(sevenYear['y']); } } } 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, metaSource: metaSource, metaCategories: metaCategoriesList, audioUrl: audio, videoUrl: video, audioId: audioIdLocal, videoId: videoIdLocal, mediaDuration: mediaDuration, metaLink: metaLinkLocal, metaScoreX1: metaScoreX1Local, metaScoreY1: metaScoreY1Local, metaScoreX3: metaScoreX3Local, metaScoreY3: metaScoreY3Local, metaScoreX7: metaScoreX7Local, metaScoreY7: metaScoreY7Local, ); } 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, metaSource: metaSource, metaCategories: metaCategories, audioUrl: audioUrl ?? this.audioUrl, videoUrl: videoUrl ?? this.videoUrl, audioId: audioId, videoId: videoId, mediaDuration: mediaDuration ?? this.mediaDuration, metaLink: metaLink, metaScoreX1: metaScoreX1, metaScoreY1: metaScoreY1, metaScoreX3: metaScoreX3, metaScoreY3: metaScoreY3, metaScoreX7: metaScoreX7, metaScoreY7: metaScoreY7, ); } } /// نتیجه‌ی 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; }