415 lines
15 KiB
Dart
415 lines
15 KiB
Dart
// 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;
|
||
|
||
/// آدرس اولین فایل صوتی (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<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;
|
||
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<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,
|
||
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<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;
|
||
}
|