Правка импорта
This commit is contained in:
+1
-1
@@ -102,7 +102,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
|||||||
|
|
||||||
if (filterType == "files")
|
if (filterType == "files")
|
||||||
{
|
{
|
||||||
return mType != "image" && mType != "video" && mType != "link";
|
return mType != "image" && mType != "video" && mType != "link" && !isGif;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filterType == "media")
|
if (filterType == "media")
|
||||||
|
|||||||
@@ -43,15 +43,46 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
var doc = await _parser.ParseDocumentAsync(htmlStream, ct);
|
var doc = await _parser.ParseDocumentAsync(htmlStream, ct);
|
||||||
var messages = new List<TelegramMessage>();
|
var messages = new List<TelegramMessage>();
|
||||||
string? lastSenderName = null;
|
string? lastSenderName = null;
|
||||||
|
DateTime? lastCreatedAt = null;
|
||||||
|
|
||||||
var messageNodes = doc.QuerySelectorAll(".message");
|
var messageNodes = doc.QuerySelectorAll(".message");
|
||||||
foreach (var node in messageNodes)
|
foreach (var node in messageNodes)
|
||||||
{
|
{
|
||||||
// STOP ALL MERGING OF SEPARATE NODES.
|
bool isJoined = node.ClassList.Contains("joined");
|
||||||
// Telegram HTML nodes represent visual bubbles. If we merge them, we break the original hierarchy
|
var media = ExtractMedia(node, baseDirInZip);
|
||||||
// and often misplace captions or hide media.
|
var textNode = node.QuerySelector(".text");
|
||||||
|
var hasText = textNode != null && !string.IsNullOrWhiteSpace(textNode.TextContent);
|
||||||
|
|
||||||
|
var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]");
|
||||||
|
var dateStr = dateNode?.GetAttribute("title") ?? "";
|
||||||
|
DateTime? currentAt = ParseDate(dateStr);
|
||||||
|
|
||||||
|
// REFINED ALBUM MERGING LOGIC:
|
||||||
|
// 1. Current is .joined
|
||||||
|
// 2. Current has media but NO text (important: if it has text, it's a new bubble group)
|
||||||
|
// 3. Sender is the same as previous message
|
||||||
|
// 4. Time difference is minimal (<= 2 seconds). Telegram albums usually share the exact same timestamp.
|
||||||
|
if (isJoined && media.Count > 0 && !hasText && messages.Count > 0)
|
||||||
|
{
|
||||||
|
var prev = messages[messages.Count - 1];
|
||||||
|
bool isSameSender = lastSenderName != null; // Since it's 'joined', it's the same visual group
|
||||||
|
bool isCloseInTime = lastCreatedAt.HasValue && currentAt.HasValue &&
|
||||||
|
Math.Abs((currentAt.Value - lastCreatedAt.Value).TotalSeconds) <= 2;
|
||||||
|
|
||||||
|
if (isSameSender && isCloseInTime)
|
||||||
|
{
|
||||||
|
var mergedMedia = (prev.Media ?? new List<TelegramMedia>()).Concat(media).ToList();
|
||||||
|
messages[messages.Count - 1] = prev with { Media = mergedMedia };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var msg = ParseSingleMessage(node, baseDirInZip, ref lastSenderName);
|
var msg = ParseSingleMessage(node, baseDirInZip, ref lastSenderName);
|
||||||
if (msg != null) messages.Add(msg);
|
if (msg != null)
|
||||||
|
{
|
||||||
|
messages.Add(msg);
|
||||||
|
lastCreatedAt = currentAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
@@ -82,11 +113,9 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
if (idAttr.StartsWith("message")) long.TryParse(idAttr.Replace("message", ""), out numericId);
|
if (idAttr.StartsWith("message")) long.TryParse(idAttr.Replace("message", ""), out numericId);
|
||||||
|
|
||||||
var fromNameNode = node.QuerySelector(".body > .from_name");
|
var fromNameNode = node.QuerySelector(".body > .from_name");
|
||||||
// Ignore forwarded from_name as message sender
|
|
||||||
if (fromNameNode != null && fromNameNode.Closest(".forwarded") != null) fromNameNode = null;
|
if (fromNameNode != null && fromNameNode.Closest(".forwarded") != null) fromNameNode = null;
|
||||||
|
|
||||||
var senderName = fromNameNode != null ? CleanName(fromNameNode) : lastSenderName;
|
var senderName = fromNameNode != null ? CleanName(fromNameNode) : lastSenderName;
|
||||||
// If it's a new bubble group (no "joined"), update lastSenderName
|
|
||||||
if (senderName != null && !node.ClassList.Contains("joined")) lastSenderName = senderName;
|
if (senderName != null && !node.ClassList.Contains("joined")) lastSenderName = senderName;
|
||||||
|
|
||||||
var textNode = node.QuerySelector(".text");
|
var textNode = node.QuerySelector(".text");
|
||||||
@@ -94,30 +123,10 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
|
|
||||||
var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]");
|
var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]");
|
||||||
var dateStr = dateNode?.GetAttribute("title") ?? "";
|
var dateStr = dateNode?.GetAttribute("title") ?? "";
|
||||||
DateTime createdAt = DateTime.UtcNow;
|
DateTime createdAt = ParseDate(dateStr) ?? DateTime.UtcNow;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(dateStr))
|
|
||||||
{
|
|
||||||
var cleanDateStr = dateStr.Trim();
|
|
||||||
var formats = new[] { "dd.MM.yyyy HH:mm:ss 'UTC'zzz", "dd.MM.yyyy HH:mm:ss", "d.MM.yyyy HH:mm:ss", "dd.M.yyyy HH:mm:ss", "d.M.yyyy HH:mm:ss" };
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (DateTimeOffset.TryParseExact(cleanDateStr, formats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto))
|
|
||||||
{
|
|
||||||
createdAt = dto.UtcDateTime;
|
|
||||||
}
|
|
||||||
else if (DateTimeOffset.TryParse(cleanDateStr.Replace("UTC", ""), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto2))
|
|
||||||
{
|
|
||||||
createdAt = dto2.UtcDateTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaList = ExtractMedia(node, baseDir);
|
var mediaList = ExtractMedia(node, baseDir);
|
||||||
|
|
||||||
// Replies
|
|
||||||
string? replyToId = null;
|
string? replyToId = null;
|
||||||
var replyNode = node.QuerySelector(".reply_to a[href^=\"#go_to_message\"]");
|
var replyNode = node.QuerySelector(".reply_to a[href^=\"#go_to_message\"]");
|
||||||
if (replyNode != null)
|
if (replyNode != null)
|
||||||
@@ -126,12 +135,10 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
replyToId = href?.Replace("#go_to_message", "message");
|
replyToId = href?.Replace("#go_to_message", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forwards
|
|
||||||
string? forwardedFrom = null;
|
string? forwardedFrom = null;
|
||||||
var forwardNode = node.QuerySelector(".forwarded .from_name");
|
var forwardNode = node.QuerySelector(".forwarded .from_name");
|
||||||
if (forwardNode != null) forwardedFrom = CleanName(forwardNode);
|
if (forwardNode != null) forwardedFrom = CleanName(forwardNode);
|
||||||
|
|
||||||
// Reactions
|
|
||||||
var reactions = new List<TelegramReaction>();
|
var reactions = new List<TelegramReaction>();
|
||||||
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
|
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
|
||||||
foreach (var r in reactionNodes)
|
foreach (var r in reactionNodes)
|
||||||
@@ -153,11 +160,20 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DateTime? ParseDate(string? dateStr)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(dateStr)) return null;
|
||||||
|
var cleanDateStr = dateStr.Trim();
|
||||||
|
var formats = new[] { "dd.MM.yyyy HH:mm:ss 'UTC'zzz", "dd.MM.yyyy HH:mm:ss", "d.MM.yyyy HH:mm:ss", "dd.M.yyyy HH:mm:ss", "d.M.yyyy HH:mm:ss" };
|
||||||
|
|
||||||
|
if (DateTimeOffset.TryParseExact(cleanDateStr, formats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto)) return dto.UtcDateTime;
|
||||||
|
if (DateTimeOffset.TryParse(cleanDateStr.Replace("UTC", ""), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto2)) return dto2.UtcDateTime;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private List<TelegramMedia> ExtractMedia(IElement node, string baseDir)
|
private List<TelegramMedia> ExtractMedia(IElement node, string baseDir)
|
||||||
{
|
{
|
||||||
var mediaList = new List<TelegramMedia>();
|
var mediaList = new List<TelegramMedia>();
|
||||||
|
|
||||||
// Look for links that represent media containers
|
|
||||||
var allLinks = node.QuerySelectorAll("a[href]").ToList();
|
var allLinks = node.QuerySelectorAll("a[href]").ToList();
|
||||||
|
|
||||||
foreach (var link in allLinks)
|
foreach (var link in allLinks)
|
||||||
@@ -165,7 +181,6 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
var href = NormalizeHref(link.GetAttribute("href"));
|
var href = NormalizeHref(link.GetAttribute("href"));
|
||||||
if (href == null || !href.Contains("/")) continue;
|
if (href == null || !href.Contains("/")) continue;
|
||||||
|
|
||||||
// Skip internal pages
|
|
||||||
if (href.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) continue;
|
if (href.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
var fullPath = BuildPath(baseDir, href);
|
var fullPath = BuildPath(baseDir, href);
|
||||||
@@ -174,28 +189,15 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
var fileName = Path.GetFileName(href);
|
var fileName = Path.GetFileName(href);
|
||||||
var mimeType = GetMimeType(href);
|
var mimeType = GetMimeType(href);
|
||||||
|
|
||||||
// ANIMATION DETECTION:
|
|
||||||
// 1. Check classes (standard TG export)
|
|
||||||
bool hasGifClass = link.ClassList.Contains("animated_wrap") || link.ClassList.Contains("media_video");
|
bool hasGifClass = link.ClassList.Contains("animated_wrap") || link.ClassList.Contains("media_video");
|
||||||
|
|
||||||
// 2. Check full text of the link container for "Animation" string
|
|
||||||
// This is the most reliable way to find TG "GIFs" which are actually MP4s
|
|
||||||
bool hasAnimationText = link.TextContent.Contains("Animation", StringComparison.OrdinalIgnoreCase);
|
bool hasAnimationText = link.TextContent.Contains("Animation", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (hasGifClass || hasAnimationText)
|
if (hasGifClass || hasAnimationText) mimeType = "image/gif";
|
||||||
{
|
if (link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages")) mimeType = "audio/ogg";
|
||||||
mimeType = "image/gif";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages"))
|
|
||||||
{
|
|
||||||
mimeType = "audio/ogg";
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaList.Add(new TelegramMedia(fullPath, fileName, mimeType));
|
mediaList.Add(new TelegramMedia(fullPath, fileName, mimeType));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alternative check for voice messages in audio tags
|
|
||||||
foreach (var audioEl in node.QuerySelectorAll("audio[src]"))
|
foreach (var audioEl in node.QuerySelectorAll("audio[src]"))
|
||||||
{
|
{
|
||||||
var href = NormalizeHref(audioEl.GetAttribute("src"));
|
var href = NormalizeHref(audioEl.GetAttribute("src"));
|
||||||
|
|||||||
@@ -308,10 +308,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
|
|
||||||
const tabsConfig = [
|
const tabsConfig = [
|
||||||
{ key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length },
|
{ key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length },
|
||||||
{ key: 'gifs' as const, label: t('gifs') || 'GIF', icon: Play, count: sortedGifs.length },
|
{ key: 'gifs' as const, label: 'GIF', icon: Play, count: sortedGifs.length },
|
||||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
|
{ key: 'media' as const, label: t('mediaTab') || 'Медиа', icon: ImageIcon, count: sortedMedia.length },
|
||||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
{ key: 'files' as const, label: t('filesTab') || 'Файлы', icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
||||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
{ key: 'links' as const, label: t('linksTab') || 'Ссылки', icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
||||||
];
|
];
|
||||||
|
|
||||||
const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0);
|
const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0);
|
||||||
@@ -336,7 +336,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
className="fixed right-3 top-3 bottom-3 w-[720px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
||||||
@@ -496,7 +496,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
className={`flex-1 flex flex-col items-center justify-center gap-1.5 py-3 text-[10px] font-black uppercase tracking-wider transition-all border-r last:border-r-0 border-white/5 ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'bg-white/5 text-knot-400'
|
? 'bg-white/5 text-knot-400'
|
||||||
: 'text-zinc-500 hover:text-zinc-300'
|
: 'text-zinc-500 hover:text-zinc-300'
|
||||||
|
|||||||
Reference in New Issue
Block a user