didvan-app/lib/models/content/content_item.dart

552 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<String, dynamic> 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<String> tagNames;
final List<String> 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<String> 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<String, dynamic> 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()
: <String>[];
// tags از relation Tag در content-service: [{id, name, color, ...}, ...]
final tagsRaw = json['tags'];
final tagNames = <String>[];
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<String> 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<String> (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<String, dynamic>.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<ContentItem> 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<String, dynamic> json) {
final dataRaw = json['data'];
final items = dataRaw is List
? dataRaw
.map((e) => ContentItem.fromJson(Map<String, dynamic>.from(e)))
.toList()
: <ContentItem>[];
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;
}