2026-11-09 16:39:06 +03:30
|
|
|
|
import { jalaliToDate } from '@/lib/jalali';
|
|
|
|
|
|
|
|
|
|
|
|
export type ActivityThreadEntry = {
|
|
|
|
|
|
at: Date | null;
|
|
|
|
|
|
dateLabel: string;
|
|
|
|
|
|
timeLabel: string;
|
|
|
|
|
|
author: string;
|
|
|
|
|
|
body: string;
|
2026-11-10 21:31:21 +03:30
|
|
|
|
/** شناسهٔ فایلهای چسبیده به این یادداشت (عکس/صوت/فایل) */
|
|
|
|
|
|
attachmentIds?: string[];
|
2026-11-09 16:39:06 +03:30
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const STAMP = /^\[(.+?) ساعت (\S+?)(?: — (.+))?\]$/;
|
2026-11-10 21:31:21 +03:30
|
|
|
|
const ATTACHMENTS = /^\[\[attachments:([^\]]+)\]\]$/i;
|
|
|
|
|
|
const COMMENT_MEDIA_WINDOW_MS = 3 * 60 * 1000;
|
2026-11-09 16:39:06 +03:30
|
|
|
|
|
|
|
|
|
|
/** یادداشت فعالیت را به نظرهای جدا با مهر تاریخ/ساعت/نویسنده تبدیل میکند. */
|
|
|
|
|
|
export function parseActivityThread(raw: string | null | undefined): ActivityThreadEntry[] | null {
|
|
|
|
|
|
const text = (raw ?? '').replace(/\r\n/g, '\n').trim();
|
|
|
|
|
|
if (!text) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const entries: ActivityThreadEntry[] = [];
|
|
|
|
|
|
const preamble: string[] = [];
|
|
|
|
|
|
let current: ActivityThreadEntry | null = null;
|
|
|
|
|
|
let sawStamp = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (const line of text.split('\n')) {
|
2026-11-10 21:31:21 +03:30
|
|
|
|
const trimmed = line.trim();
|
|
|
|
|
|
const stamp = trimmed.match(STAMP);
|
|
|
|
|
|
if (stamp) {
|
2026-11-09 16:39:06 +03:30
|
|
|
|
sawStamp = true;
|
|
|
|
|
|
if (current) entries.push(trimEntry(current));
|
|
|
|
|
|
current = {
|
2026-11-10 21:31:21 +03:30
|
|
|
|
at: stampToDate(stamp[1] ?? '', stamp[2] ?? ''),
|
|
|
|
|
|
dateLabel: (stamp[1] ?? '').trim(),
|
|
|
|
|
|
timeLabel: (stamp[2] ?? '').trim(),
|
|
|
|
|
|
author: (stamp[3] ?? '').trim(),
|
2026-11-09 16:39:06 +03:30
|
|
|
|
body: '',
|
2026-11-10 21:31:21 +03:30
|
|
|
|
attachmentIds: [],
|
2026-11-09 16:39:06 +03:30
|
|
|
|
};
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-11-10 21:31:21 +03:30
|
|
|
|
const attach = trimmed.match(ATTACHMENTS);
|
|
|
|
|
|
if (attach && current) {
|
|
|
|
|
|
current.attachmentIds = attach[1]!
|
|
|
|
|
|
.split(',')
|
|
|
|
|
|
.map((id) => id.trim())
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-11-09 16:39:06 +03:30
|
|
|
|
if (current) current.body += `${current.body ? '\n' : ''}${line}`;
|
|
|
|
|
|
else preamble.push(line);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (current) entries.push(trimEntry(current));
|
|
|
|
|
|
if (!sawStamp) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const head = preamble.join('\n').trim();
|
|
|
|
|
|
if (head) {
|
2026-11-10 21:31:21 +03:30
|
|
|
|
entries.unshift({
|
|
|
|
|
|
at: null,
|
|
|
|
|
|
dateLabel: '',
|
|
|
|
|
|
timeLabel: '',
|
|
|
|
|
|
author: '',
|
|
|
|
|
|
body: head,
|
|
|
|
|
|
attachmentIds: [],
|
|
|
|
|
|
});
|
2026-11-09 16:39:06 +03:30
|
|
|
|
}
|
|
|
|
|
|
return entries;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 21:31:21 +03:30
|
|
|
|
/** برگرداندن متن ذخیرهشونده از نظرهای جدا. */
|
|
|
|
|
|
export function serializeActivityThread(entries: ActivityThreadEntry[]): string {
|
|
|
|
|
|
return entries
|
|
|
|
|
|
.map((entry) => {
|
|
|
|
|
|
const ids = (entry.attachmentIds ?? []).filter(Boolean);
|
|
|
|
|
|
const attachLine = ids.length ? `\n[[attachments:${ids.join(',')}]]` : '';
|
|
|
|
|
|
if (!entry.dateLabel) return `${entry.body}${attachLine}`.trim();
|
|
|
|
|
|
const stamp = `[${entry.dateLabel} ساعت ${entry.timeLabel}${entry.author ? ` — ${entry.author}` : ''}]`;
|
|
|
|
|
|
return entry.body ? `${stamp}\n${entry.body}${attachLine}` : `${stamp}${attachLine}`;
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter((chunk) => chunk.trim())
|
|
|
|
|
|
.join('\n\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** فایلها را به یادداشتها میچسباند: اول با شناسهٔ صریح، بعد با نزدیکی زمان. */
|
|
|
|
|
|
export function bindThreadMedia<T extends { id: string; createdAt: string }>(
|
|
|
|
|
|
entries: ActivityThreadEntry[],
|
|
|
|
|
|
images: T[],
|
|
|
|
|
|
windowMs = COMMENT_MEDIA_WINDOW_MS,
|
|
|
|
|
|
): { blocks: Array<ActivityThreadEntry & { media: T[] }>; loose: T[] } {
|
|
|
|
|
|
const blocks = entries.map((entry) => ({ ...entry, media: [] as T[] }));
|
|
|
|
|
|
const byId = new Map(images.map((image) => [image.id, image]));
|
|
|
|
|
|
const used = new Set<string>();
|
|
|
|
|
|
|
|
|
|
|
|
blocks.forEach((block) => {
|
|
|
|
|
|
for (const id of block.attachmentIds ?? []) {
|
|
|
|
|
|
const image = byId.get(id);
|
|
|
|
|
|
if (!image || used.has(id)) continue;
|
|
|
|
|
|
block.media.push(image);
|
|
|
|
|
|
used.add(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
for (const image of images) {
|
|
|
|
|
|
if (used.has(image.id)) continue;
|
|
|
|
|
|
const at = new Date(image.createdAt).getTime();
|
|
|
|
|
|
if (Number.isNaN(at)) continue;
|
|
|
|
|
|
let best = -1;
|
|
|
|
|
|
let bestDelta = Number.POSITIVE_INFINITY;
|
|
|
|
|
|
blocks.forEach((block, index) => {
|
|
|
|
|
|
if (!block.at) return;
|
|
|
|
|
|
const delta = block.at.getTime() - at;
|
|
|
|
|
|
if (delta >= -60_000 && delta < bestDelta) {
|
|
|
|
|
|
best = index;
|
|
|
|
|
|
bestDelta = delta;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if (best >= 0 && bestDelta <= windowMs) {
|
|
|
|
|
|
blocks[best]!.media.push(image);
|
|
|
|
|
|
used.add(image.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const firstStamp = blocks.find((block) => block.at)?.at?.getTime() ?? Number.POSITIVE_INFINITY;
|
|
|
|
|
|
const preamble = blocks.find((block) => !block.at);
|
|
|
|
|
|
const loose: T[] = [];
|
|
|
|
|
|
for (const image of images) {
|
|
|
|
|
|
if (used.has(image.id)) continue;
|
|
|
|
|
|
const at = new Date(image.createdAt).getTime();
|
|
|
|
|
|
if (preamble && !Number.isNaN(at) && at < firstStamp) preamble.media.push(image);
|
|
|
|
|
|
else loose.push(image);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { blocks, loose };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-09 16:39:06 +03:30
|
|
|
|
function trimEntry(entry: ActivityThreadEntry): ActivityThreadEntry {
|
2026-11-10 21:31:21 +03:30
|
|
|
|
return {
|
|
|
|
|
|
...entry,
|
|
|
|
|
|
body: entry.body.trim(),
|
|
|
|
|
|
attachmentIds: [...new Set((entry.attachmentIds ?? []).filter(Boolean))],
|
|
|
|
|
|
};
|
2026-11-09 16:39:06 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function latinDigits(value: string): string {
|
|
|
|
|
|
const fa = '۰۱۲۳۴۵۶۷۸۹';
|
|
|
|
|
|
const ar = '٠١٢٣٤٥٦٧٨٩';
|
|
|
|
|
|
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
|
|
|
|
|
const faIndex = fa.indexOf(ch);
|
|
|
|
|
|
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stampToDate(dateLabel: string, timeLabel: string): Date | null {
|
|
|
|
|
|
const date = latinDigits(dateLabel).match(/(\d{4})\/(\d{1,2})\/(\d{1,2})/);
|
|
|
|
|
|
if (!date) return null;
|
|
|
|
|
|
const at = jalaliToDate(Number(date[1]), Number(date[2]), Number(date[3]));
|
|
|
|
|
|
if (Number.isNaN(at.getTime())) return null;
|
|
|
|
|
|
const time = latinDigits(timeLabel).match(/(\d{1,2}):(\d{2})/);
|
|
|
|
|
|
if (time) at.setHours(Number(time[1]), Number(time[2]), 0, 0);
|
|
|
|
|
|
return at;
|
|
|
|
|
|
}
|