@emaxe/tuigram 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ import blessed from "neo-blessed";
2
+ import { fg } from "../theme.js";
3
+
4
+ /**
5
+ * Создаёт нижнюю строку состояния (Status Bar) с подсказками и временными тостами.
6
+ * @param {blessed.Widgets.Screen} screen
7
+ * @param {object} theme
8
+ * @returns {blessed.Widgets.BoxElement & { showMessage: (text: string, type?: string, duration?: number) => void }}
9
+ */
10
+ export function createStatusBar(screen, theme) {
11
+ const statusBar = blessed.box({
12
+ parent: screen,
13
+ bottom: 0,
14
+ left: 0,
15
+ width: "100%",
16
+ height: 1,
17
+ tags: true,
18
+ style: {
19
+ bg: theme.status.bg,
20
+ fg: theme.status.fg,
21
+ },
22
+ });
23
+
24
+ let currentTimeout = null;
25
+
26
+ const key = (label) => `{bold}${fg(theme.accent, label)}{/bold}`;
27
+ const defaultHints =
28
+ ` ${key("[Tab]")} Панель ${fg(theme.dim, "│")} ${key("[Enter]")} Выбрать/Отправить ` +
29
+ `${fg(theme.dim, "│")} ${key("[1-6]")} Вкладки ${fg(theme.dim, "│")} ${key("[/]")} Поиск ` +
30
+ `${fg(theme.dim, "│")} ${key("[F1]")} Помощь ${fg(theme.dim, "│")} ${key("[Ctrl+A]")} Действия ` +
31
+ `${fg(theme.dim, "│")} ${key("[Ctrl+P]")} Инфо ${fg(theme.dim, "│")} ${key("[Ctrl+Q]")} Выход`;
32
+
33
+ statusBar.setContent(defaultHints);
34
+
35
+ /**
36
+ * Показывает временное статусное сообщение (тост).
37
+ * @param {string} text
38
+ * @param {"info"|"success"|"warning"|"error"} [type="info"]
39
+ * @param {number} [duration=4000]
40
+ */
41
+ statusBar.showMessage = function (text, type = "info", duration = 4000) {
42
+ if (currentTimeout) {
43
+ clearTimeout(currentTimeout);
44
+ }
45
+
46
+ const palette = {
47
+ info: [theme.info, "ℹ"],
48
+ success: [theme.success, "✓"],
49
+ warning: [theme.warning, "⚠️"],
50
+ error: [theme.error, "✕"],
51
+ };
52
+ const [color, icon] = palette[type] || palette.info;
53
+
54
+ statusBar.setContent(` ${fg(color, `${icon} ${text}`)}`);
55
+ screen.render();
56
+
57
+ currentTimeout = setTimeout(() => {
58
+ statusBar.setContent(defaultHints);
59
+ screen.render();
60
+ }, duration);
61
+ };
62
+
63
+ return statusBar;
64
+ }
@@ -0,0 +1,60 @@
1
+ import blessed from "neo-blessed";
2
+
3
+ /**
4
+ * Глобальные сочетания, которые должны работать даже когда поле ввода или строка
5
+ * поиска перехватили клавиатуру (blessed выставляет screen.grabKeys = true и
6
+ * перестаёт рассылать screen-события всему, чего нет в ignoreLocked).
7
+ */
8
+ export const GLOBAL_KEYS = [
9
+ "C-c",
10
+ "C-q",
11
+ "tab",
12
+ "S-tab",
13
+ "f1",
14
+ "C-o",
15
+ "C-r",
16
+ "C-e",
17
+ "C-p",
18
+ // escape — чтобы можно было прервать отправку файла из любого места
19
+ "escape",
20
+ ];
21
+
22
+ /**
23
+ * Создаёт и настраивает главный экран терминального интерфейса.
24
+ * @param {object} [options]
25
+ * @param {object} [options.theme] активная тема (для фона экрана)
26
+ * @param {() => void} [options.onExit] вызывается перед завершением процесса по Ctrl+C
27
+ * @returns {blessed.Widgets.Screen}
28
+ */
29
+ export function createScreen({ theme, onExit } = {}) {
30
+ const screen = blessed.screen({
31
+ smartCSR: true,
32
+ title: "TuiGram - Telegram Terminal Client",
33
+ fullUnicode: true,
34
+ dockBorders: true,
35
+ cursor: {
36
+ synthetic: true,
37
+ blink: true,
38
+ shape: "line",
39
+ },
40
+ style: {
41
+ bg: theme?.bg ?? "black",
42
+ fg: theme?.fg ?? "white",
43
+ }
44
+ });
45
+
46
+ screen.ignoreLocked = [...GLOBAL_KEYS];
47
+
48
+ // Обработка закрытия терминала или аварийного прерывания
49
+ screen.key(["C-c"], () => {
50
+ try {
51
+ onExit?.();
52
+ } catch {
53
+ // Выходим в любом случае
54
+ }
55
+ screen.destroy();
56
+ process.exit(0);
57
+ });
58
+
59
+ return screen;
60
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Цветовые схемы и стили элементов интерфейса TuiGram.
3
+ *
4
+ * Все цвета заданы hex-значениями намеренно: именованные ("blue", "cyan")
5
+ * занимают индексы 0-15, которые тема терминала перекрашивает как хочет —
6
+ * из-за этого синий фон рисовался бирюзовым, а серый текст пропадал.
7
+ *
8
+ * Важно: blessed сам сводит hex к ближайшему цвету xterm-256 и иногда попадает
9
+ * в те же индексы 0-15. Поэтому значения подобраны так, чтобы после конверсии
10
+ * индекс был >= 16 — это проверяется тестом в test/unit.test.js.
11
+ */
12
+
13
+ /**
14
+ * Собирает тему из плоской палитры, чтобы контраст задавался в одном месте.
15
+ * @param {object} p палитра
16
+ * @returns {object} тема
17
+ */
18
+ function buildTheme(p) {
19
+ return {
20
+ name: p.name,
21
+ bg: p.bg,
22
+ fg: p.fg,
23
+ surface: p.surface,
24
+ surfaceHigh: p.surfaceHigh,
25
+
26
+ // Семантика — для компонентов, которым нужен цвет «по смыслу»
27
+ accent: p.accent,
28
+ onAccent: p.onAccent,
29
+ muted: p.muted,
30
+ dim: p.dim,
31
+ success: p.green,
32
+ warning: p.yellow,
33
+ error: p.red,
34
+ info: p.cyan,
35
+
36
+ header: {
37
+ bg: p.surface,
38
+ fg: p.fgBright,
39
+ bold: true,
40
+ },
41
+ borders: {
42
+ fg: p.border,
43
+ focusFg: p.accent,
44
+ },
45
+ chatList: {
46
+ bg: p.bg,
47
+ fg: p.fg,
48
+ selectedBg: p.surfaceHigh,
49
+ selectedFg: p.fgBright,
50
+ itemHoverBg: p.surface,
51
+ itemMuted: p.dim,
52
+ itemUnreadBg: p.accent,
53
+ itemUnreadFg: p.onAccent,
54
+ pinnedFg: p.yellow,
55
+ previewFg: p.muted,
56
+ timeFg: p.dim,
57
+ },
58
+ tabs: {
59
+ bg: p.bg,
60
+ fg: p.muted,
61
+ activeBg: p.accent,
62
+ activeFg: p.onAccent,
63
+ },
64
+ chatView: {
65
+ bg: p.bg,
66
+ fg: p.fg,
67
+ incomingName: p.cyan,
68
+ outgoingName: p.green,
69
+ time: p.dim,
70
+ dateDivider: p.yellow,
71
+ replyBorder: p.muted,
72
+ systemMsg: p.yellow,
73
+ mediaFg: p.magenta,
74
+ reactionFg: p.yellow,
75
+ },
76
+ input: {
77
+ bg: p.bg,
78
+ fg: p.fgBright,
79
+ contextBg: p.surface,
80
+ contextFg: p.muted,
81
+ replyBg: p.cyan,
82
+ replyFg: p.onAccent,
83
+ editBg: p.yellow,
84
+ editFg: p.onAccent,
85
+ },
86
+ modal: {
87
+ bg: p.surface,
88
+ fg: p.fgBright,
89
+ borderFg: p.accent,
90
+ selectedBg: p.accent,
91
+ selectedFg: p.onAccent,
92
+ labelFg: p.fg,
93
+ hintFg: p.muted,
94
+ inputBg: p.bg,
95
+ inputFg: p.fgBright,
96
+ inputFocusBg: p.surfaceHigh,
97
+ inputFocusFg: p.fgBright,
98
+ buttonBg: p.green,
99
+ buttonFg: p.onAccent,
100
+ dangerBg: p.red,
101
+ dangerFg: p.onDanger,
102
+ neutralBg: p.surfaceHigh,
103
+ neutralFg: p.fg,
104
+ buttonFocusBg: p.accent,
105
+ buttonFocusFg: p.onAccent,
106
+ },
107
+ picker: {
108
+ // filemanager сам красит элементы в light-blue/light-cyan —
109
+ // эти значения подставляются вместо них (см. filePickerModal.js)
110
+ dirFg: p.accent,
111
+ fileFg: p.fgBright,
112
+ linkFg: p.cyan,
113
+ },
114
+ search: {
115
+ bg: p.bg,
116
+ fg: p.yellow,
117
+ placeholderFg: p.dim,
118
+ },
119
+ scrollbar: {
120
+ bg: p.border,
121
+ fg: p.accent,
122
+ },
123
+ status: {
124
+ bg: p.bg,
125
+ fg: p.muted,
126
+ online: p.green,
127
+ connecting: p.yellow,
128
+ offline: p.red,
129
+ },
130
+ };
131
+ }
132
+
133
+ export const themes = {
134
+ default: buildTheme({
135
+ name: "Default Dark",
136
+ bg: "#16161e",
137
+ surface: "#1f2335",
138
+ surfaceHigh: "#2f3549",
139
+ border: "#3b4261",
140
+ fg: "#a9b1d6",
141
+ fgBright: "#c0caf5",
142
+ muted: "#8288a6",
143
+ dim: "#6874a0",
144
+ accent: "#7aa2f7",
145
+ onAccent: "#16161e",
146
+ onDanger: "#16161e",
147
+ cyan: "#7dcfff",
148
+ green: "#9ece6a",
149
+ yellow: "#e0af68",
150
+ red: "#f7768e",
151
+ magenta: "#bb9af7",
152
+ }),
153
+
154
+ nord: buildTheme({
155
+ name: "Nord",
156
+ bg: "#2e3440",
157
+ surface: "#3b4252",
158
+ surfaceHigh: "#434c5e",
159
+ border: "#4c566a",
160
+ fg: "#d8dee9",
161
+ fgBright: "#eceff4",
162
+ muted: "#8b98b0",
163
+ dim: "#7e879b",
164
+ accent: "#88c0d0",
165
+ onAccent: "#2e3440",
166
+ onDanger: "#2e3440",
167
+ cyan: "#8fbcbb",
168
+ green: "#a3be8c",
169
+ yellow: "#ebcb8b",
170
+ red: "#d07a83",
171
+ magenta: "#b48ead",
172
+ }),
173
+
174
+ light: buildTheme({
175
+ name: "Light",
176
+ bg: "#f5f6f8",
177
+ surface: "#eceff4",
178
+ surfaceHigh: "#d8dee9",
179
+ border: "#c0c5ce",
180
+ fg: "#3b4252",
181
+ fgBright: "#2e3440",
182
+ muted: "#5f6672",
183
+ dim: "#6f7788",
184
+ accent: "#2d6df6",
185
+ onAccent: "#f5f6f8",
186
+ onDanger: "#f5f6f8",
187
+ cyan: "#0b7285",
188
+ green: "#2f7d32",
189
+ yellow: "#a16207",
190
+ red: "#c02c38",
191
+ magenta: "#7048b6",
192
+ }),
193
+ };
194
+
195
+ /**
196
+ * Оборачивает текст в цветовой тег blessed.
197
+ * @param {string} color hex или именованный цвет
198
+ * @param {string} text
199
+ * @returns {string}
200
+ */
201
+ export function fg(color, text) {
202
+ return `{${color}-fg}${text}{/${color}-fg}`;
203
+ }
204
+
205
+ /**
206
+ * Оборачивает текст в тег фона с явным цветом текста.
207
+ * @param {string} bgColor
208
+ * @param {string} fgColor
209
+ * @param {string} text
210
+ * @returns {string}
211
+ */
212
+ export function badge(bgColor, fgColor, text) {
213
+ return `{${bgColor}-bg}{${fgColor}-fg}${text}{/${fgColor}-fg}{/${bgColor}-bg}`;
214
+ }
215
+
216
+ /**
217
+ * Получает активную тему по её названию из конфигурации.
218
+ * @param {string} themeName
219
+ * @returns {typeof themes.default}
220
+ */
221
+ export function getTheme(themeName = "default") {
222
+ return themes[themeName] || themes.default;
223
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Разбор аргументов слэш-команд TUI.
3
+ */
4
+
5
+ /**
6
+ * Разбирает аргументы `/sendfile`.
7
+ *
8
+ * Формат: `путь [| путь ...] [-- подпись]`
9
+ * Разделитель путей — `|`, подпись отделяется первым ` -- `.
10
+ * Всё после первого ` -- ` считается подписью целиком, поэтому подпись
11
+ * сама может содержать двойное тире.
12
+ *
13
+ * @param {string|string[]} rawArgs
14
+ * @returns {{ paths: string[], caption: string }}
15
+ */
16
+ export function parseSendFileArgs(rawArgs) {
17
+ const raw = (Array.isArray(rawArgs) ? rawArgs.join(" ") : String(rawArgs ?? "")).trim();
18
+ if (!raw) return { paths: [], caption: "" };
19
+
20
+ let pathsPart = raw;
21
+ let caption = "";
22
+
23
+ const separator = raw.match(/(^|\s)--(\s|$)/);
24
+ if (separator) {
25
+ pathsPart = raw.slice(0, separator.index);
26
+ caption = raw.slice(separator.index + separator[0].length).trim();
27
+ // Разделитель в начале строки: путей нет вовсе
28
+ if (separator[1] === "") {
29
+ pathsPart = "";
30
+ caption = raw.slice(separator[0].length - (separator[2] === "" ? 0 : 1)).trim();
31
+ }
32
+ }
33
+
34
+ const paths = pathsPart
35
+ .split("|")
36
+ .map((part) => part.trim())
37
+ .filter(Boolean);
38
+
39
+ return { paths, caption };
40
+ }
@@ -0,0 +1,177 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Создаёт директорию, если она ещё не существует.
7
+ * @param {string} dirPath
8
+ */
9
+ export function ensureDir(dirPath) {
10
+ if (!fs.existsSync(dirPath)) {
11
+ fs.mkdirSync(dirPath, { recursive: true });
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Безопасно читает текстовый файл.
17
+ * @param {string} filePath
18
+ * @param {string} [defaultValue=""]
19
+ * @returns {string}
20
+ */
21
+ export function readFileSafe(filePath, defaultValue = "") {
22
+ try {
23
+ if (!fs.existsSync(filePath)) return defaultValue;
24
+ return fs.readFileSync(filePath, "utf8");
25
+ } catch {
26
+ return defaultValue;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Записывает файл, создавая родительские папки при необходимости.
32
+ * @param {string} filePath
33
+ * @param {string} content
34
+ * @param {object} [options]
35
+ */
36
+ export function writeFileSafe(filePath, content, options = {}) {
37
+ const dir = path.dirname(filePath);
38
+ ensureDir(dir);
39
+ fs.writeFileSync(filePath, content, { encoding: "utf8", ...options });
40
+ }
41
+
42
+ /**
43
+ * Сохраняет строку сессии Telegram с правами 0600 (только для владельца).
44
+ * @param {string} filePath
45
+ * @param {string} sessionString
46
+ */
47
+ export function saveSessionFile(filePath, sessionString) {
48
+ const dir = path.dirname(filePath);
49
+ ensureDir(dir);
50
+ fs.writeFileSync(filePath, sessionString.trim(), { encoding: "utf8", mode: 0o600 });
51
+ }
52
+
53
+ /**
54
+ * Читает JSON-файл или возвращает значение по умолчанию при ошибке.
55
+ * @template T
56
+ * @param {string} filePath
57
+ * @param {T} [defaultValue=null]
58
+ * @returns {T}
59
+ */
60
+ export function readJson(filePath, defaultValue = null) {
61
+ try {
62
+ if (!fs.existsSync(filePath)) return defaultValue;
63
+ const text = fs.readFileSync(filePath, "utf8");
64
+ return JSON.parse(text);
65
+ } catch {
66
+ return defaultValue;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Записывает объект в JSON-файл.
72
+ * @param {string} filePath
73
+ * @param {unknown} data
74
+ * @param {boolean} [pretty=true]
75
+ */
76
+ export function writeJson(filePath, data, pretty = true) {
77
+ const dir = path.dirname(filePath);
78
+ ensureDir(dir);
79
+ const text = JSON.stringify(data, null, pretty ? 2 : 0);
80
+ fs.writeFileSync(filePath, text, "utf8");
81
+ }
82
+
83
+ /** Расширения, которые teleproto отправляет как сжатое фото (см. Utils.isImage). */
84
+ const PHOTO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
85
+ /** Расширения, которые Telegram показывает как видео. */
86
+ const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v", ".3gp"]);
87
+
88
+ /**
89
+ * Приводит введённый пользователем путь к абсолютному:
90
+ * снимает обрамляющие кавычки, shell-экранирование (перетаскивание файла
91
+ * в терминал) и раскрывает "~".
92
+ * @param {string} raw
93
+ * @returns {string} абсолютный путь или "" для пустого ввода
94
+ */
95
+ export function resolveLocalPath(raw) {
96
+ let value = String(raw ?? "").trim();
97
+ if (!value) return "";
98
+
99
+ const quoted =
100
+ (value.startsWith('"') && value.endsWith('"')) ||
101
+ (value.startsWith("'") && value.endsWith("'"));
102
+
103
+ if (quoted && value.length >= 2) {
104
+ value = value.slice(1, -1);
105
+ } else {
106
+ // Снимаем экранирование только тех символов, которые экранирует shell,
107
+ // чтобы не покалечить windows-путь вида C:\Users\name
108
+ value = value.replace(/\\([ ()'"&!$`;|<>*?\[\]{}~#])/g, "$1");
109
+ }
110
+
111
+ value = value.trim();
112
+ if (!value) return "";
113
+
114
+ if (value === "~") {
115
+ value = os.homedir();
116
+ } else if (value.startsWith("~/") || value.startsWith("~\\")) {
117
+ value = path.join(os.homedir(), value.slice(2));
118
+ }
119
+
120
+ return path.resolve(value);
121
+ }
122
+
123
+ /**
124
+ * Определяет, чем Telegram покажет файл: фото, видео или документом.
125
+ * @param {string} filePath
126
+ * @returns {"photo"|"video"|"document"}
127
+ */
128
+ export function detectFileKind(filePath) {
129
+ const ext = path.extname(filePath).toLowerCase();
130
+ if (PHOTO_EXTENSIONS.has(ext)) return "photo";
131
+ if (VIDEO_EXTENSIONS.has(ext)) return "video";
132
+ return "document";
133
+ }
134
+
135
+ /**
136
+ * Проверяет локальный файл перед отправкой и возвращает понятную ошибку
137
+ * вместо сетевой. Единая точка валидации для TUI, слэш-команд и CLI.
138
+ * @param {string} raw путь в любом виде (с "~", кавычками, экранированием)
139
+ * @returns {{ ok: boolean, filePath: string, name?: string, size?: number, kind?: string, error?: string }}
140
+ */
141
+ export function inspectLocalFile(raw) {
142
+ const filePath = resolveLocalPath(raw);
143
+ if (!filePath) {
144
+ return { ok: false, filePath: "", error: "Не указан путь к файлу" };
145
+ }
146
+
147
+ let stat;
148
+ try {
149
+ stat = fs.statSync(filePath);
150
+ } catch {
151
+ return { ok: false, filePath, error: `Файл не найден: ${filePath}` };
152
+ }
153
+
154
+ if (stat.isDirectory()) {
155
+ return { ok: false, filePath, error: `Это папка, а не файл: ${filePath}` };
156
+ }
157
+ if (!stat.isFile()) {
158
+ return { ok: false, filePath, error: `Не обычный файл: ${filePath}` };
159
+ }
160
+ if (stat.size === 0) {
161
+ return { ok: false, filePath, error: `Файл пустой: ${path.basename(filePath)}` };
162
+ }
163
+
164
+ try {
165
+ fs.accessSync(filePath, fs.constants.R_OK);
166
+ } catch {
167
+ return { ok: false, filePath, error: `Нет прав на чтение: ${filePath}` };
168
+ }
169
+
170
+ return {
171
+ ok: true,
172
+ filePath,
173
+ name: path.basename(filePath),
174
+ size: stat.size,
175
+ kind: detectFileKind(filePath),
176
+ };
177
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Утилиты форматирования дат, времени и размеров данных.
3
+ */
4
+
5
+ /**
6
+ * Преобразует unix-timestamp (секунды или Date) в объект Date.
7
+ * @param {number|Date|string} time
8
+ * @returns {Date}
9
+ */
10
+ export function toDate(time) {
11
+ if (!time) return new Date();
12
+ if (time instanceof Date) return time;
13
+ if (typeof time === "number") {
14
+ // Если timestamp в секундах (как в Telegram), умножаем на 1000
15
+ return new Date(time < 1e11 ? time * 1000 : time);
16
+ }
17
+ return new Date(time);
18
+ }
19
+
20
+ /**
21
+ * Форматирует время для отображения в списке чатов (например, "14:32" или "29 авг").
22
+ * @param {number|Date|string} time
23
+ * @returns {string}
24
+ */
25
+ export function formatChatTime(time) {
26
+ if (!time) return "";
27
+ const date = toDate(time);
28
+ const now = new Date();
29
+
30
+ const isToday =
31
+ date.getDate() === now.getDate() &&
32
+ date.getMonth() === now.getMonth() &&
33
+ date.getFullYear() === now.getFullYear();
34
+
35
+ if (isToday) {
36
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
37
+ }
38
+
39
+ const yesterday = new Date(now);
40
+ yesterday.setDate(now.getDate() - 1);
41
+ const isYesterday =
42
+ date.getDate() === yesterday.getDate() &&
43
+ date.getMonth() === yesterday.getMonth() &&
44
+ date.getFullYear() === yesterday.getFullYear();
45
+
46
+ if (isYesterday) {
47
+ return "Вчера";
48
+ }
49
+
50
+ const isSameYear = date.getFullYear() === now.getFullYear();
51
+ if (isSameYear) {
52
+ return date.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
53
+ }
54
+
55
+ return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
56
+ }
57
+
58
+ /**
59
+ * Форматирует время для сообщения (HH:MM).
60
+ * @param {number|Date|string} time
61
+ * @returns {string}
62
+ */
63
+ export function formatMessageTime(time) {
64
+ if (!time) return "";
65
+ const date = toDate(time);
66
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
67
+ }
68
+
69
+ /**
70
+ * Форматирует полную дату и время (DD.MM.YYYY HH:MM:SS).
71
+ * @param {number|Date|string} time
72
+ * @returns {string}
73
+ */
74
+ export function formatFullDateTime(time) {
75
+ if (!time) return "";
76
+ const date = toDate(time);
77
+ return date.toLocaleString("ru-RU", {
78
+ day: "2-digit",
79
+ month: "2-digit",
80
+ year: "numeric",
81
+ hour: "2-digit",
82
+ minute: "2-digit",
83
+ second: "2-digit",
84
+ hour12: false,
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Возвращает строку разделителя даты для ленты сообщений (например "29 августа 2026").
90
+ * @param {number|Date|string} time
91
+ * @returns {string}
92
+ */
93
+ export function formatDateDivider(time) {
94
+ if (!time) return "";
95
+ const date = toDate(time);
96
+ return date.toLocaleDateString("ru-RU", {
97
+ day: "numeric",
98
+ month: "long",
99
+ year: "numeric",
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Форматирует размер файла в человекопонятный вид (KB, MB, GB).
105
+ * @param {number} bytes
106
+ * @returns {string}
107
+ */
108
+ export function formatFileSize(bytes) {
109
+ if (!bytes || bytes <= 0) return "0 B";
110
+ const units = ["B", "KB", "MB", "GB", "TB"];
111
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
112
+ const size = (bytes / Math.pow(1024, i)).toFixed(1);
113
+ return `${size} ${units[i]}`;
114
+ }
115
+
116
+ /**
117
+ * Форматирует длительность в секундах (MM:SS или HH:MM:SS).
118
+ * @param {number} seconds
119
+ * @returns {string}
120
+ */
121
+ export function formatDuration(seconds) {
122
+ if (!seconds) return "0:00";
123
+ const sec = Math.floor(seconds);
124
+ const m = Math.floor(sec / 60);
125
+ const s = sec % 60;
126
+ if (m < 60) {
127
+ return `${m}:${s.toString().padStart(2, "0")}`;
128
+ }
129
+ const h = Math.floor(m / 60);
130
+ const remM = m % 60;
131
+ return `${h}:${remM.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
132
+ }