itd-api 0.7.0 → 0.7.1
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.
- package/dist/index.cjs +465 -9804
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +231 -4950
- package/dist/index.d.ts +231 -4950
- package/dist/index.js +331 -9670
- package/dist/index.js.map +1 -1
- package/dist/{node.cjs → node/index.cjs} +16 -14
- package/dist/node/index.cjs.map +1 -0
- package/dist/{node.d.cts → node/index.d.cts} +4 -3
- package/dist/{node.d.ts → node/index.d.ts} +4 -3
- package/dist/{node.js → node/index.js} +5 -3
- package/dist/node/index.js.map +1 -0
- package/dist/realtime/index.cjs +165 -0
- package/dist/realtime/index.cjs.map +1 -0
- package/dist/realtime/index.d.cts +51 -0
- package/dist/realtime/index.d.ts +51 -0
- package/dist/realtime/index.js +120 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/rest/index.cjs +334 -0
- package/dist/rest/index.cjs.map +1 -0
- package/dist/rest/index.d.cts +138 -0
- package/dist/rest/index.d.ts +138 -0
- package/dist/rest/index.js +238 -0
- package/dist/rest/index.js.map +1 -0
- package/dist/shared/auth-provider-BfogACAb.js +91 -0
- package/dist/shared/auth-provider-BfogACAb.js.map +1 -0
- package/dist/shared/auth-provider-CTJkKfgy.cjs +108 -0
- package/dist/shared/auth-provider-CTJkKfgy.cjs.map +1 -0
- package/dist/shared/contracts-BoT7msmq.d.cts +84 -0
- package/dist/shared/contracts-BoT7msmq.d.ts +84 -0
- package/dist/{multi-storage-Bf84xiO8.cjs → shared/cookies-DZwFq6kr.cjs} +98 -429
- package/dist/shared/cookies-DZwFq6kr.cjs.map +1 -0
- package/dist/{multi-storage-BUZaLAPO.js → shared/cookies-tX2sNwxb.js} +99 -352
- package/dist/shared/cookies-tX2sNwxb.js.map +1 -0
- package/dist/{storage-IHdXw52v.js → shared/errors-Bhrd2fJd.js} +2 -229
- package/dist/shared/errors-Bhrd2fJd.js.map +1 -0
- package/dist/{storage-DnzZPS_9.cjs → shared/errors-DfU8M5eS.cjs} +1 -288
- package/dist/shared/errors-DfU8M5eS.cjs.map +1 -0
- package/dist/shared/multi-storage--yTEqiod.cjs +150 -0
- package/dist/shared/multi-storage--yTEqiod.cjs.map +1 -0
- package/dist/shared/multi-storage-CjAPB5Kq.d.cts +72 -0
- package/dist/shared/multi-storage-CkvTUC5m.js +121 -0
- package/dist/shared/multi-storage-CkvTUC5m.js.map +1 -0
- package/dist/shared/multi-storage-DccjD7Ww.d.ts +72 -0
- package/dist/shared/options-Dg5N3r1V.cjs +189 -0
- package/dist/shared/options-Dg5N3r1V.cjs.map +1 -0
- package/dist/shared/options-DtATYdLr.js +142 -0
- package/dist/shared/options-DtATYdLr.js.map +1 -0
- package/dist/shared/render-DMp_3Nzk.d.cts +2250 -0
- package/dist/shared/render-DZrxhC5_.d.ts +2250 -0
- package/dist/shared/render-DyeHJNBw.cjs +4147 -0
- package/dist/shared/render-DyeHJNBw.cjs.map +1 -0
- package/dist/shared/render-vtLixIiU.js +3992 -0
- package/dist/shared/render-vtLixIiU.js.map +1 -0
- package/dist/shared/storage-BPJR_k4-.cjs +290 -0
- package/dist/shared/storage-BPJR_k4-.cjs.map +1 -0
- package/dist/{storage-BqMxs76Y.d.ts → shared/storage-C_eICCep.d.cts} +2 -2
- package/dist/{storage-BqMxs76Y.d.cts → shared/storage-C_eICCep.d.ts} +2 -2
- package/dist/shared/storage-D86edNCB.js +231 -0
- package/dist/shared/storage-D86edNCB.js.map +1 -0
- package/dist/shared/url-B6-bXHKt.d.cts +2083 -0
- package/dist/shared/url-B6-bXHKt.d.ts +2083 -0
- package/dist/shared/url-CYXgxqGx.js +3673 -0
- package/dist/shared/url-CYXgxqGx.js.map +1 -0
- package/dist/shared/url-yjl2c8Ie.cjs +4032 -0
- package/dist/shared/url-yjl2c8Ie.cjs.map +1 -0
- package/dist/shared/websocket-BMtihD56.d.ts +562 -0
- package/dist/shared/websocket-CbzB1Leq.js +1874 -0
- package/dist/shared/websocket-CbzB1Leq.js.map +1 -0
- package/dist/shared/websocket-D1p32SB2.d.cts +562 -0
- package/dist/shared/websocket-sYlynrr0.cjs +1945 -0
- package/dist/shared/websocket-sYlynrr0.cjs.map +1 -0
- package/dist/{web.cjs → web/index.cjs} +4 -3
- package/dist/web/index.cjs.map +1 -0
- package/dist/{web.d.cts → web/index.d.cts} +2 -2
- package/dist/{web.d.ts → web/index.d.ts} +2 -2
- package/dist/{web.js → web/index.js} +3 -2
- package/dist/web/index.js.map +1 -0
- package/package.json +40 -12
- package/dist/multi-storage-BUZaLAPO.js.map +0 -1
- package/dist/multi-storage-BUoEoW8f.d.cts +0 -154
- package/dist/multi-storage-Bf84xiO8.cjs.map +0 -1
- package/dist/multi-storage-CQSlI_kn.d.ts +0 -154
- package/dist/node.cjs.map +0 -1
- package/dist/node.js.map +0 -1
- package/dist/storage-DnzZPS_9.cjs.map +0 -1
- package/dist/storage-IHdXw52v.js.map +0 -1
- package/dist/web.cjs.map +0 -1
- package/dist/web.js.map +0 -1
|
@@ -0,0 +1,3992 @@
|
|
|
1
|
+
import { F as pickArray, I as pickBoolean, L as pickNumber, P as isRecord, R as pickObject, et as createDeviceId, nt as isFile, p as InteractionType, q as STATUS_SERVICE, r as normalizeNotification, tt as isBlob, v as ReportReason, x as SpanType, y as ReportTargetType, z as pickString } from "./url-CYXgxqGx.js";
|
|
2
|
+
import { a as ItdConfigError, l as ItdFileError, s as ItdError, u as ItdFileErrorReason } from "./errors-Bhrd2fJd.js";
|
|
3
|
+
import { o as encodePathSegment } from "./cookies-tX2sNwxb.js";
|
|
4
|
+
import { a as fileTooLarge, c as FileTransferMode, i as isReadableByteStream, n as boundedFileStream, o as optionalBytes, r as isBoundedFileStream, s as DEFAULT_URL_FILE_MAX_BYTES, t as resolveFileStreamOptions } from "./options-DtATYdLr.js";
|
|
5
|
+
//#region src/resources/pagination.ts
|
|
6
|
+
/** Схема пагинации эндпоинта. */
|
|
7
|
+
const PaginationMode = Object.freeze({
|
|
8
|
+
/** Следующая страница запрашивается непрозрачным курсором. */
|
|
9
|
+
Cursor: "cursor",
|
|
10
|
+
/** Следующая страница запрашивается номером. */
|
|
11
|
+
Page: "page",
|
|
12
|
+
/** Следующая страница запрашивается смещением от начала списка. */
|
|
13
|
+
Offset: "offset"
|
|
14
|
+
});
|
|
15
|
+
/** Применяет преобразование к элементам страницы, сохраняя сведения о пагинации. */
|
|
16
|
+
function mapPage(page, map) {
|
|
17
|
+
return {
|
|
18
|
+
...page,
|
|
19
|
+
items: page.items.map(map)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Достаёт список из ответа, перебирая все формы, которые встречаются у сервера.
|
|
24
|
+
*
|
|
25
|
+
* Список может прийти под именем сущности, под её альтернативным именем
|
|
26
|
+
* (`followers` вместо `users`) либо голым массивом.
|
|
27
|
+
*
|
|
28
|
+
* @param fields имена полей в порядке предпочтения
|
|
29
|
+
*/
|
|
30
|
+
function readItems(body, fields) {
|
|
31
|
+
if (Array.isArray(body)) return body;
|
|
32
|
+
for (const field of fields) {
|
|
33
|
+
const items = pickArray(body, field);
|
|
34
|
+
if (items.length > 0) return items;
|
|
35
|
+
}
|
|
36
|
+
const primary = fields[0];
|
|
37
|
+
return primary === void 0 ? [] : pickArray(body, primary);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Достаёт курсор следующей страницы.
|
|
41
|
+
*
|
|
42
|
+
* Проверяются все известные места: внутри `pagination`, рядом со списком и во вложенном
|
|
43
|
+
* объекте `meta.cursor.next`.
|
|
44
|
+
*/
|
|
45
|
+
function readCursor(body) {
|
|
46
|
+
const pagination = pickObject(body, "pagination");
|
|
47
|
+
const meta = pickObject(body, "meta");
|
|
48
|
+
const metaCursor = pickObject(meta, "cursor");
|
|
49
|
+
return pickString(pagination, "nextCursor") ?? pickString(body, "nextCursor") ?? pickString(body, "cursor") ?? pickString(metaCursor, "next") ?? null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Читает страницу курсорной схемы.
|
|
53
|
+
*
|
|
54
|
+
* Используется лентой, постами пользователя и постами по хэштегу.
|
|
55
|
+
*
|
|
56
|
+
* @param fields имена возможных полей со списком; первое — основное
|
|
57
|
+
*/
|
|
58
|
+
function readCursorPage(body, ...fields) {
|
|
59
|
+
const pagination = pickObject(body, "pagination");
|
|
60
|
+
const items = readItems(body, fields);
|
|
61
|
+
const nextCursor = readCursor(body);
|
|
62
|
+
return {
|
|
63
|
+
items,
|
|
64
|
+
hasMore: pickBoolean(pagination, "hasMore", pickBoolean(body, "hasMore", nextCursor !== null)),
|
|
65
|
+
nextCursor,
|
|
66
|
+
limit: pickNumber(pagination, "limit", 0) || void 0,
|
|
67
|
+
raw: body
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Читает страницу курсорной схемы с курсором рядом со списком.
|
|
72
|
+
*
|
|
73
|
+
* Так устроены комментарии к посту и ответы на комментарий: `nextCursor` и `hasMore`
|
|
74
|
+
* лежат на одном уровне со списком, а не внутри объекта `pagination`.
|
|
75
|
+
*/
|
|
76
|
+
function readFlatCursorPage(body, ...fields) {
|
|
77
|
+
const items = readItems(body, fields);
|
|
78
|
+
const nextCursor = readCursor(body);
|
|
79
|
+
const total = pickNumber(body, "total", -1);
|
|
80
|
+
return {
|
|
81
|
+
items,
|
|
82
|
+
hasMore: pickBoolean(body, "hasMore", nextCursor !== null),
|
|
83
|
+
nextCursor,
|
|
84
|
+
...total >= 0 ? { total } : {},
|
|
85
|
+
raw: body
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Читает страницу постраничной схемы: `data.pagination.{page,limit,total,hasMore}`.
|
|
90
|
+
*
|
|
91
|
+
* Если сервер вместо этого прислал курсор, он тоже подхватывается — некоторые списки
|
|
92
|
+
* отвечают в курсорной форме, хотя документация описывает постраничную.
|
|
93
|
+
*/
|
|
94
|
+
function readPagedPage(body, ...fields) {
|
|
95
|
+
const pagination = pickObject(body, "pagination");
|
|
96
|
+
const items = readItems(body, fields);
|
|
97
|
+
const nextCursor = readCursor(body);
|
|
98
|
+
return {
|
|
99
|
+
items,
|
|
100
|
+
hasMore: pickBoolean(pagination, "hasMore", pickBoolean(body, "hasMore", nextCursor !== null)),
|
|
101
|
+
page: pickNumber(pagination, "page", 1),
|
|
102
|
+
limit: pickNumber(pagination, "limit", 0) || void 0,
|
|
103
|
+
total: pickNumber(pagination, "total", 0),
|
|
104
|
+
...nextCursor !== null ? { nextCursor } : {},
|
|
105
|
+
raw: body
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Читает страницу схемы со смещением.
|
|
110
|
+
*
|
|
111
|
+
* Используется для списков со смещением, включая уведомления.
|
|
112
|
+
*/
|
|
113
|
+
function readOffsetPage(body, field, offset) {
|
|
114
|
+
const items = pickArray(body, field);
|
|
115
|
+
return {
|
|
116
|
+
items,
|
|
117
|
+
hasMore: pickBoolean(body, "hasMore"),
|
|
118
|
+
nextOffset: offset + items.length,
|
|
119
|
+
raw: body
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Перебор страниц списка.
|
|
124
|
+
*
|
|
125
|
+
* Скрывает различия трёх схем пагинации: перебор элементов, страниц и сбор в массив
|
|
126
|
+
* выглядят одинаково независимо от эндпоинта.
|
|
127
|
+
*
|
|
128
|
+
* **Одноразовый.** Позиция хранится внутри, поэтому повторный перебор того же объекта
|
|
129
|
+
* ничего не вернёт: он продолжится с места, где закончился прошлый. Нужен второй проход —
|
|
130
|
+
* возьмите новый перебор у того же метода ресурса.
|
|
131
|
+
*
|
|
132
|
+
* @example Перебор элементов
|
|
133
|
+
* ```ts
|
|
134
|
+
* for await (const post of itd.posts.iterate({ tab: 'following' })) {
|
|
135
|
+
* console.log(post.content);
|
|
136
|
+
* }
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* @example Первые сто элементов
|
|
140
|
+
* ```ts
|
|
141
|
+
* const posts = await itd.posts.iterate({ tab: 'popular' }).collect(100);
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* @example Постранично
|
|
145
|
+
* ```ts
|
|
146
|
+
* for await (const page of itd.users.followers('nowkie').pages()) {
|
|
147
|
+
* console.log(page.items.length, 'из', page.total);
|
|
148
|
+
* }
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
var Paginator = class {
|
|
152
|
+
#options;
|
|
153
|
+
#maxPages;
|
|
154
|
+
#state;
|
|
155
|
+
#finished = false;
|
|
156
|
+
#pagesLoaded = 0;
|
|
157
|
+
constructor(options) {
|
|
158
|
+
if (options.maxPages !== void 0 && (!Number.isInteger(options.maxPages) || options.maxPages < 1)) throw new ItdConfigError(`maxPages должен быть целым числом не меньше 1, получено: ${options.maxPages}`);
|
|
159
|
+
this.#options = options;
|
|
160
|
+
this.#maxPages = options.maxPages ?? 1e3;
|
|
161
|
+
this.#state = options.start ?? {};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Загружает следующую страницу.
|
|
165
|
+
*
|
|
166
|
+
* @returns страница либо `null`, если перебор закончен
|
|
167
|
+
*/
|
|
168
|
+
async next() {
|
|
169
|
+
if (this.#finished) return null;
|
|
170
|
+
if (this.#options.signal?.aborted) return null;
|
|
171
|
+
if (this.#pagesLoaded >= this.#maxPages) {
|
|
172
|
+
this.#finished = true;
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const previous = this.#state;
|
|
176
|
+
const page = await this.#options.load(previous);
|
|
177
|
+
this.#pagesLoaded += 1;
|
|
178
|
+
this.#state = this.#advance(previous, page);
|
|
179
|
+
return page;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Перебирает страницы целиком.
|
|
183
|
+
*
|
|
184
|
+
* Полезно, когда нужны сведения о самой странице — например `total`.
|
|
185
|
+
*/
|
|
186
|
+
async *pages() {
|
|
187
|
+
for (;;) {
|
|
188
|
+
const page = await this.next();
|
|
189
|
+
if (!page) return;
|
|
190
|
+
yield page;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Перебирает элементы всех страниц подряд. */
|
|
194
|
+
async *[Symbol.asyncIterator]() {
|
|
195
|
+
for await (const page of this.pages()) for (const item of page.items) {
|
|
196
|
+
if (this.#options.signal?.aborted) return;
|
|
197
|
+
yield item;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Собирает элементы в массив.
|
|
202
|
+
*
|
|
203
|
+
* @param max сколько элементов достаточно; без него перебираются все страницы
|
|
204
|
+
*/
|
|
205
|
+
async collect(max) {
|
|
206
|
+
if (max !== void 0 && max <= 0) return [];
|
|
207
|
+
const result = [];
|
|
208
|
+
for await (const item of this) {
|
|
209
|
+
result.push(item);
|
|
210
|
+
if (max !== void 0 && result.length >= max) break;
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Вычисляет позицию следующей страницы и решает, продолжать ли.
|
|
216
|
+
*
|
|
217
|
+
* Здесь же стоят предохранители: пустая страница при `hasMore`, неизменившийся курсор
|
|
218
|
+
* и отсутствие курсора останавливают перебор. Без них ошибка на сервере превратилась бы
|
|
219
|
+
* в бесконечный цикл запросов.
|
|
220
|
+
*/
|
|
221
|
+
#advance(previous, page) {
|
|
222
|
+
if (!page.hasMore || page.items.length === 0) {
|
|
223
|
+
this.#finished = true;
|
|
224
|
+
return previous;
|
|
225
|
+
}
|
|
226
|
+
if (this.#options.mode === PaginationMode.Cursor) {
|
|
227
|
+
const cursor = page.nextCursor ?? void 0;
|
|
228
|
+
if (!cursor || cursor === previous.cursor) {
|
|
229
|
+
this.#finished = true;
|
|
230
|
+
return previous;
|
|
231
|
+
}
|
|
232
|
+
return { cursor };
|
|
233
|
+
}
|
|
234
|
+
if (this.#options.mode === PaginationMode.Page) return { page: (previous.page ?? 1) + 1 };
|
|
235
|
+
return { offset: page.nextOffset ?? (previous.offset ?? 0) + page.items.length };
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/resources/base.ts
|
|
240
|
+
/** Общая основа всех групп методов клиента. */
|
|
241
|
+
var BaseResource = class {
|
|
242
|
+
/** @internal */
|
|
243
|
+
http;
|
|
244
|
+
constructor(http) {
|
|
245
|
+
this.http = http;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Собирает перебор страниц.
|
|
249
|
+
*
|
|
250
|
+
* @param mode схема пагинации эндпоинта
|
|
251
|
+
* @param load загружает одну страницу для указанной позиции
|
|
252
|
+
* @param options только управление самим перебором: предел, отмена и начальная позиция
|
|
253
|
+
*/
|
|
254
|
+
paginate(mode, load, options) {
|
|
255
|
+
return new Paginator({
|
|
256
|
+
mode,
|
|
257
|
+
load,
|
|
258
|
+
...options?.maxPages !== void 0 ? { maxPages: options.maxPages } : {},
|
|
259
|
+
...options?.signal !== void 0 ? { signal: options.signal } : {},
|
|
260
|
+
...options?.start !== void 0 ? { start: options.start } : {}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Собирает пару «загрузка страницы + перебор» из одного описания.
|
|
265
|
+
*
|
|
266
|
+
* Путь, параметры запроса и разбор ответа задаются один раз; `list` и `iterate`
|
|
267
|
+
* строятся из них.
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* ```ts
|
|
271
|
+
* #feed = this.paginated<Post, FeedParams>({
|
|
272
|
+
* operationId: 'posts.list',
|
|
273
|
+
* path: () => '/api/posts',
|
|
274
|
+
* query: (p) => ({ tab: p.tab, limit: p.limit }),
|
|
275
|
+
* start: (p) => (p.cursor ? { cursor: p.cursor } : {}),
|
|
276
|
+
* read: (body) => readCursorPage<Post>(body, 'posts'),
|
|
277
|
+
* mode: PaginationMode.Cursor,
|
|
278
|
+
* });
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
paginated(spec) {
|
|
282
|
+
const load = async (params, state, options = {}) => {
|
|
283
|
+
const operationId = typeof spec.operationId === "function" ? spec.operationId(params) : spec.operationId;
|
|
284
|
+
const body = await this.http.operation(operationId, {
|
|
285
|
+
path: spec.path(params),
|
|
286
|
+
query: withPageState(spec.query(params), state),
|
|
287
|
+
...options
|
|
288
|
+
});
|
|
289
|
+
return spec.read(body, state);
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
list: (params, options) => load(params, spec.start(params), options),
|
|
293
|
+
iterate: (params, options = {}) => {
|
|
294
|
+
const { maxPages, ...requestOptions } = options;
|
|
295
|
+
return this.paginate(spec.mode, (state) => load(params, state, requestOptions), {
|
|
296
|
+
...maxPages !== void 0 ? { maxPages } : {},
|
|
297
|
+
...requestOptions.signal !== void 0 ? { signal: requestOptions.signal } : {},
|
|
298
|
+
start: spec.start(params)
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
/** Добавляет позицию страницы в параметры запроса. */
|
|
305
|
+
function withPageState(query, state) {
|
|
306
|
+
return {
|
|
307
|
+
...query,
|
|
308
|
+
...state.cursor !== void 0 ? { cursor: state.cursor } : {},
|
|
309
|
+
...state.page !== void 0 ? { page: state.page } : {},
|
|
310
|
+
...state.offset !== void 0 ? { offset: state.offset } : {}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/builders/base.ts
|
|
315
|
+
/** Метка билдера. Через `Symbol.for` — чтобы распознавание переживало смешивание ESM и CJS. */
|
|
316
|
+
const BUILDER = Symbol.for("itd.builder");
|
|
317
|
+
/** Является ли значение билдером. */
|
|
318
|
+
function isBuilder(value) {
|
|
319
|
+
return typeof value === "object" && value !== null && BUILDER in value;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Приводит любую из трёх форм входа к готовому объекту.
|
|
323
|
+
*
|
|
324
|
+
* Проверка выполняется всегда, независимо от формы: обычный объект проходит ровно те же
|
|
325
|
+
* правила, что и результат билдера.
|
|
326
|
+
*
|
|
327
|
+
* @param input то, что передал пользователь
|
|
328
|
+
* @param factory создаёт пустой билдер для формы с функцией
|
|
329
|
+
* @param validate проверяет собранный объект
|
|
330
|
+
*/
|
|
331
|
+
function resolveInput(input, factory, validate) {
|
|
332
|
+
if (typeof input === "function") {
|
|
333
|
+
const result = input(factory());
|
|
334
|
+
return isBuilder(result) ? result.build() : validate(result);
|
|
335
|
+
}
|
|
336
|
+
if (isBuilder(input)) return input.build();
|
|
337
|
+
return validate(input);
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/builders/comment.ts
|
|
341
|
+
/**
|
|
342
|
+
* Проверяет данные комментария.
|
|
343
|
+
*
|
|
344
|
+
* @throws {ItdConfigError} если комментарий пуст или голосовой собран неверно
|
|
345
|
+
*/
|
|
346
|
+
function validateComment(input) {
|
|
347
|
+
const content = typeof input?.content === "string" ? input.content : "";
|
|
348
|
+
const attachmentIds = input?.attachmentIds ?? [];
|
|
349
|
+
const files = input?.files ?? [];
|
|
350
|
+
const hasContent = content.trim() !== "";
|
|
351
|
+
const hasAttachments = attachmentIds.length > 0 || files.length > 0;
|
|
352
|
+
if (!hasContent && !hasAttachments) throw new ItdConfigError("Комментарий пуст: нужен текст или вложение");
|
|
353
|
+
return input;
|
|
354
|
+
}
|
|
355
|
+
/** Дополнительная проверка голосового комментария. */
|
|
356
|
+
function validateVoice(state) {
|
|
357
|
+
if (state.content.trim() !== "") throw new ItdConfigError("У голосового комментария не может быть текста: API принимает либо текст, либо аудио");
|
|
358
|
+
const total = state.files.length + state.attachmentIds.length;
|
|
359
|
+
if (total !== 1) throw new ItdConfigError(`Голосовой комментарий требует ровно одно аудиовложение, передано: ${total}`);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Билдер комментария и ответа на комментарий.
|
|
363
|
+
*
|
|
364
|
+
* Неизменяемый: каждый вызов возвращает новый экземпляр. Создаётся функцией {@link comment}.
|
|
365
|
+
*/
|
|
366
|
+
var CommentBuilder = class CommentBuilder {
|
|
367
|
+
/** @internal */
|
|
368
|
+
[BUILDER] = true;
|
|
369
|
+
#state;
|
|
370
|
+
/** @internal Создавайте билдер функцией {@link comment}. */
|
|
371
|
+
constructor(state) {
|
|
372
|
+
this.#state = state;
|
|
373
|
+
}
|
|
374
|
+
/** Задаёт текст комментария. */
|
|
375
|
+
content(text) {
|
|
376
|
+
return new CommentBuilder({
|
|
377
|
+
...this.#state,
|
|
378
|
+
content: text
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/** Прикладывает файл — он будет загружен перед отправкой. */
|
|
382
|
+
attach(file) {
|
|
383
|
+
return new CommentBuilder({
|
|
384
|
+
...this.#state,
|
|
385
|
+
files: [...this.#state.files, file]
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** Прикладывает уже загруженное вложение. */
|
|
389
|
+
attachId(attachmentId) {
|
|
390
|
+
return new CommentBuilder({
|
|
391
|
+
...this.#state,
|
|
392
|
+
attachmentIds: [...this.#state.attachmentIds, attachmentId]
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Делает комментарий голосовым.
|
|
397
|
+
*
|
|
398
|
+
* Текста у такого комментария быть не должно, а вложение ровно одно — аудио в формате
|
|
399
|
+
* `audio/ogg`. Так его принимает API.
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* ```ts
|
|
403
|
+
* import { fromPath } from 'itd-api/node';
|
|
404
|
+
*
|
|
405
|
+
* await itd.posts.comment(postId, (c) => c.voice(fromPath('./answer.ogg')));
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
voice(audio) {
|
|
409
|
+
return new CommentBuilder({
|
|
410
|
+
...this.#state,
|
|
411
|
+
files: [audio],
|
|
412
|
+
voice: true
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Кому адресован ответ.
|
|
417
|
+
*
|
|
418
|
+
* Имеет смысл только в `itd.comments.reply()`; при отправке комментария к посту
|
|
419
|
+
* это поле вызовет ошибку.
|
|
420
|
+
*/
|
|
421
|
+
replyTo(userId) {
|
|
422
|
+
return new CommentBuilder({
|
|
423
|
+
...this.#state,
|
|
424
|
+
replyToUserId: userId
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
build() {
|
|
428
|
+
const { content, attachmentIds, files, voice, ...rest } = this.#state;
|
|
429
|
+
if (voice) validateVoice(this.#state);
|
|
430
|
+
return validateComment({
|
|
431
|
+
...rest,
|
|
432
|
+
content,
|
|
433
|
+
...attachmentIds.length > 0 ? { attachmentIds } : {},
|
|
434
|
+
...files.length > 0 ? { files } : {}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
toJSON() {
|
|
438
|
+
return this.build();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* Начинает сборку комментария.
|
|
443
|
+
*
|
|
444
|
+
* @param content текст; можно задать позже методом {@link CommentBuilder.content}
|
|
445
|
+
*
|
|
446
|
+
* @example
|
|
447
|
+
* ```ts
|
|
448
|
+
* import { comment } from 'itd-api';
|
|
449
|
+
*
|
|
450
|
+
* await itd.posts.comment(postId, comment('согласен').attach({ url: memeUrl }));
|
|
451
|
+
* ```
|
|
452
|
+
*/
|
|
453
|
+
function comment(content = "") {
|
|
454
|
+
return new CommentBuilder({
|
|
455
|
+
content,
|
|
456
|
+
attachmentIds: [],
|
|
457
|
+
files: [],
|
|
458
|
+
voice: false
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Приводит любую форму входа к готовым данным комментария.
|
|
463
|
+
*
|
|
464
|
+
* @param allowReplyTo разрешено ли поле `replyToUserId`; в комментарии к посту — нет
|
|
465
|
+
*/
|
|
466
|
+
function resolveComment(input, allowReplyTo = false) {
|
|
467
|
+
const resolved = resolveInput(input, () => comment(), validateComment);
|
|
468
|
+
if (!allowReplyTo && resolved.replyToUserId !== void 0) throw new ItdConfigError("replyTo применим только к ответу на комментарий (itd.comments.reply). В комментарии к посту адресат не указывается.");
|
|
469
|
+
return resolved;
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/resources/comments.ts
|
|
473
|
+
/**
|
|
474
|
+
* Комментарии и ответы на них.
|
|
475
|
+
*
|
|
476
|
+
* Доступна как `itd.comments`. Комментарии **к посту** живут в `itd.posts`:
|
|
477
|
+
* `itd.posts.comments()` и `itd.posts.comment()`.
|
|
478
|
+
*/
|
|
479
|
+
var CommentsResource = class extends BaseResource {
|
|
480
|
+
#uploadFiles;
|
|
481
|
+
/** Ответы на комментарий: `/api/comments/{id}/replies`, постраничная пагинация. */
|
|
482
|
+
#replies = this.paginated({
|
|
483
|
+
operationId: "comments.replies",
|
|
484
|
+
path: (p) => `/api/comments/${encodePathSegment(p.commentId, "commentId")}/replies`,
|
|
485
|
+
query: (p) => ({ limit: p.limit }),
|
|
486
|
+
start: (p) => p.page !== void 0 ? { page: p.page } : {},
|
|
487
|
+
read: (body) => readPagedPage(body, "replies"),
|
|
488
|
+
mode: PaginationMode.Page
|
|
489
|
+
});
|
|
490
|
+
constructor(http, deps) {
|
|
491
|
+
super(http);
|
|
492
|
+
this.#uploadFiles = deps.uploadFiles;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Загружает страницу ответов на комментарий.
|
|
496
|
+
*
|
|
497
|
+
* Здесь пагинация **постраничная**, в отличие от комментариев к посту, где курсорная.
|
|
498
|
+
*/
|
|
499
|
+
replies(commentId, params = {}, options = {}) {
|
|
500
|
+
return this.#replies.list({
|
|
501
|
+
...params,
|
|
502
|
+
commentId
|
|
503
|
+
}, options);
|
|
504
|
+
}
|
|
505
|
+
/** Перебирает ответы на комментарий. */
|
|
506
|
+
iterateReplies(commentId, params = {}, options = {}) {
|
|
507
|
+
return this.#replies.iterate({
|
|
508
|
+
...params,
|
|
509
|
+
commentId
|
|
510
|
+
}, options);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Отвечает на комментарий.
|
|
514
|
+
*
|
|
515
|
+
* @example
|
|
516
|
+
* ```ts
|
|
517
|
+
* await itd.comments.reply(commentId, 'согласен');
|
|
518
|
+
* await itd.comments.reply(commentId, (c) => c.content('и вот почему').replyTo(userId));
|
|
519
|
+
* ```
|
|
520
|
+
*/
|
|
521
|
+
async reply(commentId, input, options = {}) {
|
|
522
|
+
const data = resolveComment(typeof input === "string" ? { content: input } : input, true);
|
|
523
|
+
const existing = data.attachmentIds ?? [];
|
|
524
|
+
const files = data.files ?? [];
|
|
525
|
+
const attachmentIds = files.length > 0 ? [...existing, ...await this.#uploadFiles(files, options)] : existing;
|
|
526
|
+
return this.http.operation("comments.reply", {
|
|
527
|
+
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/replies`,
|
|
528
|
+
body: {
|
|
529
|
+
content: data.content ?? "",
|
|
530
|
+
attachmentIds,
|
|
531
|
+
...data.replyToUserId ? { replyToUserId: data.replyToUserId } : {}
|
|
532
|
+
},
|
|
533
|
+
...options
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/** Редактирует текст комментария. */
|
|
537
|
+
update(commentId, content, options = {}) {
|
|
538
|
+
return this.http.operation("comments.update", {
|
|
539
|
+
path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
|
|
540
|
+
body: { content },
|
|
541
|
+
...options
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/** Удаляет комментарий. Восстановить его можно через {@link restore}. */
|
|
545
|
+
remove(commentId, options = {}) {
|
|
546
|
+
return this.http.operation("comments.remove", {
|
|
547
|
+
path: `/api/comments/${encodePathSegment(commentId, "commentId")}`,
|
|
548
|
+
...options
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/** Восстанавливает удалённый комментарий. */
|
|
552
|
+
restore(commentId, options = {}) {
|
|
553
|
+
return this.http.operation("comments.restore", {
|
|
554
|
+
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/restore`,
|
|
555
|
+
...options
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/** Ставит реакцию на комментарий. */
|
|
559
|
+
like(commentId, options = {}) {
|
|
560
|
+
return this.http.operation("comments.like", {
|
|
561
|
+
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
|
|
562
|
+
...options
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/** Убирает реакцию с комментария. */
|
|
566
|
+
unlike(commentId, options = {}) {
|
|
567
|
+
return this.http.operation("comments.unlike", {
|
|
568
|
+
path: `/api/comments/${encodePathSegment(commentId, "commentId")}/like`,
|
|
569
|
+
...options
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/core/attachments/url-source.ts
|
|
575
|
+
/** Убирает параметры MIME и приводит его к форме для сравнения. */
|
|
576
|
+
function normalizeMimeType(contentType) {
|
|
577
|
+
return contentType?.split(";", 1)[0]?.trim().toLowerCase() || void 0;
|
|
578
|
+
}
|
|
579
|
+
/** Достаёт имя файла из пути URL. */
|
|
580
|
+
function filenameFromUrl(url) {
|
|
581
|
+
const last = url.pathname.split("/").pop();
|
|
582
|
+
if (!last) return void 0;
|
|
583
|
+
try {
|
|
584
|
+
return decodeURIComponent(last) || void 0;
|
|
585
|
+
} catch {
|
|
586
|
+
return last;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** Проверяет объявленный размер и возвращает его, если заголовок корректен. */
|
|
590
|
+
function declaredSize(response, maxBytes, url) {
|
|
591
|
+
const header = response.headers.get("content-length");
|
|
592
|
+
if (header === null) return void 0;
|
|
593
|
+
const size = Number(header);
|
|
594
|
+
if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) return void 0;
|
|
595
|
+
if (maxBytes !== void 0 && size > maxBytes) throw fileTooLarge(url, maxBytes, size);
|
|
596
|
+
return size;
|
|
597
|
+
}
|
|
598
|
+
/** Получает HTTP-ответ источника и проверяет его статус. */
|
|
599
|
+
async function fetchFile(target, options, context) {
|
|
600
|
+
let requested;
|
|
601
|
+
try {
|
|
602
|
+
requested = new URL(target);
|
|
603
|
+
} catch {
|
|
604
|
+
throw new ItdConfigError(`«${target}» не разбирается как адрес`);
|
|
605
|
+
}
|
|
606
|
+
if (requested.protocol !== "http:" && requested.protocol !== "https:") throw new ItdConfigError(`вложение по адресу поддерживает только http и https, получено: ${requested.protocol}`);
|
|
607
|
+
let response;
|
|
608
|
+
try {
|
|
609
|
+
response = await context.fetch(requested, { ...context.signal ? { signal: context.signal } : {} });
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (context.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
|
|
612
|
+
throw new ItdFileError(`не удалось получить файл по адресу ${requested.href}`, {
|
|
613
|
+
reason: ItdFileErrorReason.Network,
|
|
614
|
+
url: requested.href,
|
|
615
|
+
retryable: true,
|
|
616
|
+
cause: error
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
const finalUrl = response.url ? new URL(response.url) : requested;
|
|
620
|
+
if (!response.ok) {
|
|
621
|
+
await response.body?.cancel().catch(() => {});
|
|
622
|
+
throw new ItdFileError(`источник ${finalUrl.href} ответил статусом ${response.status}`, {
|
|
623
|
+
reason: ItdFileErrorReason.Http,
|
|
624
|
+
url: finalUrl.href,
|
|
625
|
+
status: response.status,
|
|
626
|
+
retryable: response.status === 408 || response.status === 429 || response.status >= 500
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const { maxBytes } = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
|
|
630
|
+
let size;
|
|
631
|
+
try {
|
|
632
|
+
size = declaredSize(response, maxBytes, finalUrl.href);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
await response.body?.cancel().catch(() => {});
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
response,
|
|
639
|
+
url: finalUrl,
|
|
640
|
+
size
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/** Читает ответ с контролем размера до создания итогового `Blob`. */
|
|
644
|
+
async function responseBlob(response, url, maxBytes, streamBufferBytes, signal) {
|
|
645
|
+
if (!response.body) {
|
|
646
|
+
const blob = await response.blob();
|
|
647
|
+
if (maxBytes !== void 0 && blob.size > maxBytes) throw fileTooLarge(url, maxBytes, blob.size);
|
|
648
|
+
return blob;
|
|
649
|
+
}
|
|
650
|
+
const chunks = [];
|
|
651
|
+
const reader = boundedFileStream(response.body, {
|
|
652
|
+
...maxBytes !== void 0 ? { maxBytes } : {},
|
|
653
|
+
streamBufferBytes,
|
|
654
|
+
...signal ? { signal } : {},
|
|
655
|
+
url,
|
|
656
|
+
retryableRead: true
|
|
657
|
+
}).getReader();
|
|
658
|
+
try {
|
|
659
|
+
for (;;) {
|
|
660
|
+
const next = await reader.read();
|
|
661
|
+
if (next.done) break;
|
|
662
|
+
chunks.push(next.value);
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
reader.releaseLock();
|
|
666
|
+
}
|
|
667
|
+
return new Blob(chunks.map((chunk) => Uint8Array.from(chunk).buffer));
|
|
668
|
+
}
|
|
669
|
+
/** Скачивает файл целиком с ограничением размера. @internal */
|
|
670
|
+
async function downloadFile(target, options, context) {
|
|
671
|
+
const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
|
|
672
|
+
const { response, url } = await fetchFile(target, options, context);
|
|
673
|
+
const blob = await responseBlob(response, url.href, resolved.maxBytes, resolved.streamBufferBytes, context.signal);
|
|
674
|
+
const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
|
|
675
|
+
const filename = options.filename ?? filenameFromUrl(url);
|
|
676
|
+
return {
|
|
677
|
+
file: new Blob([blob], { type: contentType ?? "" }),
|
|
678
|
+
...filename ? { filename } : {},
|
|
679
|
+
...contentType ? { contentType } : {}
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/** Открывает HTTP-ответ как ограниченный поток. @internal */
|
|
683
|
+
async function openUrlFile(target, options, context) {
|
|
684
|
+
const resolved = resolveFileStreamOptions(options, DEFAULT_URL_FILE_MAX_BYTES);
|
|
685
|
+
const { response, url, size } = await fetchFile(target, options, context);
|
|
686
|
+
if (!response.body) throw new ItdFileError(`источник ${url.href} не предоставил потоковое тело`, {
|
|
687
|
+
reason: ItdFileErrorReason.StreamUnavailable,
|
|
688
|
+
url: url.href
|
|
689
|
+
});
|
|
690
|
+
const stream = boundedFileStream(response.body, {
|
|
691
|
+
...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
|
|
692
|
+
streamBufferBytes: resolved.streamBufferBytes,
|
|
693
|
+
...context.signal ? { signal: context.signal } : {},
|
|
694
|
+
url: url.href,
|
|
695
|
+
retryableRead: true
|
|
696
|
+
});
|
|
697
|
+
const filename = options.filename ?? filenameFromUrl(url);
|
|
698
|
+
const contentType = normalizeMimeType(options.contentType ?? response.headers.get("content-type") ?? void 0);
|
|
699
|
+
return {
|
|
700
|
+
stream,
|
|
701
|
+
...filename ? { filename } : {},
|
|
702
|
+
...contentType ? { contentType } : {},
|
|
703
|
+
...size !== void 0 ? { size } : {},
|
|
704
|
+
close: () => stream.cancel().catch(() => {})
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region src/core/multipart.ts
|
|
709
|
+
const encoder = new TextEncoder();
|
|
710
|
+
/** Убирает возможность внедрить дополнительный multipart-заголовок через имя файла. */
|
|
711
|
+
function quotedFilename(filename) {
|
|
712
|
+
return filename.replace(/[\r\n]/g, "").replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
713
|
+
}
|
|
714
|
+
/** Удаляет разделители строк, чтобы значение не создало новый multipart-заголовок. */
|
|
715
|
+
function headerValue(value) {
|
|
716
|
+
return value.replace(/[\r\n]/g, "");
|
|
717
|
+
}
|
|
718
|
+
/** Создаёт уникальную границу без зависимости от Web Crypto. */
|
|
719
|
+
function boundary() {
|
|
720
|
+
return `----itd-api-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Кодирует один файл в multipart без полной буферизации.
|
|
724
|
+
*
|
|
725
|
+
* Читает следующий чанк только когда потребитель запросил данные.
|
|
726
|
+
*/
|
|
727
|
+
function createMultipartFileBody(file) {
|
|
728
|
+
const marker = boundary();
|
|
729
|
+
const prefix = encoder.encode(`--${marker}\r\nContent-Disposition: form-data; name="file"; filename="${quotedFilename(file.filename)}"\r\nContent-Type: ${headerValue(file.contentType)}\r\n\r\n`);
|
|
730
|
+
const suffix = encoder.encode(`\r\n--${marker}--\r\n`);
|
|
731
|
+
const reader = file.stream.getReader();
|
|
732
|
+
let stage = "prefix";
|
|
733
|
+
let cancelled = false;
|
|
734
|
+
const cancel = async (reason) => {
|
|
735
|
+
if (cancelled) return;
|
|
736
|
+
cancelled = true;
|
|
737
|
+
await reader.cancel(reason).catch(() => {});
|
|
738
|
+
};
|
|
739
|
+
return {
|
|
740
|
+
body: new ReadableStream({
|
|
741
|
+
async pull(controller) {
|
|
742
|
+
if (stage === "prefix") {
|
|
743
|
+
stage = "file";
|
|
744
|
+
controller.enqueue(prefix);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (stage === "file") try {
|
|
748
|
+
const next = await reader.read();
|
|
749
|
+
if (!next.done) {
|
|
750
|
+
controller.enqueue(next.value);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
stage = "suffix";
|
|
754
|
+
} catch (error) {
|
|
755
|
+
stage = "done";
|
|
756
|
+
controller.error(error);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (stage === "suffix") {
|
|
760
|
+
stage = "done";
|
|
761
|
+
controller.enqueue(suffix);
|
|
762
|
+
controller.close();
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
cancel
|
|
766
|
+
}),
|
|
767
|
+
contentType: `multipart/form-data; boundary=${marker}`,
|
|
768
|
+
cancel
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/domain/mime.ts
|
|
773
|
+
/** Изображения, которые принимает `POST /api/files/upload`. */
|
|
774
|
+
const IMAGE_MIME_TYPES = Object.freeze([
|
|
775
|
+
"image/jpeg",
|
|
776
|
+
"image/png",
|
|
777
|
+
"image/gif",
|
|
778
|
+
"image/webp",
|
|
779
|
+
"image/avif",
|
|
780
|
+
"image/heic",
|
|
781
|
+
"image/heif"
|
|
782
|
+
]);
|
|
783
|
+
/** Видео, которые принимает `POST /api/files/upload`. */
|
|
784
|
+
const VIDEO_MIME_TYPES = Object.freeze([
|
|
785
|
+
"video/mp4",
|
|
786
|
+
"video/webm",
|
|
787
|
+
"video/quicktime"
|
|
788
|
+
]);
|
|
789
|
+
/** Аудио для голосовых комментариев. */
|
|
790
|
+
const AUDIO_MIME_TYPES = Object.freeze(["audio/ogg"]);
|
|
791
|
+
/** Все типы, которые принимает загрузка. */
|
|
792
|
+
const ALLOWED_MIME_TYPES = Object.freeze([
|
|
793
|
+
...IMAGE_MIME_TYPES,
|
|
794
|
+
...VIDEO_MIME_TYPES,
|
|
795
|
+
...AUDIO_MIME_TYPES
|
|
796
|
+
]);
|
|
797
|
+
/**
|
|
798
|
+
* Соответствие расширения и MIME-типа.
|
|
799
|
+
*
|
|
800
|
+
* Своя маленькая таблица вместо зависимости: библиотеке нужны ровно те типы,
|
|
801
|
+
* которые принимает API.
|
|
802
|
+
*/
|
|
803
|
+
const EXTENSION_TO_MIME = Object.freeze({
|
|
804
|
+
jpg: "image/jpeg",
|
|
805
|
+
jpeg: "image/jpeg",
|
|
806
|
+
jfif: "image/jpeg",
|
|
807
|
+
png: "image/png",
|
|
808
|
+
gif: "image/gif",
|
|
809
|
+
webp: "image/webp",
|
|
810
|
+
avif: "image/avif",
|
|
811
|
+
heic: "image/heic",
|
|
812
|
+
heif: "image/heif",
|
|
813
|
+
mp4: "video/mp4",
|
|
814
|
+
m4v: "video/mp4",
|
|
815
|
+
webm: "video/webm",
|
|
816
|
+
mov: "video/quicktime",
|
|
817
|
+
qt: "video/quicktime",
|
|
818
|
+
ogg: "audio/ogg",
|
|
819
|
+
oga: "audio/ogg",
|
|
820
|
+
opus: "audio/ogg"
|
|
821
|
+
});
|
|
822
|
+
/** Определяет MIME-тип по расширению имени файла. */
|
|
823
|
+
function mimeFromFilename(filename) {
|
|
824
|
+
const dot = filename.lastIndexOf(".");
|
|
825
|
+
if (dot < 0) return void 0;
|
|
826
|
+
return EXTENSION_TO_MIME[filename.slice(dot + 1).toLowerCase()];
|
|
827
|
+
}
|
|
828
|
+
/** Разрешён ли тип к загрузке. */
|
|
829
|
+
function isAllowedMime(mimeType) {
|
|
830
|
+
return ALLOWED_MIME_TYPES.includes(mimeType.toLowerCase());
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Проверяет тип файла до отправки.
|
|
834
|
+
*
|
|
835
|
+
* Отказ на стороне клиента экономит и время, и трафик: тот же `UNSUPPORTED_FILE_TYPE`
|
|
836
|
+
* пришёл бы с сервера, но уже после загрузки всего файла.
|
|
837
|
+
*
|
|
838
|
+
* @throws {ItdConfigError} если тип не поддерживается
|
|
839
|
+
*/
|
|
840
|
+
function assertAllowedMime(mimeType, filename) {
|
|
841
|
+
if (!mimeType) throw new ItdConfigError(`Не удалось определить тип файла${filename ? ` «${filename}»` : ""}. Укажите его явно: { file, filename, contentType }.`);
|
|
842
|
+
if (!isAllowedMime(mimeType)) throw new ItdConfigError(`Тип «${mimeType}» не поддерживается загрузкой. Допустимые: ${ALLOWED_MIME_TYPES.join(", ")}.`);
|
|
843
|
+
}
|
|
844
|
+
/** Файлы и медиа. */
|
|
845
|
+
var FilesResource = class extends BaseResource {
|
|
846
|
+
#fetch;
|
|
847
|
+
constructor(http, deps) {
|
|
848
|
+
super(http);
|
|
849
|
+
this.#fetch = deps.fetch;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Загружает файл и возвращает его идентификатор.
|
|
853
|
+
*
|
|
854
|
+
* Потоковый источник открывается заново при каждой повторной попытке. Буферный источник
|
|
855
|
+
* после успешного чтения переиспользуется.
|
|
856
|
+
*/
|
|
857
|
+
upload(input, uploadOptions = {}, requestOptions = {}) {
|
|
858
|
+
const bodyFactory = this.#createBodyFactory(input, uploadOptions);
|
|
859
|
+
return this.http.operation("files.upload", {
|
|
860
|
+
path: "/api/files/upload",
|
|
861
|
+
bodyFactory,
|
|
862
|
+
...requestOptions,
|
|
863
|
+
timeout: requestOptions.timeout ?? 3e5
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
/** Загружает несколько файлов последовательно, сохраняя порядок. */
|
|
867
|
+
async uploadMany(files, uploadOptions = {}, requestOptions = {}) {
|
|
868
|
+
const ids = [];
|
|
869
|
+
for (const file of files) ids.push((await this.upload(file, uploadOptions, requestOptions)).id);
|
|
870
|
+
return ids;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Загружает сведения о файле.
|
|
874
|
+
*
|
|
875
|
+
* Для ещё не прикреплённого файла сервер может ответить `404`.
|
|
876
|
+
*/
|
|
877
|
+
get(fileId, options = {}) {
|
|
878
|
+
return this.http.operation("files.get", {
|
|
879
|
+
path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
|
|
880
|
+
...options
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
/** Удаляет загруженный файл. */
|
|
884
|
+
remove(fileId, options = {}) {
|
|
885
|
+
return this.http.operation("files.remove", {
|
|
886
|
+
path: `/api/files/${encodePathSegment(fileId, "fileId")}`,
|
|
887
|
+
...options
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Создаёт фабрику тела. Успешно подготовленный буфер кешируется, а поток открывается
|
|
892
|
+
* заново, поэтому каждая транспортная попытка получает непрочитанное тело.
|
|
893
|
+
*/
|
|
894
|
+
#createBodyFactory(input, options) {
|
|
895
|
+
const streamMode = this.#isStreamInput(input);
|
|
896
|
+
let buffered;
|
|
897
|
+
return async ({ signal, attempt }) => {
|
|
898
|
+
const context = {
|
|
899
|
+
fetch: this.#fetch,
|
|
900
|
+
signal,
|
|
901
|
+
attempt
|
|
902
|
+
};
|
|
903
|
+
let prepared;
|
|
904
|
+
try {
|
|
905
|
+
if (streamMode) prepared = await this.#prepareStream(input, options, context);
|
|
906
|
+
else {
|
|
907
|
+
buffered ??= this.#prepareBuffer(input, options, context).catch((error) => {
|
|
908
|
+
buffered = void 0;
|
|
909
|
+
throw error;
|
|
910
|
+
});
|
|
911
|
+
prepared = await buffered;
|
|
912
|
+
}
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (error instanceof ItdError || signal.aborted) throw error;
|
|
915
|
+
throw new ItdFileError("не удалось получить содержимое вложения", {
|
|
916
|
+
reason: ItdFileErrorReason.Read,
|
|
917
|
+
retryable: true,
|
|
918
|
+
cause: error
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
if (prepared.mode === "buffer") {
|
|
922
|
+
const form = new FormData();
|
|
923
|
+
form.set("file", prepared.blob, prepared.filename);
|
|
924
|
+
return { body: form };
|
|
925
|
+
}
|
|
926
|
+
const multipart = createMultipartFileBody(prepared);
|
|
927
|
+
return {
|
|
928
|
+
body: multipart.body,
|
|
929
|
+
headers: { "Content-Type": multipart.contentType },
|
|
930
|
+
cleanup: async () => {
|
|
931
|
+
await multipart.cancel();
|
|
932
|
+
await prepared.close?.();
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
/** Определяет режим без чтения источника. */
|
|
938
|
+
#isStreamInput(input) {
|
|
939
|
+
if (typeof input !== "object" || input === null) return false;
|
|
940
|
+
if ("open" in input) return true;
|
|
941
|
+
if ("url" in input) return resolveFileStreamOptions(input).mode === FileTransferMode.Stream;
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
/** Получает и проверяет буферный источник. */
|
|
945
|
+
async #prepareBuffer(input, options, context) {
|
|
946
|
+
const content = await this.#resolveBuffer(input, context);
|
|
947
|
+
const filename = options.filename ?? content.filename ?? this.#nameFromMime(options.contentType ?? content.contentType);
|
|
948
|
+
const contentType = normalizeMimeType(options.contentType ?? content.contentType) ?? normalizeMimeType(isBlob(content.file) ? content.file.type : void 0) ?? mimeFromFilename(filename);
|
|
949
|
+
if (options.validateMime !== false) assertAllowedMime(contentType, filename);
|
|
950
|
+
const blob = isBlob(content.file) && (!contentType || content.file.type === contentType) ? content.file : new Blob([content.file], { type: contentType ?? "" });
|
|
951
|
+
const limits = resolveFileStreamOptions(options);
|
|
952
|
+
if (limits.maxBytes !== void 0 && blob.size > limits.maxBytes) throw new ItdFileError(`файл больше предела в ${limits.maxBytes} байт: ${blob.size}`, {
|
|
953
|
+
reason: ItdFileErrorReason.TooLarge,
|
|
954
|
+
limit: limits.maxBytes,
|
|
955
|
+
actual: blob.size
|
|
956
|
+
});
|
|
957
|
+
return {
|
|
958
|
+
mode: "buffer",
|
|
959
|
+
blob,
|
|
960
|
+
filename
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/** Открывает и проверяет потоковый источник. */
|
|
964
|
+
async #prepareStream(input, options, context) {
|
|
965
|
+
let opened;
|
|
966
|
+
if (typeof input === "object" && input !== null && "open" in input) opened = await input.open(context);
|
|
967
|
+
else if (typeof input === "object" && input !== null && "url" in input) {
|
|
968
|
+
const { url, ...urlOptions } = input;
|
|
969
|
+
opened = await openUrlFile(url, urlOptions, context);
|
|
970
|
+
} else throw new ItdConfigError("потоковое вложение должно иметь форму { open } или { url, mode }");
|
|
971
|
+
if (!opened || typeof ReadableStream === "undefined" || !(opened.stream instanceof ReadableStream)) {
|
|
972
|
+
await opened?.close?.();
|
|
973
|
+
throw new ItdConfigError("потоковый источник должен вернуть { stream: ReadableStream }");
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const filename = options.filename ?? opened.filename ?? this.#nameFromMime(options.contentType ?? opened.contentType);
|
|
977
|
+
const contentType = normalizeMimeType(options.contentType ?? opened.contentType) ?? mimeFromFilename(filename);
|
|
978
|
+
if (options.validateMime !== false) assertAllowedMime(contentType, filename);
|
|
979
|
+
const limits = resolveFileStreamOptions({
|
|
980
|
+
mode: FileTransferMode.Stream,
|
|
981
|
+
...options.maxBytes !== void 0 ? { maxBytes: options.maxBytes } : {},
|
|
982
|
+
...options.streamBufferBytes !== void 0 ? { streamBufferBytes: options.streamBufferBytes } : {}
|
|
983
|
+
});
|
|
984
|
+
if (limits.maxBytes !== void 0 && opened.size !== void 0 && opened.size > limits.maxBytes) throw new ItdFileError(`файл больше предела в ${limits.maxBytes} байт: ${opened.size}`, {
|
|
985
|
+
reason: ItdFileErrorReason.TooLarge,
|
|
986
|
+
limit: limits.maxBytes,
|
|
987
|
+
actual: opened.size
|
|
988
|
+
});
|
|
989
|
+
return {
|
|
990
|
+
mode: "stream",
|
|
991
|
+
stream: isBoundedFileStream(opened.stream) && options.maxBytes === void 0 && options.streamBufferBytes === void 0 ? opened.stream : boundedFileStream(opened.stream, {
|
|
992
|
+
...limits.maxBytes !== void 0 ? { maxBytes: limits.maxBytes } : {},
|
|
993
|
+
streamBufferBytes: limits.streamBufferBytes,
|
|
994
|
+
signal: context.signal
|
|
995
|
+
}),
|
|
996
|
+
filename,
|
|
997
|
+
contentType: contentType ?? "",
|
|
998
|
+
...opened.close ? { close: opened.close } : {}
|
|
999
|
+
};
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
await opened.close?.();
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/** Разрешает только буферные формы. */
|
|
1006
|
+
#resolveBuffer(input, context) {
|
|
1007
|
+
if (input instanceof ArrayBuffer || ArrayBuffer.isView(input) || isBlob(input)) return {
|
|
1008
|
+
file: input,
|
|
1009
|
+
...isFile(input) ? { filename: input.name } : {}
|
|
1010
|
+
};
|
|
1011
|
+
if (typeof input !== "object" || input === null) throw new ItdConfigError(`вложение задано значением типа ${typeof input}; ожидается бинарное значение или объект { file }, { url }, { load }, { open }. Для файла на диске используйте fromPath('./photo.jpg') из itd-api/node`);
|
|
1012
|
+
if ("load" in input) return input.load(context);
|
|
1013
|
+
if ("url" in input) {
|
|
1014
|
+
const { url, ...urlOptions } = input;
|
|
1015
|
+
return downloadFile(url, urlOptions, context);
|
|
1016
|
+
}
|
|
1017
|
+
if ("file" in input) return input;
|
|
1018
|
+
if ("open" in input) throw new ItdConfigError("потоковый источник нельзя использовать как буферный");
|
|
1019
|
+
throw new ItdConfigError("вложение не распознано: ожидается { file }, { url }, { load } или { open }");
|
|
1020
|
+
}
|
|
1021
|
+
/** Подбирает непустое имя, обязательное для multipart. */
|
|
1022
|
+
#nameFromMime(contentType) {
|
|
1023
|
+
const extension = normalizeMimeType(contentType)?.split("/")[1];
|
|
1024
|
+
return extension ? `file.${extension}` : "file";
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/resources/hashtags.ts
|
|
1029
|
+
/**
|
|
1030
|
+
* Хэштеги.
|
|
1031
|
+
*
|
|
1032
|
+
* Доступна как `itd.hashtags`.
|
|
1033
|
+
*/
|
|
1034
|
+
var HashtagsResource = class extends BaseResource {
|
|
1035
|
+
/** Посты по хэштегу: `/api/hashtags/{tag}/posts`, курсорная пагинация. */
|
|
1036
|
+
#posts = this.paginated({
|
|
1037
|
+
operationId: "hashtags.posts",
|
|
1038
|
+
path: (p) => `/api/hashtags/${encodePathSegment(p.tag, "tag")}/posts`,
|
|
1039
|
+
query: (p) => ({ limit: p.limit }),
|
|
1040
|
+
start: (p) => p.cursor ? { cursor: p.cursor } : {},
|
|
1041
|
+
read: (body) => readCursorPage(body, "posts"),
|
|
1042
|
+
mode: PaginationMode.Cursor
|
|
1043
|
+
});
|
|
1044
|
+
/**
|
|
1045
|
+
* Ищет хэштеги.
|
|
1046
|
+
*
|
|
1047
|
+
* Без строки запроса возвращает общий список.
|
|
1048
|
+
*/
|
|
1049
|
+
async search(query, params = {}, options = {}) {
|
|
1050
|
+
const body = await this.http.operation("hashtags.search", {
|
|
1051
|
+
path: "/api/hashtags",
|
|
1052
|
+
query: {
|
|
1053
|
+
q: query,
|
|
1054
|
+
limit: params.limit
|
|
1055
|
+
},
|
|
1056
|
+
...options
|
|
1057
|
+
});
|
|
1058
|
+
return pickArray(body, "hashtags");
|
|
1059
|
+
}
|
|
1060
|
+
/** Загружает трендовые хэштеги. */
|
|
1061
|
+
async trending(params = {}, options = {}) {
|
|
1062
|
+
const body = await this.http.operation("hashtags.trending", {
|
|
1063
|
+
path: "/api/hashtags/trending",
|
|
1064
|
+
query: { limit: params.limit },
|
|
1065
|
+
...options
|
|
1066
|
+
});
|
|
1067
|
+
return pickArray(body, "hashtags");
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Загружает страницу постов по хэштегу.
|
|
1071
|
+
*
|
|
1072
|
+
* @param tag название без решётки; кодируется автоматически, поэтому кириллица
|
|
1073
|
+
* и пробелы допустимы
|
|
1074
|
+
*/
|
|
1075
|
+
posts(tag, params = {}, options = {}) {
|
|
1076
|
+
return this.#posts.list({
|
|
1077
|
+
...params,
|
|
1078
|
+
tag
|
|
1079
|
+
}, options);
|
|
1080
|
+
}
|
|
1081
|
+
/** Перебирает посты по хэштегу. */
|
|
1082
|
+
iteratePosts(tag, params = {}, options = {}) {
|
|
1083
|
+
return this.#posts.iterate({
|
|
1084
|
+
...params,
|
|
1085
|
+
tag
|
|
1086
|
+
}, options);
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
//#endregion
|
|
1090
|
+
//#region src/resources/notifications.ts
|
|
1091
|
+
const NOTIFICATION_SETTING_KEYS = [
|
|
1092
|
+
"enabled",
|
|
1093
|
+
"sound",
|
|
1094
|
+
"follows",
|
|
1095
|
+
"wallPosts",
|
|
1096
|
+
"likes",
|
|
1097
|
+
"comments",
|
|
1098
|
+
"mentions"
|
|
1099
|
+
];
|
|
1100
|
+
/**
|
|
1101
|
+
* Сколько идентификаторов уходит в одном запросе на отметку прочтения.
|
|
1102
|
+
*
|
|
1103
|
+
* Столько же отправляет сайт итд.com — значит на сервере, скорее всего, есть ограничение.
|
|
1104
|
+
*/
|
|
1105
|
+
const READ_BATCH_SIZE = 20;
|
|
1106
|
+
/**
|
|
1107
|
+
* Читает настройки уведомлений.
|
|
1108
|
+
*
|
|
1109
|
+
* Сервер отдаёт плоский объект: `enabled`, `sound`, `follows`, `wallPosts`, `likes`,
|
|
1110
|
+
* `comments`, `mentions`. Отсутствующая настройка считается включённой — так же
|
|
1111
|
+
* ведёт себя сайт итд.com.
|
|
1112
|
+
*/
|
|
1113
|
+
function readSettings(body) {
|
|
1114
|
+
const settings = {};
|
|
1115
|
+
for (const key of NOTIFICATION_SETTING_KEYS) settings[key] = pickBoolean(body, key, true);
|
|
1116
|
+
return settings;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Уведомления: список, счётчик, отметки о прочтении, настройки.
|
|
1120
|
+
*
|
|
1121
|
+
* Доступна как `itd.notifications`. Все уведомления приведены к единой форме, поэтому
|
|
1122
|
+
* объекты отсюда и из потока событий можно складывать в один список.
|
|
1123
|
+
*/
|
|
1124
|
+
var NotificationsResource = class extends BaseResource {
|
|
1125
|
+
/** Уведомления: `/api/notifications/`, пагинация по смещению. */
|
|
1126
|
+
#list = this.paginated({
|
|
1127
|
+
operationId: "notifications.list",
|
|
1128
|
+
path: () => "/api/notifications/",
|
|
1129
|
+
query: (p) => ({ limit: p.limit }),
|
|
1130
|
+
start: (p) => ({ offset: p.offset ?? 0 }),
|
|
1131
|
+
read: (body, state) => {
|
|
1132
|
+
const page = readOffsetPage(body, "notifications", state.offset ?? 0);
|
|
1133
|
+
return {
|
|
1134
|
+
...page,
|
|
1135
|
+
items: page.items.map(normalizeNotification)
|
|
1136
|
+
};
|
|
1137
|
+
},
|
|
1138
|
+
mode: PaginationMode.Offset
|
|
1139
|
+
});
|
|
1140
|
+
/**
|
|
1141
|
+
* Загружает страницу уведомлений.
|
|
1142
|
+
*
|
|
1143
|
+
* Пагинация здесь основана на смещении.
|
|
1144
|
+
*
|
|
1145
|
+
* @example
|
|
1146
|
+
* ```ts
|
|
1147
|
+
* const page = await itd.notifications.list({ limit: 20 });
|
|
1148
|
+
* const next = await itd.notifications.list({ limit: 20, offset: page.nextOffset });
|
|
1149
|
+
* ```
|
|
1150
|
+
*/
|
|
1151
|
+
list(params = {}, options = {}) {
|
|
1152
|
+
return this.#list.list(params, options);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Перебирает уведомления.
|
|
1156
|
+
*
|
|
1157
|
+
* @example
|
|
1158
|
+
* ```ts
|
|
1159
|
+
* for await (const notification of itd.notifications.iterate()) {
|
|
1160
|
+
* console.log(formatNotificationText(notification));
|
|
1161
|
+
* }
|
|
1162
|
+
* ```
|
|
1163
|
+
*/
|
|
1164
|
+
iterate(params = {}, options = {}) {
|
|
1165
|
+
return this.#list.iterate(params, options);
|
|
1166
|
+
}
|
|
1167
|
+
/** Загружает число непрочитанных уведомлений. */
|
|
1168
|
+
async count(options = {}) {
|
|
1169
|
+
const body = await this.http.operation("notifications.count", {
|
|
1170
|
+
path: "/api/notifications/count",
|
|
1171
|
+
...options
|
|
1172
|
+
});
|
|
1173
|
+
return pickNumber(body, "count", 0);
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Отмечает уведомление прочитанным.
|
|
1177
|
+
*
|
|
1178
|
+
* @returns сколько записей отметил сервер
|
|
1179
|
+
*/
|
|
1180
|
+
async markRead(notificationId, options = {}) {
|
|
1181
|
+
const body = await this.http.operation("notifications.markRead", {
|
|
1182
|
+
path: `/api/notifications/${encodePathSegment(notificationId, "notificationId")}/read`,
|
|
1183
|
+
...options
|
|
1184
|
+
});
|
|
1185
|
+
return pickNumber(body, "markedCount", 0);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Отмечает прочитанными сразу несколько уведомлений.
|
|
1189
|
+
*
|
|
1190
|
+
* Список автоматически режется на части по 20 идентификаторов — столько же отправляет
|
|
1191
|
+
* сайт итд.com, поэтому на сервере вероятен предел. Части уходят последовательно,
|
|
1192
|
+
* результат суммируется.
|
|
1193
|
+
*
|
|
1194
|
+
* @returns сколько записей отметил сервер суммарно
|
|
1195
|
+
*/
|
|
1196
|
+
async markReadBatch(ids, options = {}) {
|
|
1197
|
+
let marked = 0;
|
|
1198
|
+
for (let index = 0; index < ids.length; index += READ_BATCH_SIZE) {
|
|
1199
|
+
const chunk = ids.slice(index, index + READ_BATCH_SIZE);
|
|
1200
|
+
const body = await this.http.operation("notifications.markReadBatch", {
|
|
1201
|
+
path: "/api/notifications/read-batch",
|
|
1202
|
+
body: { ids: chunk },
|
|
1203
|
+
...options
|
|
1204
|
+
});
|
|
1205
|
+
marked += pickNumber(body, "markedCount", 0);
|
|
1206
|
+
}
|
|
1207
|
+
return marked;
|
|
1208
|
+
}
|
|
1209
|
+
/** Отмечает прочитанными все уведомления. */
|
|
1210
|
+
async markAllRead(options = {}) {
|
|
1211
|
+
const body = await this.http.operation("notifications.markAllRead", {
|
|
1212
|
+
path: "/api/notifications/read-all",
|
|
1213
|
+
...options
|
|
1214
|
+
});
|
|
1215
|
+
return pickNumber(body, "markedCount", 0);
|
|
1216
|
+
}
|
|
1217
|
+
/** Загружает настройки уведомлений. */
|
|
1218
|
+
async getSettings(options = {}) {
|
|
1219
|
+
return readSettings(await this.http.operation("notifications.getSettings", {
|
|
1220
|
+
path: "/api/notifications/settings",
|
|
1221
|
+
...options
|
|
1222
|
+
}));
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Обновляет настройки уведомлений.
|
|
1226
|
+
*
|
|
1227
|
+
* Отправляются только изменяемые поля, в том же виде, в каком сервер их возвращает.
|
|
1228
|
+
*/
|
|
1229
|
+
async updateSettings(input, options = {}) {
|
|
1230
|
+
const payload = {};
|
|
1231
|
+
for (const key of NOTIFICATION_SETTING_KEYS) {
|
|
1232
|
+
const value = input[key];
|
|
1233
|
+
if (value !== void 0) payload[key] = value;
|
|
1234
|
+
}
|
|
1235
|
+
return readSettings(await this.http.operation("notifications.updateSettings", {
|
|
1236
|
+
path: "/api/notifications/settings",
|
|
1237
|
+
body: payload,
|
|
1238
|
+
...options
|
|
1239
|
+
}));
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
//#endregion
|
|
1243
|
+
//#region src/domain/time.ts
|
|
1244
|
+
const NAIVE_STAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/;
|
|
1245
|
+
/**
|
|
1246
|
+
* Приводит отметку времени без часового пояса к ISO-8601, считая её временем UTC.
|
|
1247
|
+
*
|
|
1248
|
+
* Строку другого вида возвращает нетронутой.
|
|
1249
|
+
*
|
|
1250
|
+
* @example
|
|
1251
|
+
* ```ts
|
|
1252
|
+
* utcStampToIso('2026-07-23 23:14:25'); // '2026-07-23T23:14:25Z'
|
|
1253
|
+
* utcStampToIso('2026-07-23T23:14:25Z'); // без изменений
|
|
1254
|
+
* ```
|
|
1255
|
+
*/
|
|
1256
|
+
function utcStampToIso(value) {
|
|
1257
|
+
const match = typeof value === "string" ? NAIVE_STAMP.exec(value) : null;
|
|
1258
|
+
if (!match) return value;
|
|
1259
|
+
const iso = `${match[1]}T${match[2]}Z`;
|
|
1260
|
+
return Number.isFinite(Date.parse(iso)) ? iso : value;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Разбирает дату API в объект `Date`.
|
|
1264
|
+
*
|
|
1265
|
+
* @returns `null`, если строки нет или она не разбирается
|
|
1266
|
+
*
|
|
1267
|
+
* @example
|
|
1268
|
+
* ```ts
|
|
1269
|
+
* const created = toDate(post.createdAt);
|
|
1270
|
+
* ```
|
|
1271
|
+
*/
|
|
1272
|
+
function toDate(value) {
|
|
1273
|
+
if (!value) return null;
|
|
1274
|
+
const date = new Date(value);
|
|
1275
|
+
return Number.isFinite(date.getTime()) ? date : null;
|
|
1276
|
+
}
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/resources/platform.ts
|
|
1279
|
+
/** Приводит `last_checked` каждого сервиса к ISO. Остальное остаётся как прислал сервер. */
|
|
1280
|
+
function normalizeStatus(body) {
|
|
1281
|
+
if (!isRecord(body) || !Array.isArray(body.services)) return body;
|
|
1282
|
+
return {
|
|
1283
|
+
...body,
|
|
1284
|
+
services: body.services.map((service) => typeof service?.last_checked === "string" ? {
|
|
1285
|
+
...service,
|
|
1286
|
+
last_checked: utcStampToIso(service.last_checked)
|
|
1287
|
+
} : service)
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Сведения о платформе: версии приложений, изменения, анонсы, баннер события.
|
|
1292
|
+
*
|
|
1293
|
+
* Доступна как `itd.platform`.
|
|
1294
|
+
*/
|
|
1295
|
+
var PlatformResource = class extends BaseResource {
|
|
1296
|
+
/**
|
|
1297
|
+
* Загружает минимальные и актуальные версии клиентских приложений.
|
|
1298
|
+
*
|
|
1299
|
+
* Endpoint публичный: автоматическая авторизация в запрос не добавляется.
|
|
1300
|
+
*
|
|
1301
|
+
* @example
|
|
1302
|
+
* ```ts
|
|
1303
|
+
* const versions = await itd.platform.version();
|
|
1304
|
+
* console.log(versions.android.latestVersion);
|
|
1305
|
+
* ```
|
|
1306
|
+
*/
|
|
1307
|
+
version(options = {}) {
|
|
1308
|
+
return this.http.operation("platform.version", {
|
|
1309
|
+
path: "/api/platform/version",
|
|
1310
|
+
skipAuth: true,
|
|
1311
|
+
...options
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
/** Загружает журнал изменений. */
|
|
1315
|
+
async changelog(options = {}) {
|
|
1316
|
+
const body = await this.http.operation("platform.changelog", {
|
|
1317
|
+
path: "/api/platform/changelog",
|
|
1318
|
+
...options
|
|
1319
|
+
});
|
|
1320
|
+
return Array.isArray(body) ? body : [];
|
|
1321
|
+
}
|
|
1322
|
+
/** Загружает анонсы платформы. */
|
|
1323
|
+
async announcements(options = {}) {
|
|
1324
|
+
const body = await this.http.operation("platform.announcements", {
|
|
1325
|
+
path: "/api/platform/announcements",
|
|
1326
|
+
...options
|
|
1327
|
+
});
|
|
1328
|
+
return pickArray(body, "announcements");
|
|
1329
|
+
}
|
|
1330
|
+
/** Загружает баннер текущего события — виджет «портал». */
|
|
1331
|
+
portal(options = {}) {
|
|
1332
|
+
return this.http.operation("platform.portal", {
|
|
1333
|
+
path: "/api/v1/portal",
|
|
1334
|
+
...options
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Загружает состояние сервисов платформы за последние 90 суток.
|
|
1339
|
+
*
|
|
1340
|
+
* Идёт на хост `статус.итд.com` без авторизации. Ответ кэшируется сервером на минуту.
|
|
1341
|
+
* История по суткам приходит разреженной, ровный массив даёт `statusDays`.
|
|
1342
|
+
*
|
|
1343
|
+
* @example
|
|
1344
|
+
* ```ts
|
|
1345
|
+
* const status = await itd.platform.status();
|
|
1346
|
+
*
|
|
1347
|
+
* if (status.overall_status !== 'operational') {
|
|
1348
|
+
* const broken = status.services.filter((s) => s.current_status !== 'operational');
|
|
1349
|
+
* console.log('лежит:', broken.map((s) => s.name).join(', '));
|
|
1350
|
+
* }
|
|
1351
|
+
* ```
|
|
1352
|
+
*/
|
|
1353
|
+
async status(options = {}) {
|
|
1354
|
+
return normalizeStatus(await this.http.operation("platform.status", {
|
|
1355
|
+
service: STATUS_SERVICE,
|
|
1356
|
+
path: "/api/status",
|
|
1357
|
+
...options
|
|
1358
|
+
}));
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
//#endregion
|
|
1362
|
+
//#region src/spans/validate.ts
|
|
1363
|
+
/**
|
|
1364
|
+
* Проверяет, что spans целиком лежат внутри текста.
|
|
1365
|
+
*
|
|
1366
|
+
* Возвращает копии, чтобы последующая мутация входного массива не меняла уже собранные данные.
|
|
1367
|
+
*/
|
|
1368
|
+
function validateSpans(content, spans) {
|
|
1369
|
+
if (!Array.isArray(spans)) throw new ItdConfigError("spans должен быть массивом");
|
|
1370
|
+
if (content.length === 0 && spans.length > 0) throw new ItdConfigError("Нельзя задать spans без текста: сначала укажите непустой content");
|
|
1371
|
+
return spans.map((span, index) => {
|
|
1372
|
+
if (!span || typeof span !== "object") throw new ItdConfigError(`Некорректный span №${index + 1}: ожидался объект`);
|
|
1373
|
+
const candidate = span;
|
|
1374
|
+
if (typeof candidate.type !== "string" || !Number.isInteger(candidate.offset) || !Number.isInteger(candidate.length) || (candidate.offset ?? -1) < 0 || (candidate.length ?? 0) <= 0 || (candidate.offset ?? 0) + (candidate.length ?? 0) > content.length) throw new ItdConfigError(`Некорректный span №${index + 1}: offset и length должны указывать на непустой фрагмент content`);
|
|
1375
|
+
return { ...candidate };
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
//#endregion
|
|
1379
|
+
//#region src/spans/parse.ts
|
|
1380
|
+
const HTML_BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
1381
|
+
"address",
|
|
1382
|
+
"article",
|
|
1383
|
+
"aside",
|
|
1384
|
+
"blockquote",
|
|
1385
|
+
"div",
|
|
1386
|
+
"footer",
|
|
1387
|
+
"h1",
|
|
1388
|
+
"h2",
|
|
1389
|
+
"h3",
|
|
1390
|
+
"h4",
|
|
1391
|
+
"h5",
|
|
1392
|
+
"h6",
|
|
1393
|
+
"header",
|
|
1394
|
+
"li",
|
|
1395
|
+
"main",
|
|
1396
|
+
"nav",
|
|
1397
|
+
"ol",
|
|
1398
|
+
"p",
|
|
1399
|
+
"pre",
|
|
1400
|
+
"section",
|
|
1401
|
+
"ul"
|
|
1402
|
+
]);
|
|
1403
|
+
const HTML_DROPPED_TAGS = /* @__PURE__ */ new Set([
|
|
1404
|
+
"applet",
|
|
1405
|
+
"canvas",
|
|
1406
|
+
"embed",
|
|
1407
|
+
"iframe",
|
|
1408
|
+
"math",
|
|
1409
|
+
"noscript",
|
|
1410
|
+
"object",
|
|
1411
|
+
"script",
|
|
1412
|
+
"style",
|
|
1413
|
+
"svg",
|
|
1414
|
+
"template"
|
|
1415
|
+
]);
|
|
1416
|
+
const HTML_ENTITIES = Object.freeze({
|
|
1417
|
+
amp: "&",
|
|
1418
|
+
apos: "'",
|
|
1419
|
+
gt: ">",
|
|
1420
|
+
lt: "<",
|
|
1421
|
+
nbsp: "\xA0",
|
|
1422
|
+
quot: "\""
|
|
1423
|
+
});
|
|
1424
|
+
function append(output, value) {
|
|
1425
|
+
output.content += value;
|
|
1426
|
+
}
|
|
1427
|
+
function addSpan(output, start, span) {
|
|
1428
|
+
const length = output.content.length - start;
|
|
1429
|
+
if (length > 0) output.spans.push({
|
|
1430
|
+
...span,
|
|
1431
|
+
offset: start,
|
|
1432
|
+
length
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
function finish(output) {
|
|
1436
|
+
const unique = /* @__PURE__ */ new Map();
|
|
1437
|
+
for (const span of output.spans) {
|
|
1438
|
+
const key = [
|
|
1439
|
+
span.type,
|
|
1440
|
+
span.offset,
|
|
1441
|
+
span.length,
|
|
1442
|
+
span.url ?? "",
|
|
1443
|
+
span.tag ?? "",
|
|
1444
|
+
span.username ?? "",
|
|
1445
|
+
span.id ?? ""
|
|
1446
|
+
].join("\0");
|
|
1447
|
+
if (!unique.has(key)) unique.set(key, span);
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
content: output.content,
|
|
1451
|
+
spans: validateSpans(output.content, [...unique.values()]).sort((left, right) => left.offset - right.offset || right.length - left.length)
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
function allowedProtocols(options) {
|
|
1455
|
+
const values = options.allowedLinkProtocols ?? ["http", "https"];
|
|
1456
|
+
return new Set(values.map((value) => `${value.toLowerCase().replace(/:$/u, "")}:`).filter((value) => value !== ":"));
|
|
1457
|
+
}
|
|
1458
|
+
function safeLink(value, protocols) {
|
|
1459
|
+
try {
|
|
1460
|
+
const url = new URL(value);
|
|
1461
|
+
return url.hostname !== "" && protocols.has(url.protocol.toLowerCase()) ? value : null;
|
|
1462
|
+
} catch {
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
function isEscaped(value, index) {
|
|
1467
|
+
let slashes = 0;
|
|
1468
|
+
for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) slashes += 1;
|
|
1469
|
+
return slashes % 2 === 1;
|
|
1470
|
+
}
|
|
1471
|
+
function findClosing(value, marker, from) {
|
|
1472
|
+
let cursor = from;
|
|
1473
|
+
while (cursor < value.length) {
|
|
1474
|
+
const found = value.indexOf(marker, cursor);
|
|
1475
|
+
if (found < 0) return -1;
|
|
1476
|
+
if (!isEscaped(value, found)) return found;
|
|
1477
|
+
cursor = found + marker.length;
|
|
1478
|
+
}
|
|
1479
|
+
return -1;
|
|
1480
|
+
}
|
|
1481
|
+
function findBracketEnd(value, from, open, close) {
|
|
1482
|
+
let depth = 1;
|
|
1483
|
+
for (let cursor = from; cursor < value.length; cursor += 1) {
|
|
1484
|
+
const character = value[cursor];
|
|
1485
|
+
if (character === "\\") {
|
|
1486
|
+
cursor += 1;
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
if (character === open) depth += 1;
|
|
1490
|
+
if (character === close) {
|
|
1491
|
+
depth -= 1;
|
|
1492
|
+
if (depth === 0) return cursor;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return -1;
|
|
1496
|
+
}
|
|
1497
|
+
function decodeEntities(value) {
|
|
1498
|
+
return value.replace(/&(#(?:x[\da-f]+|\d+)|[a-z][a-z\d]+);/giu, (raw, entity) => {
|
|
1499
|
+
if (entity.startsWith("#x") || entity.startsWith("#X")) {
|
|
1500
|
+
const code = Number.parseInt(entity.slice(2), 16);
|
|
1501
|
+
return Number.isFinite(code) && code <= 1114111 ? String.fromCodePoint(code) : raw;
|
|
1502
|
+
}
|
|
1503
|
+
if (entity.startsWith("#")) {
|
|
1504
|
+
const code = Number.parseInt(entity.slice(1), 10);
|
|
1505
|
+
return Number.isFinite(code) && code <= 1114111 ? String.fromCodePoint(code) : raw;
|
|
1506
|
+
}
|
|
1507
|
+
return HTML_ENTITIES[entity.toLowerCase()] ?? raw;
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
function normalizeCodeSpan(value) {
|
|
1511
|
+
const normalized = value.replace(/\r\n?|\n/gu, " ");
|
|
1512
|
+
if (normalized.length >= 2 && normalized.startsWith(" ") && normalized.endsWith(" ") && !/^ +$/u.test(normalized)) return normalized.slice(1, -1);
|
|
1513
|
+
return normalized;
|
|
1514
|
+
}
|
|
1515
|
+
function markdownStyle(source, index) {
|
|
1516
|
+
const styles = [
|
|
1517
|
+
{
|
|
1518
|
+
marker: "**",
|
|
1519
|
+
type: SpanType.Bold
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
marker: "__",
|
|
1523
|
+
type: SpanType.Bold
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
marker: "~~",
|
|
1527
|
+
type: SpanType.Strike
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
marker: "||",
|
|
1531
|
+
type: SpanType.Spoiler
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
marker: "*",
|
|
1535
|
+
type: SpanType.Italic
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
marker: "_",
|
|
1539
|
+
type: SpanType.Italic
|
|
1540
|
+
}
|
|
1541
|
+
];
|
|
1542
|
+
for (const style of styles) {
|
|
1543
|
+
if (!source.startsWith(style.marker, index)) continue;
|
|
1544
|
+
if (style.marker.includes("_")) {
|
|
1545
|
+
const before = source[index - 1] ?? "";
|
|
1546
|
+
const after = source[index + style.marker.length] ?? "";
|
|
1547
|
+
if (/[\p{L}\p{N}]/u.test(before) && /[\p{L}\p{N}]/u.test(after)) continue;
|
|
1548
|
+
}
|
|
1549
|
+
return style;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
function parseMarkdownInline(source, output, protocols) {
|
|
1553
|
+
let index = 0;
|
|
1554
|
+
while (index < source.length) {
|
|
1555
|
+
const character = source[index] ?? "";
|
|
1556
|
+
if (character === "\\" && index + 1 < source.length) {
|
|
1557
|
+
append(output, source[index + 1] ?? "");
|
|
1558
|
+
index += 2;
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
if (character === "`") {
|
|
1562
|
+
const run = source.slice(index).match(/^`+/u)?.[0] ?? "`";
|
|
1563
|
+
const close = findClosing(source, run, index + run.length);
|
|
1564
|
+
if (close > index + run.length) {
|
|
1565
|
+
const start = output.content.length;
|
|
1566
|
+
append(output, normalizeCodeSpan(source.slice(index + run.length, close)));
|
|
1567
|
+
addSpan(output, start, { type: SpanType.Monospace });
|
|
1568
|
+
index = close + run.length;
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
const image = source.startsWith("![", index);
|
|
1573
|
+
if ((image || character === "[") && !isEscaped(source, index)) {
|
|
1574
|
+
const labelStart = index + (image ? 2 : 1);
|
|
1575
|
+
const labelEnd = findBracketEnd(source, labelStart, "[", "]");
|
|
1576
|
+
if (labelEnd >= 0 && source[labelEnd + 1] === "(") {
|
|
1577
|
+
const destinationEnd = findBracketEnd(source, labelEnd + 2, "(", ")");
|
|
1578
|
+
if (destinationEnd >= 0) {
|
|
1579
|
+
const label = source.slice(labelStart, labelEnd);
|
|
1580
|
+
const destination = source.slice(labelEnd + 2, destinationEnd).trim().replace(/\\([\\)])/gu, "$1");
|
|
1581
|
+
const start = output.content.length;
|
|
1582
|
+
parseMarkdownInline(label, output, protocols);
|
|
1583
|
+
if (!image) {
|
|
1584
|
+
const url = safeLink(destination, protocols);
|
|
1585
|
+
if (url) addSpan(output, start, {
|
|
1586
|
+
type: SpanType.Link,
|
|
1587
|
+
url
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
index = destinationEnd + 1;
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (source.slice(index, index + 3).toLowerCase() === "<u>") {
|
|
1596
|
+
const close = source.toLowerCase().indexOf("</u>", index + 3);
|
|
1597
|
+
if (close > index + 3) {
|
|
1598
|
+
const start = output.content.length;
|
|
1599
|
+
parseMarkdownInline(source.slice(index + 3, close), output, protocols);
|
|
1600
|
+
addSpan(output, start, { type: SpanType.Underline });
|
|
1601
|
+
index = close + 4;
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
if (character === "<") {
|
|
1606
|
+
const close = source.indexOf(">", index + 1);
|
|
1607
|
+
if (close > index + 1) {
|
|
1608
|
+
const candidate = decodeEntities(source.slice(index + 1, close));
|
|
1609
|
+
const url = safeLink(candidate, protocols);
|
|
1610
|
+
if (url) {
|
|
1611
|
+
const start = output.content.length;
|
|
1612
|
+
append(output, candidate);
|
|
1613
|
+
addSpan(output, start, {
|
|
1614
|
+
type: SpanType.Link,
|
|
1615
|
+
url
|
|
1616
|
+
});
|
|
1617
|
+
index = close + 1;
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
const style = markdownStyle(source, index);
|
|
1623
|
+
if (style) {
|
|
1624
|
+
const close = findClosing(source, style.marker, index + style.marker.length);
|
|
1625
|
+
if (close > index + style.marker.length) {
|
|
1626
|
+
const start = output.content.length;
|
|
1627
|
+
parseMarkdownInline(source.slice(index + style.marker.length, close), output, protocols);
|
|
1628
|
+
addSpan(output, start, { type: style.type });
|
|
1629
|
+
index = close + style.marker.length;
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (character === "&") {
|
|
1634
|
+
const entity = source.slice(index).match(/^&(?:#(?:x[\da-f]+|\d+)|[a-z][a-z\d]+);/iu)?.[0];
|
|
1635
|
+
if (entity) {
|
|
1636
|
+
append(output, decodeEntities(entity));
|
|
1637
|
+
index += entity.length;
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
append(output, character);
|
|
1642
|
+
index += 1;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
function lineEnd(source, from) {
|
|
1646
|
+
const found = source.indexOf("\n", from);
|
|
1647
|
+
return found < 0 ? source.length : found;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Преобразует безопасное подмножество Markdown в текст и wire-spans.
|
|
1651
|
+
*
|
|
1652
|
+
* Поддерживаются bold, italic, strike, spoiler, inline/fenced code, ссылки, underline
|
|
1653
|
+
* через `<u>` и цитаты `>`. Неподдерживаемая и незакрытая разметка остаётся текстом.
|
|
1654
|
+
* У изображений сохраняется alt-текст без URL.
|
|
1655
|
+
*/
|
|
1656
|
+
function parseMarkdown(source, options = {}) {
|
|
1657
|
+
const output = {
|
|
1658
|
+
content: "",
|
|
1659
|
+
spans: []
|
|
1660
|
+
};
|
|
1661
|
+
const protocols = allowedProtocols(options);
|
|
1662
|
+
let position = 0;
|
|
1663
|
+
while (position < source.length) {
|
|
1664
|
+
const end = lineEnd(source, position);
|
|
1665
|
+
const line = source.slice(position, end);
|
|
1666
|
+
const hasNewline = end < source.length;
|
|
1667
|
+
const fence = line.match(/^ {0,3}(`{3,}|~{3,})[^\n]*$/u)?.[1];
|
|
1668
|
+
if (fence) {
|
|
1669
|
+
let closeStart = hasNewline ? end + 1 : source.length;
|
|
1670
|
+
let closeEnd = closeStart;
|
|
1671
|
+
let found = false;
|
|
1672
|
+
while (closeStart < source.length) {
|
|
1673
|
+
closeEnd = lineEnd(source, closeStart);
|
|
1674
|
+
const marker = source.slice(closeStart, closeEnd).match(/^ {0,3}(`{3,}|~{3,})\s*$/u)?.[1];
|
|
1675
|
+
if (marker !== void 0 && marker[0] === fence[0] && marker.length >= fence.length) {
|
|
1676
|
+
found = true;
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
closeStart = closeEnd < source.length ? closeEnd + 1 : source.length;
|
|
1680
|
+
}
|
|
1681
|
+
if (found) {
|
|
1682
|
+
const bodyStart = end + 1;
|
|
1683
|
+
let body = source.slice(bodyStart, closeStart);
|
|
1684
|
+
if (body.endsWith("\n")) body = body.slice(0, -1);
|
|
1685
|
+
const start = output.content.length;
|
|
1686
|
+
append(output, body);
|
|
1687
|
+
addSpan(output, start, { type: SpanType.Monospace });
|
|
1688
|
+
if (closeEnd < source.length) append(output, "\n");
|
|
1689
|
+
position = closeEnd < source.length ? closeEnd + 1 : source.length;
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
const quote = line.match(/^ {0,3}> ?(.*)$/u);
|
|
1694
|
+
if (quote) {
|
|
1695
|
+
const start = output.content.length;
|
|
1696
|
+
parseMarkdownInline(quote[1] ?? "", output, protocols);
|
|
1697
|
+
addSpan(output, start, { type: SpanType.Quote });
|
|
1698
|
+
} else parseMarkdownInline(line, output, protocols);
|
|
1699
|
+
if (hasNewline) append(output, "\n");
|
|
1700
|
+
position = hasNewline ? end + 1 : source.length;
|
|
1701
|
+
}
|
|
1702
|
+
return finish(output);
|
|
1703
|
+
}
|
|
1704
|
+
function readAttribute(source, name) {
|
|
1705
|
+
const pattern = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\\x60]+))`, "iu");
|
|
1706
|
+
const match = source.match(pattern);
|
|
1707
|
+
const value = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
1708
|
+
return value === void 0 ? void 0 : decodeEntities(value);
|
|
1709
|
+
}
|
|
1710
|
+
function htmlSpan(tag, attributes, protocols) {
|
|
1711
|
+
switch (tag) {
|
|
1712
|
+
case "b":
|
|
1713
|
+
case "strong": return { type: SpanType.Bold };
|
|
1714
|
+
case "em":
|
|
1715
|
+
case "i": return { type: SpanType.Italic };
|
|
1716
|
+
case "u": return { type: SpanType.Underline };
|
|
1717
|
+
case "del":
|
|
1718
|
+
case "s":
|
|
1719
|
+
case "strike": return { type: SpanType.Strike };
|
|
1720
|
+
case "code":
|
|
1721
|
+
case "pre": return { type: SpanType.Monospace };
|
|
1722
|
+
case "blockquote": return { type: SpanType.Quote };
|
|
1723
|
+
case "a": {
|
|
1724
|
+
const href = readAttribute(attributes, "href");
|
|
1725
|
+
const url = href ? safeLink(href, protocols) : null;
|
|
1726
|
+
return url ? {
|
|
1727
|
+
type: SpanType.Link,
|
|
1728
|
+
url
|
|
1729
|
+
} : void 0;
|
|
1730
|
+
}
|
|
1731
|
+
case "span": {
|
|
1732
|
+
const classes = readAttribute(attributes, "class")?.split(/\s+/u) ?? [];
|
|
1733
|
+
if (readAttribute(attributes, "data-spoiler") === "true" || classes.some((value) => value.endsWith("-spoiler"))) return { type: SpanType.Spoiler };
|
|
1734
|
+
if (classes.some((value) => value.endsWith("-quote"))) return { type: SpanType.Quote };
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
default: return;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
function ensureBlockBreak(output) {
|
|
1741
|
+
if (output.content !== "" && !output.content.endsWith("\n")) append(output, "\n");
|
|
1742
|
+
}
|
|
1743
|
+
function findHtmlTagEnd(source, from) {
|
|
1744
|
+
let quote;
|
|
1745
|
+
for (let index = from; index < source.length; index += 1) {
|
|
1746
|
+
const character = source[index];
|
|
1747
|
+
if (quote) {
|
|
1748
|
+
if (character === quote) quote = void 0;
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
if (character === "\"" || character === "'") {
|
|
1752
|
+
quote = character;
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
if (character === ">") return index;
|
|
1756
|
+
}
|
|
1757
|
+
return -1;
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Преобразует ограниченный HTML в обычный текст и wire-spans.
|
|
1761
|
+
*
|
|
1762
|
+
* Разрешены только семантические теги форматирования. Атрибуты событий игнорируются,
|
|
1763
|
+
* содержимое `script`, `style`, `iframe`, `object`, SVG и подобных активных элементов
|
|
1764
|
+
* удаляется. Опасный `href` сохраняет текст ссылки, но не создаёт span.
|
|
1765
|
+
*/
|
|
1766
|
+
function parseHtml(source, options = {}) {
|
|
1767
|
+
const output = {
|
|
1768
|
+
content: "",
|
|
1769
|
+
spans: []
|
|
1770
|
+
};
|
|
1771
|
+
const protocols = allowedProtocols(options);
|
|
1772
|
+
const stack = [];
|
|
1773
|
+
let position = 0;
|
|
1774
|
+
const closeTag = (tag) => {
|
|
1775
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
1776
|
+
const open = stack[index];
|
|
1777
|
+
if (open?.tag !== tag) continue;
|
|
1778
|
+
stack.splice(index, 1);
|
|
1779
|
+
addSpan(output, open.start, open.span);
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
while (position < source.length) {
|
|
1784
|
+
if (source.startsWith("<!--", position)) {
|
|
1785
|
+
const end = source.indexOf("-->", position + 4);
|
|
1786
|
+
position = end < 0 ? source.length : end + 3;
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
if (source[position] === "<") {
|
|
1790
|
+
const end = findHtmlTagEnd(source, position + 1);
|
|
1791
|
+
if (end >= 0) {
|
|
1792
|
+
const parsed = source.slice(position, end + 1).match(/^<\s*(\/?)\s*([a-z][\w-]*)([\s\S]*?)\/?\s*>$/iu);
|
|
1793
|
+
if (parsed) {
|
|
1794
|
+
const closing = parsed[1] === "/";
|
|
1795
|
+
const tag = (parsed[2] ?? "").toLowerCase();
|
|
1796
|
+
const attributes = parsed[3] ?? "";
|
|
1797
|
+
if (!closing && HTML_DROPPED_TAGS.has(tag)) {
|
|
1798
|
+
const closingPattern = new RegExp(`<\\s*\\/\\s*${tag}\\s*>`, "igu");
|
|
1799
|
+
closingPattern.lastIndex = end + 1;
|
|
1800
|
+
const match = closingPattern.exec(source);
|
|
1801
|
+
position = match ? match.index + match[0].length : source.length;
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
if (tag === "br" && !closing) {
|
|
1805
|
+
append(output, "\n");
|
|
1806
|
+
position = end + 1;
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
if (tag === "img" && !closing) {
|
|
1810
|
+
append(output, readAttribute(attributes, "alt") ?? "");
|
|
1811
|
+
position = end + 1;
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
if (closing) {
|
|
1815
|
+
closeTag(tag);
|
|
1816
|
+
if (HTML_BLOCK_TAGS.has(tag)) ensureBlockBreak(output);
|
|
1817
|
+
} else {
|
|
1818
|
+
if (HTML_BLOCK_TAGS.has(tag)) ensureBlockBreak(output);
|
|
1819
|
+
const span = htmlSpan(tag, attributes, protocols);
|
|
1820
|
+
if (span) stack.push({
|
|
1821
|
+
tag,
|
|
1822
|
+
start: output.content.length,
|
|
1823
|
+
span
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
position = end + 1;
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
const next = source.indexOf("<", position + 1);
|
|
1832
|
+
const end = next < 0 ? source.length : next;
|
|
1833
|
+
append(output, decodeEntities(source.slice(position, end)));
|
|
1834
|
+
position = end;
|
|
1835
|
+
}
|
|
1836
|
+
while (stack.length > 0) {
|
|
1837
|
+
const open = stack.pop();
|
|
1838
|
+
if (open) addSpan(output, open.start, open.span);
|
|
1839
|
+
}
|
|
1840
|
+
while (output.content.endsWith("\n")) output.content = output.content.slice(0, -1);
|
|
1841
|
+
return finish(output);
|
|
1842
|
+
}
|
|
1843
|
+
//#endregion
|
|
1844
|
+
//#region src/builders/markup.ts
|
|
1845
|
+
function validateMarkup(value) {
|
|
1846
|
+
if (!value || typeof value.content !== "string" || !Array.isArray(value.spans)) throw new ItdConfigError("Разметка должна содержать строку content и массив spans");
|
|
1847
|
+
return {
|
|
1848
|
+
content: value.content,
|
|
1849
|
+
spans: validateSpans(value.content, value.spans).sort((left, right) => left.offset - right.offset || right.length - left.length)
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
function entityName(value, prefix, label) {
|
|
1853
|
+
const name = value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
1854
|
+
if (name === "" || /\s/u.test(name)) throw new ItdConfigError(`${label} должен быть непустым и не содержать пробелов`);
|
|
1855
|
+
return name;
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Неизменяемый билдер текста с разметкой.
|
|
1859
|
+
*
|
|
1860
|
+
* Каждый метод дописывает фрагмент и сам считает `offset` и `length` в единицах UTF-16 —
|
|
1861
|
+
* именно такие индексы использует JavaScript-редактор сайта.
|
|
1862
|
+
*/
|
|
1863
|
+
var MarkupBuilder = class MarkupBuilder {
|
|
1864
|
+
/** @internal */
|
|
1865
|
+
[BUILDER] = true;
|
|
1866
|
+
#content;
|
|
1867
|
+
#spans;
|
|
1868
|
+
/** @internal Создавайте билдер функцией {@link markup}. */
|
|
1869
|
+
constructor(content, spans) {
|
|
1870
|
+
this.#content = content;
|
|
1871
|
+
this.#spans = spans;
|
|
1872
|
+
}
|
|
1873
|
+
/** Дописывает обычный текст без разметки. */
|
|
1874
|
+
text(value) {
|
|
1875
|
+
return new MarkupBuilder(this.#content + value, this.#spans);
|
|
1876
|
+
}
|
|
1877
|
+
/** Дописывает переводы строк. */
|
|
1878
|
+
newline(count = 1) {
|
|
1879
|
+
if (!Number.isInteger(count) || count < 0) throw new ItdConfigError("Количество переводов строк должно быть целым неотрицательным");
|
|
1880
|
+
return this.text("\n".repeat(count));
|
|
1881
|
+
}
|
|
1882
|
+
/** @internal Добавляет готовый фрагмент и сдвигает его spans к текущему концу текста. */
|
|
1883
|
+
#formatted(value, outerSpans) {
|
|
1884
|
+
const fragment = typeof value === "string" ? {
|
|
1885
|
+
content: value,
|
|
1886
|
+
spans: []
|
|
1887
|
+
} : resolveMarkup(value);
|
|
1888
|
+
if (fragment.content.length === 0) throw new ItdConfigError("Размечаемый фрагмент не может быть пустым");
|
|
1889
|
+
const offset = this.#content.length;
|
|
1890
|
+
const shifted = fragment.spans.map((span) => ({
|
|
1891
|
+
...span,
|
|
1892
|
+
offset: span.offset + offset
|
|
1893
|
+
}));
|
|
1894
|
+
const outer = outerSpans.map((span) => ({
|
|
1895
|
+
...span,
|
|
1896
|
+
offset,
|
|
1897
|
+
length: fragment.content.length
|
|
1898
|
+
}));
|
|
1899
|
+
return new MarkupBuilder(this.#content + fragment.content, [
|
|
1900
|
+
...this.#spans,
|
|
1901
|
+
...shifted,
|
|
1902
|
+
...outer
|
|
1903
|
+
]);
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Дописывает фрагмент с произвольным типом разметки.
|
|
1907
|
+
*
|
|
1908
|
+
* Вложенный билдер позволяет форматировать часть фрагмента дополнительным стилем.
|
|
1909
|
+
* Для нескольких стилей на всём фрагменте используйте {@link styled}.
|
|
1910
|
+
*/
|
|
1911
|
+
span(value, span) {
|
|
1912
|
+
return this.#formatted(value, [span]);
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Дописывает фрагмент с несколькими стилями на одном диапазоне.
|
|
1916
|
+
*
|
|
1917
|
+
* Для `link`, которому нужен `url`, используйте {@link link}; произвольные spans с
|
|
1918
|
+
* метаданными можно объединять через вложенные вызовы {@link span}.
|
|
1919
|
+
*/
|
|
1920
|
+
styled(value, ...types) {
|
|
1921
|
+
if (types.length === 0) return this.#formatted(value, []);
|
|
1922
|
+
return this.#formatted(value, types.map((type) => ({ type })));
|
|
1923
|
+
}
|
|
1924
|
+
/** Дописывает `#хэштег` и сохраняет имя без решётки в `tag`. */
|
|
1925
|
+
hashtag(tag) {
|
|
1926
|
+
const name = entityName(tag, "#", "Хэштег");
|
|
1927
|
+
return this.span(`#${name}`, {
|
|
1928
|
+
type: SpanType.Hashtag,
|
|
1929
|
+
tag: name
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
/** Дописывает `@username` и сохраняет имя пользователя в `username`. */
|
|
1933
|
+
mention(username) {
|
|
1934
|
+
const name = entityName(username, "@", "Упоминание");
|
|
1935
|
+
return this.span(`@${name}`, {
|
|
1936
|
+
type: SpanType.Mention,
|
|
1937
|
+
username: name
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Дописывает ссылку.
|
|
1942
|
+
*
|
|
1943
|
+
* Для строки адрес по умолчанию становится и текстом ссылки. Вложенному форматированному
|
|
1944
|
+
* фрагменту URL нужно передать явно.
|
|
1945
|
+
*/
|
|
1946
|
+
link(content, url) {
|
|
1947
|
+
const resolvedUrl = url ?? (typeof content === "string" ? content : void 0);
|
|
1948
|
+
if (resolvedUrl === void 0) throw new ItdConfigError("Для форматированного текста ссылки нужно явно передать URL");
|
|
1949
|
+
return this.span(content, {
|
|
1950
|
+
type: SpanType.Link,
|
|
1951
|
+
url: resolvedUrl
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
bold(content) {
|
|
1955
|
+
return this.span(content, { type: SpanType.Bold });
|
|
1956
|
+
}
|
|
1957
|
+
italic(content) {
|
|
1958
|
+
return this.span(content, { type: SpanType.Italic });
|
|
1959
|
+
}
|
|
1960
|
+
underline(content) {
|
|
1961
|
+
return this.span(content, { type: SpanType.Underline });
|
|
1962
|
+
}
|
|
1963
|
+
strike(content) {
|
|
1964
|
+
return this.span(content, { type: SpanType.Strike });
|
|
1965
|
+
}
|
|
1966
|
+
spoiler(content) {
|
|
1967
|
+
return this.span(content, { type: SpanType.Spoiler });
|
|
1968
|
+
}
|
|
1969
|
+
monospace(content) {
|
|
1970
|
+
return this.span(content, { type: SpanType.Monospace });
|
|
1971
|
+
}
|
|
1972
|
+
quote(content) {
|
|
1973
|
+
return this.span(content, { type: SpanType.Quote });
|
|
1974
|
+
}
|
|
1975
|
+
build() {
|
|
1976
|
+
return validateMarkup({
|
|
1977
|
+
content: this.#content,
|
|
1978
|
+
spans: this.#spans
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
toJSON() {
|
|
1982
|
+
return this.build();
|
|
1983
|
+
}
|
|
1984
|
+
};
|
|
1985
|
+
/** Начинает сборку текста с автоматически вычисляемыми смещениями. */
|
|
1986
|
+
function markup(content = "") {
|
|
1987
|
+
return new MarkupBuilder(content, []);
|
|
1988
|
+
}
|
|
1989
|
+
/** Приводит любую форму разметки к тексту и массиву spans. */
|
|
1990
|
+
function resolveMarkup(input) {
|
|
1991
|
+
return resolveInput(input, () => markup(), validateMarkup);
|
|
1992
|
+
}
|
|
1993
|
+
function trimUrlEnd(value) {
|
|
1994
|
+
let result = value;
|
|
1995
|
+
const pairs = [
|
|
1996
|
+
["(", ")"],
|
|
1997
|
+
["[", "]"],
|
|
1998
|
+
["{", "}"]
|
|
1999
|
+
];
|
|
2000
|
+
let changed;
|
|
2001
|
+
do {
|
|
2002
|
+
const before = result;
|
|
2003
|
+
while (/[.,!?;:…'"»”]$/u.test(result)) result = result.slice(0, -1);
|
|
2004
|
+
for (const [open, close] of pairs) while (result.endsWith(close) && result.split(close).length > result.split(open).length) result = result.slice(0, -1);
|
|
2005
|
+
changed = result !== before;
|
|
2006
|
+
} while (changed);
|
|
2007
|
+
return result;
|
|
2008
|
+
}
|
|
2009
|
+
function overlaps(span, start, end) {
|
|
2010
|
+
return span.offset < end && start < span.offset + span.length;
|
|
2011
|
+
}
|
|
2012
|
+
function isHttpUrl(value) {
|
|
2013
|
+
try {
|
|
2014
|
+
const url = new URL(value);
|
|
2015
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
|
|
2016
|
+
} catch {
|
|
2017
|
+
return false;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
function previousCodePoint(text, index) {
|
|
2021
|
+
if (index <= 0) return "";
|
|
2022
|
+
const last = text.charCodeAt(index - 1);
|
|
2023
|
+
if (last >= 56320 && last <= 57343 && index >= 2) {
|
|
2024
|
+
const first = text.charCodeAt(index - 2);
|
|
2025
|
+
if (first >= 55296 && first <= 56319) return text.slice(index - 2, index);
|
|
2026
|
+
}
|
|
2027
|
+
return text[index - 1] ?? "";
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Находит в тексте те сущности, которые сайт получает после серверного разбора:
|
|
2031
|
+
* HTTP(S)-ссылки, `#хэштеги` и `@упоминания`.
|
|
2032
|
+
*
|
|
2033
|
+
* Смещения выражены в UTF-16 code units, поэтому совпадают с `String#slice`,
|
|
2034
|
+
* `substring`, DOM Selection и wire-форматом сайта даже при наличии эмодзи.
|
|
2035
|
+
*/
|
|
2036
|
+
function autoSpans(text, options = {}) {
|
|
2037
|
+
const includeLinks = options.links ?? true;
|
|
2038
|
+
const includeHashtags = options.hashtags ?? true;
|
|
2039
|
+
const includeMentions = options.mentions ?? true;
|
|
2040
|
+
const spans = [];
|
|
2041
|
+
const links = [];
|
|
2042
|
+
for (const match of text.matchAll(/https?:\/\/[^\s<>"'`]+/giu)) {
|
|
2043
|
+
const raw = trimUrlEnd(match[0]);
|
|
2044
|
+
if (raw.length === 0 || match.index === void 0 || !isHttpUrl(raw)) continue;
|
|
2045
|
+
links.push({
|
|
2046
|
+
type: SpanType.Link,
|
|
2047
|
+
offset: match.index,
|
|
2048
|
+
length: raw.length,
|
|
2049
|
+
url: raw
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
if (includeLinks) spans.push(...links);
|
|
2053
|
+
if (includeHashtags || includeMentions) for (const match of text.matchAll(/#([\p{L}\p{M}\p{N}_]+)|@([a-z\d][a-z\d._-]*)/giu)) {
|
|
2054
|
+
if (match.index === void 0) continue;
|
|
2055
|
+
const offset = match.index;
|
|
2056
|
+
const previous = previousCodePoint(text, offset);
|
|
2057
|
+
if (/[\p{L}\p{M}\p{N}_#@.]/u.test(previous)) continue;
|
|
2058
|
+
const end = offset + match[0].length;
|
|
2059
|
+
if (links.some((span) => overlaps(span, offset, end))) continue;
|
|
2060
|
+
const hashtag = match[1];
|
|
2061
|
+
const username = match[2]?.replace(/[._-]+$/u, "");
|
|
2062
|
+
if (hashtag !== void 0 && includeHashtags) spans.push({
|
|
2063
|
+
type: SpanType.Hashtag,
|
|
2064
|
+
offset,
|
|
2065
|
+
length: match[0].length,
|
|
2066
|
+
tag: hashtag
|
|
2067
|
+
});
|
|
2068
|
+
else if (username !== void 0 && includeMentions) spans.push({
|
|
2069
|
+
type: SpanType.Mention,
|
|
2070
|
+
offset,
|
|
2071
|
+
length: username.length + 1,
|
|
2072
|
+
username
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
return spans.sort((left, right) => left.offset - right.offset || right.length - left.length);
|
|
2076
|
+
}
|
|
2077
|
+
//#endregion
|
|
2078
|
+
//#region src/builders/poll.ts
|
|
2079
|
+
/** Минимальное число вариантов ответа. */
|
|
2080
|
+
const MIN_OPTIONS = 2;
|
|
2081
|
+
/** Ограничения опроса — те же, что действуют в интерфейсе итд.com. */
|
|
2082
|
+
const MAX_OPTIONS = 10;
|
|
2083
|
+
const MAX_QUESTION_LENGTH = 200;
|
|
2084
|
+
const MAX_OPTION_LENGTH = 100;
|
|
2085
|
+
/**
|
|
2086
|
+
* Проверяет данные опроса.
|
|
2087
|
+
*
|
|
2088
|
+
* Применяется и к билдеру, и к обычному объекту — правила одни и те же.
|
|
2089
|
+
*
|
|
2090
|
+
* @throws {ItdConfigError} если вопрос пуст, вариантов меньше двух или есть дубли
|
|
2091
|
+
*/
|
|
2092
|
+
function validatePoll(input) {
|
|
2093
|
+
const question = input?.question;
|
|
2094
|
+
if (typeof question !== "string" || question.trim() === "") throw new ItdConfigError("Опрос требует непустого вопроса");
|
|
2095
|
+
if (question.trim().length > MAX_QUESTION_LENGTH) throw new ItdConfigError(`Вопрос длиннее ${MAX_QUESTION_LENGTH} символов (передано ${question.trim().length})`);
|
|
2096
|
+
const texts = (Array.isArray(input.options) ? input.options : []).map((option, index) => {
|
|
2097
|
+
const text = typeof option?.text === "string" ? option.text.trim() : "";
|
|
2098
|
+
if (text === "") throw new ItdConfigError(`Вариант ответа №${index + 1} пуст — у каждого должен быть текст`);
|
|
2099
|
+
if (text.length > MAX_OPTION_LENGTH) throw new ItdConfigError(`Вариант ответа №${index + 1} длиннее ${MAX_OPTION_LENGTH} символов (передано ${text.length})`);
|
|
2100
|
+
return text;
|
|
2101
|
+
});
|
|
2102
|
+
if (texts.length < MIN_OPTIONS) throw new ItdConfigError(`Опрос требует минимум ${MIN_OPTIONS} варианта, передано: ${texts.length}`);
|
|
2103
|
+
if (texts.length > MAX_OPTIONS) throw new ItdConfigError(`Опрос допускает не больше ${MAX_OPTIONS} вариантов, передано: ${texts.length}`);
|
|
2104
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2105
|
+
for (const text of texts) {
|
|
2106
|
+
if (seen.has(text)) throw new ItdConfigError(`Вариант «${text}» повторяется — варианты должны различаться`);
|
|
2107
|
+
seen.add(text);
|
|
2108
|
+
}
|
|
2109
|
+
return {
|
|
2110
|
+
question: question.trim(),
|
|
2111
|
+
options: texts.map((text) => ({ text })),
|
|
2112
|
+
multipleChoice: input.multipleChoice ?? false
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Билдер опроса.
|
|
2117
|
+
*
|
|
2118
|
+
* Неизменяемый: каждый вызов возвращает новый экземпляр, поэтому заготовку можно
|
|
2119
|
+
* переиспользовать, не боясь её испортить. Создаётся функцией {@link poll}.
|
|
2120
|
+
*/
|
|
2121
|
+
var PollBuilder = class PollBuilder {
|
|
2122
|
+
/** @internal */
|
|
2123
|
+
[BUILDER] = true;
|
|
2124
|
+
#state;
|
|
2125
|
+
/** @internal Создавайте билдер функцией {@link poll}. */
|
|
2126
|
+
constructor(state) {
|
|
2127
|
+
this.#state = state;
|
|
2128
|
+
}
|
|
2129
|
+
/** Задаёт вопрос. */
|
|
2130
|
+
question(text) {
|
|
2131
|
+
return new PollBuilder({
|
|
2132
|
+
...this.#state,
|
|
2133
|
+
question: text
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
/** Добавляет один вариант ответа. */
|
|
2137
|
+
option(text) {
|
|
2138
|
+
return new PollBuilder({
|
|
2139
|
+
...this.#state,
|
|
2140
|
+
options: [...this.#state.options, { text }]
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Добавляет несколько вариантов сразу.
|
|
2145
|
+
*
|
|
2146
|
+
* @example
|
|
2147
|
+
* ```ts
|
|
2148
|
+
* poll('ну как?').options('да', 'нет', 'не знаю');
|
|
2149
|
+
* ```
|
|
2150
|
+
*/
|
|
2151
|
+
options(...texts) {
|
|
2152
|
+
return new PollBuilder({
|
|
2153
|
+
...this.#state,
|
|
2154
|
+
options: [...this.#state.options, ...texts.map((text) => ({ text }))]
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
/** Разрешает выбор нескольких вариантов. */
|
|
2158
|
+
multipleChoice(enabled = true) {
|
|
2159
|
+
return new PollBuilder({
|
|
2160
|
+
...this.#state,
|
|
2161
|
+
multipleChoice: enabled
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
build() {
|
|
2165
|
+
return validatePoll(this.#state);
|
|
2166
|
+
}
|
|
2167
|
+
toJSON() {
|
|
2168
|
+
return this.build();
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
/**
|
|
2172
|
+
* Начинает сборку опроса.
|
|
2173
|
+
*
|
|
2174
|
+
* @param question вопрос; можно задать позже методом {@link PollBuilder.question}
|
|
2175
|
+
*
|
|
2176
|
+
* @example
|
|
2177
|
+
* ```ts
|
|
2178
|
+
* import { poll } from 'itd-api';
|
|
2179
|
+
*
|
|
2180
|
+
* const q = poll('Какой язык лучше?')
|
|
2181
|
+
* .options('TypeScript', 'JavaScript')
|
|
2182
|
+
* .multipleChoice();
|
|
2183
|
+
*
|
|
2184
|
+
* await itd.posts.create({ content: 'голосуем', poll: q });
|
|
2185
|
+
* ```
|
|
2186
|
+
*/
|
|
2187
|
+
function poll(question = "") {
|
|
2188
|
+
return new PollBuilder({
|
|
2189
|
+
question,
|
|
2190
|
+
options: []
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
/** Приводит любую форму входа к готовым данным опроса. */
|
|
2194
|
+
function resolvePoll(input) {
|
|
2195
|
+
return resolveInput(input, () => poll(), validatePoll);
|
|
2196
|
+
}
|
|
2197
|
+
//#endregion
|
|
2198
|
+
//#region src/builders/post.ts
|
|
2199
|
+
/** UUID любой версии. */
|
|
2200
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2201
|
+
const BUILD_UPDATE = Symbol.for("itd.postBuilder.update");
|
|
2202
|
+
/**
|
|
2203
|
+
* Проверяет и нормализует данные поста.
|
|
2204
|
+
*
|
|
2205
|
+
* Отдельного внимания заслуживает `wallRecipientId`: API принимает там **только UUID**,
|
|
2206
|
+
* а имя пользователя молча приводит к ошибке на сервере. Проверка здесь превращает
|
|
2207
|
+
* это в понятное сообщение до отправки запроса.
|
|
2208
|
+
*
|
|
2209
|
+
* @throws {ItdConfigError} если пост пуст или получатель стены задан именем пользователя
|
|
2210
|
+
*/
|
|
2211
|
+
function validatePost(input) {
|
|
2212
|
+
const content = typeof input?.content === "string" ? input.content : "";
|
|
2213
|
+
const attachmentIds = input?.attachmentIds ?? [];
|
|
2214
|
+
const files = input?.files ?? [];
|
|
2215
|
+
const hasContent = content.trim() !== "";
|
|
2216
|
+
const hasAttachments = attachmentIds.length > 0 || files.length > 0;
|
|
2217
|
+
const hasPoll = Boolean(input?.poll);
|
|
2218
|
+
if (!hasContent && !hasAttachments && !hasPoll) throw new ItdConfigError("Пост пуст: нужен текст, вложение или опрос");
|
|
2219
|
+
const wallRecipientId = input.wallRecipientId;
|
|
2220
|
+
if (wallRecipientId !== void 0 && wallRecipientId !== null) {
|
|
2221
|
+
if (!UUID_PATTERN.test(wallRecipientId)) throw new ItdConfigError(`wallRecipientId должен быть UUID, а не именем пользователя (получено: «${wallRecipientId}»). Идентификатор можно взять из профиля: (await itd.users.get(username)).id`);
|
|
2222
|
+
}
|
|
2223
|
+
const { poll: inputPoll, ...data } = input;
|
|
2224
|
+
return {
|
|
2225
|
+
...data,
|
|
2226
|
+
...input.spans !== void 0 ? { spans: validateSpans(content, input.spans) } : {},
|
|
2227
|
+
...inputPoll !== void 0 ? { poll: resolvePoll(inputPoll) } : {}
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Билдер поста.
|
|
2232
|
+
*
|
|
2233
|
+
* Неизменяемый: каждый вызов возвращает новый экземпляр, поэтому заготовку можно
|
|
2234
|
+
* переиспользовать. Создаётся функцией {@link post}.
|
|
2235
|
+
*
|
|
2236
|
+
* @example Заготовка для нескольких постов
|
|
2237
|
+
* ```ts
|
|
2238
|
+
* const onWall = post().onWall(userId);
|
|
2239
|
+
*
|
|
2240
|
+
* await itd.posts.create(onWall.content('первый'));
|
|
2241
|
+
* await itd.posts.create(onWall.content('второй')); // заготовка не испорчена
|
|
2242
|
+
* ```
|
|
2243
|
+
*/
|
|
2244
|
+
var PostBuilder = class PostBuilder {
|
|
2245
|
+
/** @internal */
|
|
2246
|
+
[BUILDER] = true;
|
|
2247
|
+
#state;
|
|
2248
|
+
/** @internal Создавайте билдер функцией {@link post}. */
|
|
2249
|
+
constructor(state) {
|
|
2250
|
+
this.#state = state;
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Задаёт текст поста, заменяя прежний вместе с его разметкой.
|
|
2254
|
+
*
|
|
2255
|
+
* Spans привязаны к конкретному тексту, поэтому после замены их нужно задать заново
|
|
2256
|
+
* через {@link spans}, {@link markup} или {@link autoSpans}.
|
|
2257
|
+
*/
|
|
2258
|
+
content(text) {
|
|
2259
|
+
const state = {
|
|
2260
|
+
...this.#state,
|
|
2261
|
+
content: text,
|
|
2262
|
+
contentSet: true
|
|
2263
|
+
};
|
|
2264
|
+
delete state.spans;
|
|
2265
|
+
return new PostBuilder(state);
|
|
2266
|
+
}
|
|
2267
|
+
/** Дописывает текст к уже заданному. */
|
|
2268
|
+
append(text) {
|
|
2269
|
+
const separator = this.#state.content === "" ? "" : "\n";
|
|
2270
|
+
return new PostBuilder({
|
|
2271
|
+
...this.#state,
|
|
2272
|
+
content: this.#state.content + separator + text,
|
|
2273
|
+
contentSet: true
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* Задаёт готовую разметку текста. Смещения проверяются при {@link build}.
|
|
2278
|
+
*
|
|
2279
|
+
* Для автоматического поиска сущностей есть {@link autoSpans}, а для вычисления смещений
|
|
2280
|
+
* при сборке текста — {@link markup}.
|
|
2281
|
+
*/
|
|
2282
|
+
spans(spans) {
|
|
2283
|
+
return new PostBuilder({
|
|
2284
|
+
...this.#state,
|
|
2285
|
+
spans
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Заменяет текст и разметку результатом {@link MarkupBuilder}.
|
|
2290
|
+
*
|
|
2291
|
+
* @example
|
|
2292
|
+
* ```ts
|
|
2293
|
+
* post().markup((m) => m.text('смотрите ').hashtag('котики').text(' от ').mention('nowkie'));
|
|
2294
|
+
* ```
|
|
2295
|
+
*/
|
|
2296
|
+
markup(input) {
|
|
2297
|
+
const result = resolveMarkup(input);
|
|
2298
|
+
return new PostBuilder({
|
|
2299
|
+
...this.#state,
|
|
2300
|
+
content: result.content,
|
|
2301
|
+
contentSet: true,
|
|
2302
|
+
spans: result.spans
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
/** Заменяет текст и spans результатом безопасного разбора Markdown. */
|
|
2306
|
+
markdown(source, options = {}) {
|
|
2307
|
+
return this.markup(parseMarkdown(source, options));
|
|
2308
|
+
}
|
|
2309
|
+
/** Заменяет текст и spans результатом безопасного разбора ограниченного HTML. */
|
|
2310
|
+
html(source, options = {}) {
|
|
2311
|
+
return this.markup(parseHtml(source, options));
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Находит HTTP(S)-ссылки, хэштеги и упоминания в уже заданном тексте.
|
|
2315
|
+
*
|
|
2316
|
+
* Ручные стили сохраняются. Повторный вызов не дублирует уже найденные сущности.
|
|
2317
|
+
*/
|
|
2318
|
+
autoSpans(options = {}) {
|
|
2319
|
+
const current = this.#state.spans ?? [];
|
|
2320
|
+
const entityTypes = /* @__PURE__ */ new Set([
|
|
2321
|
+
SpanType.Hashtag,
|
|
2322
|
+
SpanType.Mention,
|
|
2323
|
+
SpanType.Link
|
|
2324
|
+
]);
|
|
2325
|
+
const detected = autoSpans(this.#state.content, options).filter((candidate) => {
|
|
2326
|
+
return !current.some((span) => entityTypes.has(span.type) && span.offset < candidate.offset + candidate.length && candidate.offset < span.offset + span.length);
|
|
2327
|
+
});
|
|
2328
|
+
return new PostBuilder({
|
|
2329
|
+
...this.#state,
|
|
2330
|
+
spans: [...current, ...detected].sort((left, right) => left.offset - right.offset || right.length - left.length)
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Публикует пост на стене другого пользователя.
|
|
2335
|
+
*
|
|
2336
|
+
* @param userId **UUID** пользователя; имя пользователя не подойдёт
|
|
2337
|
+
*/
|
|
2338
|
+
onWall(userId) {
|
|
2339
|
+
return new PostBuilder({
|
|
2340
|
+
...this.#state,
|
|
2341
|
+
wallRecipientId: userId
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Прикладывает файл — он будет загружен перед публикацией.
|
|
2346
|
+
*
|
|
2347
|
+
* Порядок вызовов сохраняется в порядке вложений.
|
|
2348
|
+
*/
|
|
2349
|
+
attach(file) {
|
|
2350
|
+
return new PostBuilder({
|
|
2351
|
+
...this.#state,
|
|
2352
|
+
files: [...this.#state.files, file]
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
/** Прикладывает уже загруженное вложение по его идентификатору. */
|
|
2356
|
+
attachId(attachmentId) {
|
|
2357
|
+
return new PostBuilder({
|
|
2358
|
+
...this.#state,
|
|
2359
|
+
attachmentIds: [...this.#state.attachmentIds, attachmentId]
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Добавляет опрос.
|
|
2364
|
+
*
|
|
2365
|
+
* Принимает объект, {@link PollBuilder} или функцию-настройщик.
|
|
2366
|
+
*
|
|
2367
|
+
* @example
|
|
2368
|
+
* ```ts
|
|
2369
|
+
* post('голосуем').poll((q) => q.question('ну как?').options('да', 'нет'));
|
|
2370
|
+
* ```
|
|
2371
|
+
*/
|
|
2372
|
+
poll(input) {
|
|
2373
|
+
return new PostBuilder({
|
|
2374
|
+
...this.#state,
|
|
2375
|
+
poll: resolvePoll(input)
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
#input(includeEmptyContent = false) {
|
|
2379
|
+
const { content, contentSet: _contentSet, attachmentIds, files, ...rest } = this.#state;
|
|
2380
|
+
return {
|
|
2381
|
+
...rest,
|
|
2382
|
+
...content !== "" || includeEmptyContent ? { content } : {},
|
|
2383
|
+
...attachmentIds.length > 0 ? { attachmentIds } : {},
|
|
2384
|
+
...files.length > 0 ? { files } : {}
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
build() {
|
|
2388
|
+
return validatePost(this.#input());
|
|
2389
|
+
}
|
|
2390
|
+
/** @internal Собирает данные по правилам `posts.update`, не применяя правила создания. */
|
|
2391
|
+
[BUILD_UPDATE]() {
|
|
2392
|
+
return validatePostUpdate(this.#input(this.#state.contentSet));
|
|
2393
|
+
}
|
|
2394
|
+
toJSON() {
|
|
2395
|
+
return this.build();
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
/**
|
|
2399
|
+
* Начинает сборку поста.
|
|
2400
|
+
*
|
|
2401
|
+
* @param content текст; можно задать позже методом {@link PostBuilder.content}
|
|
2402
|
+
*
|
|
2403
|
+
* @example
|
|
2404
|
+
* ```ts
|
|
2405
|
+
* import { post } from 'itd-api';
|
|
2406
|
+
*
|
|
2407
|
+
* await itd.posts.create(
|
|
2408
|
+
* post('смотрите что нашёл')
|
|
2409
|
+
* .attach({ url: 'https://example.com/photo.jpg' })
|
|
2410
|
+
* .poll((q) => q.question('нравится?').options('да', 'нет')),
|
|
2411
|
+
* );
|
|
2412
|
+
* ```
|
|
2413
|
+
*/
|
|
2414
|
+
function post(content) {
|
|
2415
|
+
return new PostBuilder({
|
|
2416
|
+
content: content ?? "",
|
|
2417
|
+
contentSet: content !== void 0,
|
|
2418
|
+
attachmentIds: [],
|
|
2419
|
+
files: []
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
/** Приводит любую форму входа к готовым данным поста. */
|
|
2423
|
+
function resolvePost(input) {
|
|
2424
|
+
const resolved = typeof input === "function" ? input(post()) : input;
|
|
2425
|
+
return isBuilder(resolved) ? validatePost(resolved.build()) : validatePost(resolved);
|
|
2426
|
+
}
|
|
2427
|
+
function validatePostUpdate(input) {
|
|
2428
|
+
if (!input || typeof input !== "object") throw new ItdConfigError("Для обновления поста нужен объект с явно заданным content");
|
|
2429
|
+
const unsupported = [
|
|
2430
|
+
"wallRecipientId",
|
|
2431
|
+
"attachmentIds",
|
|
2432
|
+
"files",
|
|
2433
|
+
"poll"
|
|
2434
|
+
].filter((key) => input[key] !== void 0);
|
|
2435
|
+
if (unsupported.length > 0) throw new ItdConfigError(`posts.update изменяет только content и spans; не поддерживаются: ${unsupported.join(", ")}`);
|
|
2436
|
+
if (input.content === void 0) throw new ItdConfigError("posts.update требует явно заданный content; обновление одних spans могло бы стереть текст поста");
|
|
2437
|
+
if (typeof input.content !== "string") throw new ItdConfigError("content обновляемого поста должен быть строкой");
|
|
2438
|
+
if (input.spans !== void 0 && !Array.isArray(input.spans)) throw new ItdConfigError("spans обновляемого поста должен быть массивом");
|
|
2439
|
+
return {
|
|
2440
|
+
content: input.content,
|
|
2441
|
+
...input.spans !== void 0 ? { spans: validateSpans(input.content, input.spans) } : {}
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
/** Приводит вход `posts.update` к поддерживаемым данным поста. */
|
|
2445
|
+
function resolvePostUpdate(input) {
|
|
2446
|
+
if (typeof input === "function") {
|
|
2447
|
+
const result = input(post());
|
|
2448
|
+
return isBuilder(result) ? result[BUILD_UPDATE]() : validatePostUpdate(result);
|
|
2449
|
+
}
|
|
2450
|
+
return isBuilder(input) ? input[BUILD_UPDATE]() : validatePostUpdate(input);
|
|
2451
|
+
}
|
|
2452
|
+
//#endregion
|
|
2453
|
+
//#region src/resources/posts.ts
|
|
2454
|
+
/** Курсорная позиция из параметров: если курсор задан — с него, иначе с начала. */
|
|
2455
|
+
function cursorStart(params) {
|
|
2456
|
+
return params.cursor ? { cursor: params.cursor } : {};
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Посты: лента, публикация, реакции, репосты, комментарии.
|
|
2460
|
+
*
|
|
2461
|
+
* Доступна как `itd.posts`.
|
|
2462
|
+
*/
|
|
2463
|
+
var PostsResource = class extends BaseResource {
|
|
2464
|
+
#uploadFiles;
|
|
2465
|
+
/** Лента: `/api/posts`, курсорная пагинация. */
|
|
2466
|
+
#feed = this.paginated({
|
|
2467
|
+
operationId: "posts.list",
|
|
2468
|
+
path: () => "/api/posts",
|
|
2469
|
+
query: (p) => ({
|
|
2470
|
+
tab: p.tab,
|
|
2471
|
+
limit: p.limit
|
|
2472
|
+
}),
|
|
2473
|
+
start: cursorStart,
|
|
2474
|
+
read: (body) => readCursorPage(body, "posts"),
|
|
2475
|
+
mode: PaginationMode.Cursor
|
|
2476
|
+
});
|
|
2477
|
+
/** Стена пользователя: `/api/posts/user/{user}`. */
|
|
2478
|
+
#wall = this.paginated({
|
|
2479
|
+
operationId: "posts.byUser",
|
|
2480
|
+
path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}`,
|
|
2481
|
+
query: (p) => ({
|
|
2482
|
+
limit: p.limit,
|
|
2483
|
+
sort: p.sort,
|
|
2484
|
+
pinnedPostId: p.pinnedPostId
|
|
2485
|
+
}),
|
|
2486
|
+
start: cursorStart,
|
|
2487
|
+
read: (body) => readCursorPage(body, "posts"),
|
|
2488
|
+
mode: PaginationMode.Cursor
|
|
2489
|
+
});
|
|
2490
|
+
/** Понравившиеся посты пользователя: `/api/posts/user/{user}/liked`. */
|
|
2491
|
+
#liked = this.paginated({
|
|
2492
|
+
operationId: "posts.likedByUser",
|
|
2493
|
+
path: (p) => `/api/posts/user/${encodePathSegment(p.user, "user")}/liked`,
|
|
2494
|
+
query: (p) => ({ limit: p.limit }),
|
|
2495
|
+
start: cursorStart,
|
|
2496
|
+
read: (body) => readCursorPage(body, "posts"),
|
|
2497
|
+
mode: PaginationMode.Cursor
|
|
2498
|
+
});
|
|
2499
|
+
/** Комментарии к посту: курсор лежит рядом со списком, поэтому свой reader. */
|
|
2500
|
+
#comments = this.paginated({
|
|
2501
|
+
operationId: "posts.comments",
|
|
2502
|
+
path: (p) => `/api/posts/${encodePathSegment(p.postId, "postId")}/comments`,
|
|
2503
|
+
query: (p) => ({
|
|
2504
|
+
limit: p.limit,
|
|
2505
|
+
sort: p.sort
|
|
2506
|
+
}),
|
|
2507
|
+
start: cursorStart,
|
|
2508
|
+
read: (body) => readFlatCursorPage(body, "comments"),
|
|
2509
|
+
mode: PaginationMode.Cursor
|
|
2510
|
+
});
|
|
2511
|
+
constructor(http, deps) {
|
|
2512
|
+
super(http);
|
|
2513
|
+
this.#uploadFiles = deps.uploadFiles;
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Загружает страницу ленты.
|
|
2517
|
+
*
|
|
2518
|
+
* @example
|
|
2519
|
+
* ```ts
|
|
2520
|
+
* const page = await itd.posts.list({ tab: FeedTab.Following, limit: 20 });
|
|
2521
|
+
* const next = await itd.posts.list({ tab: FeedTab.Following, cursor: page.nextCursor ?? undefined });
|
|
2522
|
+
* ```
|
|
2523
|
+
*/
|
|
2524
|
+
list(params = {}, options = {}) {
|
|
2525
|
+
return this.#feed.list(params, options);
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* Перебирает ленту, сама подставляя курсоры.
|
|
2529
|
+
*
|
|
2530
|
+
* @example
|
|
2531
|
+
* ```ts
|
|
2532
|
+
* for await (const post of itd.posts.iterate({ tab: 'following' })) {
|
|
2533
|
+
* console.log(post.author.username, post.content);
|
|
2534
|
+
* }
|
|
2535
|
+
* ```
|
|
2536
|
+
*/
|
|
2537
|
+
iterate(params = {}, options = {}) {
|
|
2538
|
+
return this.#feed.iterate(params, options);
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Публикует пост.
|
|
2542
|
+
*
|
|
2543
|
+
* Принимает обычный объект, {@link PostBuilder} или функцию-настройщик. Файлы из поля
|
|
2544
|
+
* `files` загружаются автоматически, порядок вложений сохраняется.
|
|
2545
|
+
*
|
|
2546
|
+
* @example
|
|
2547
|
+
* ```ts
|
|
2548
|
+
* await itd.posts.create({ content: 'привет' });
|
|
2549
|
+
* await itd.posts.create((p) => p.content('привет').attach({ url: 'https://example.com/photo.jpg' }));
|
|
2550
|
+
* ```
|
|
2551
|
+
*/
|
|
2552
|
+
async create(input, options = {}) {
|
|
2553
|
+
const data = resolvePost(input);
|
|
2554
|
+
const attachmentIds = await this.#collectAttachments(data, options);
|
|
2555
|
+
return this.http.operation("posts.create", {
|
|
2556
|
+
path: "/api/posts",
|
|
2557
|
+
body: {
|
|
2558
|
+
content: data.content ?? "",
|
|
2559
|
+
...data.spans ? { spans: data.spans } : {},
|
|
2560
|
+
...data.wallRecipientId ? { wallRecipientId: data.wallRecipientId } : {},
|
|
2561
|
+
...attachmentIds.length > 0 ? { attachmentIds } : {},
|
|
2562
|
+
...data.poll ? { poll: data.poll } : {}
|
|
2563
|
+
},
|
|
2564
|
+
...options
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Загружает один пост вместе с топовыми комментариями.
|
|
2569
|
+
*
|
|
2570
|
+
* В отличие от списков, здесь у поста заполнено поле `comments`.
|
|
2571
|
+
*/
|
|
2572
|
+
get(postId, options = {}) {
|
|
2573
|
+
return this.http.operation("posts.get", {
|
|
2574
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}`,
|
|
2575
|
+
...options
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* Редактирует текст и разметку поста.
|
|
2580
|
+
*
|
|
2581
|
+
* Как и {@link create}, принимает объект, готовый {@link PostBuilder} или
|
|
2582
|
+
* функцию-настройщик. Поля создания поста, которые update endpoint не поддерживает
|
|
2583
|
+
* (вложения, опрос и стена), отвергаются до запроса.
|
|
2584
|
+
*/
|
|
2585
|
+
update(postId, input, options = {}) {
|
|
2586
|
+
const data = resolvePostUpdate(input);
|
|
2587
|
+
return this.http.operation("posts.update", {
|
|
2588
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}`,
|
|
2589
|
+
body: {
|
|
2590
|
+
content: data.content,
|
|
2591
|
+
...data.spans ? { spans: data.spans } : {}
|
|
2592
|
+
},
|
|
2593
|
+
...options
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
/** Удаляет пост. Восстановить его можно через {@link restore}. */
|
|
2597
|
+
remove(postId, options = {}) {
|
|
2598
|
+
return this.http.operation("posts.remove", {
|
|
2599
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}`,
|
|
2600
|
+
...options
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
/** Восстанавливает удалённый пост. */
|
|
2604
|
+
restore(postId, options = {}) {
|
|
2605
|
+
return this.http.operation("posts.restore", {
|
|
2606
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/restore`,
|
|
2607
|
+
...options
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
/** Ставит реакцию на пост. */
|
|
2611
|
+
like(postId, options = {}) {
|
|
2612
|
+
return this.http.operation("posts.like", {
|
|
2613
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
|
|
2614
|
+
...options
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
/** Убирает реакцию с поста. */
|
|
2618
|
+
unlike(postId, options = {}) {
|
|
2619
|
+
return this.http.operation("posts.unlike", {
|
|
2620
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/like`,
|
|
2621
|
+
...options
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Делает репост с необязательным комментарием.
|
|
2626
|
+
*
|
|
2627
|
+
* Вложения к репосту не поддерживаются: сервер их игнорирует, поэтому параметров
|
|
2628
|
+
* для файлов здесь нет.
|
|
2629
|
+
*/
|
|
2630
|
+
repost(postId, content = "", options = {}) {
|
|
2631
|
+
return this.http.operation("posts.repost", {
|
|
2632
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
|
|
2633
|
+
body: { content },
|
|
2634
|
+
...options
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
/** Отменяет репост. */
|
|
2638
|
+
unrepost(postId, options = {}) {
|
|
2639
|
+
return this.http.operation("posts.unrepost", {
|
|
2640
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/repost`,
|
|
2641
|
+
...options
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
/** Закрепляет пост в профиле. */
|
|
2645
|
+
pin(postId, options = {}) {
|
|
2646
|
+
return this.http.operation("posts.pin", {
|
|
2647
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
|
|
2648
|
+
...options
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
/** Открепляет пост. */
|
|
2652
|
+
unpin(postId, options = {}) {
|
|
2653
|
+
return this.http.operation("posts.unpin", {
|
|
2654
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/pin`,
|
|
2655
|
+
...options
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Голосует в опросе.
|
|
2660
|
+
*
|
|
2661
|
+
* @param optionIds выбранные варианты; несколько допустимы только при `multipleChoice`
|
|
2662
|
+
*/
|
|
2663
|
+
vote(postId, optionIds, options = {}) {
|
|
2664
|
+
return this.http.operation("posts.vote", {
|
|
2665
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/poll/vote`,
|
|
2666
|
+
body: { optionIds },
|
|
2667
|
+
...options
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
/** Запрашивает счётчики сразу для нескольких постов. */
|
|
2671
|
+
async stats(ids, options = {}) {
|
|
2672
|
+
const body = await this.http.operation("posts.stats", {
|
|
2673
|
+
path: "/api/posts/stats",
|
|
2674
|
+
body: { ids },
|
|
2675
|
+
...options
|
|
2676
|
+
});
|
|
2677
|
+
return pickArray(body, "posts");
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Загружает страницу стены пользователя.
|
|
2681
|
+
*
|
|
2682
|
+
* Это **не только его собственные посты**: сюда попадают и записи, которые другие
|
|
2683
|
+
* оставили на его стене — у них `author` чужой, а `wallRecipient` указывает на владельца
|
|
2684
|
+
* стены. Поэтому число записей обычно больше, чем `postsCount` из профиля; чтобы
|
|
2685
|
+
* получить только авторские посты, отфильтруйте по `post.author.id`.
|
|
2686
|
+
*
|
|
2687
|
+
* Принимает и UUID, и имя пользователя.
|
|
2688
|
+
*/
|
|
2689
|
+
byUser(user, params = {}, options = {}) {
|
|
2690
|
+
return this.#wall.list({
|
|
2691
|
+
...params,
|
|
2692
|
+
user
|
|
2693
|
+
}, options);
|
|
2694
|
+
}
|
|
2695
|
+
/** Перебирает стену пользователя. Что именно в неё входит — см. {@link byUser}. */
|
|
2696
|
+
iterateByUser(user, params = {}, options = {}) {
|
|
2697
|
+
return this.#wall.iterate({
|
|
2698
|
+
...params,
|
|
2699
|
+
user
|
|
2700
|
+
}, options);
|
|
2701
|
+
}
|
|
2702
|
+
/** Загружает страницу постов, которые пользователь отметил реакцией. */
|
|
2703
|
+
likedByUser(user, params = {}, options = {}) {
|
|
2704
|
+
return this.#liked.list({
|
|
2705
|
+
...params,
|
|
2706
|
+
user
|
|
2707
|
+
}, options);
|
|
2708
|
+
}
|
|
2709
|
+
/** Перебирает посты, которые пользователь отметил реакцией. */
|
|
2710
|
+
iterateLikedByUser(user, params = {}, options = {}) {
|
|
2711
|
+
return this.#liked.iterate({
|
|
2712
|
+
...params,
|
|
2713
|
+
user
|
|
2714
|
+
}, options);
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* Загружает страницу комментариев к посту.
|
|
2718
|
+
*
|
|
2719
|
+
* У этого эндпоинта курсор и признак продолжения лежат рядом со списком, а не внутри
|
|
2720
|
+
* объекта `pagination`, как у остальных, — разница скрыта внутри.
|
|
2721
|
+
*/
|
|
2722
|
+
comments(postId, params = {}, options = {}) {
|
|
2723
|
+
return this.#comments.list({
|
|
2724
|
+
...params,
|
|
2725
|
+
postId
|
|
2726
|
+
}, options);
|
|
2727
|
+
}
|
|
2728
|
+
/** Перебирает комментарии к посту. */
|
|
2729
|
+
iterateComments(postId, params = {}, options = {}) {
|
|
2730
|
+
return this.#comments.iterate({
|
|
2731
|
+
...params,
|
|
2732
|
+
postId
|
|
2733
|
+
}, options);
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Комментирует пост.
|
|
2737
|
+
*
|
|
2738
|
+
* @example
|
|
2739
|
+
* ```ts
|
|
2740
|
+
* await itd.posts.comment(postId, 'согласен');
|
|
2741
|
+
* await itd.posts.comment(postId, (c) => c.content('смотри').attach(blob));
|
|
2742
|
+
* ```
|
|
2743
|
+
*/
|
|
2744
|
+
async comment(postId, input, options = {}) {
|
|
2745
|
+
const data = resolveComment(typeof input === "string" ? { content: input } : input);
|
|
2746
|
+
const attachmentIds = await this.#collectAttachments(data, options);
|
|
2747
|
+
return this.http.operation("posts.comment", {
|
|
2748
|
+
path: `/api/posts/${encodePathSegment(postId, "postId")}/comments`,
|
|
2749
|
+
body: {
|
|
2750
|
+
content: data.content ?? "",
|
|
2751
|
+
attachmentIds
|
|
2752
|
+
},
|
|
2753
|
+
...options
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Отправляет голосовой комментарий.
|
|
2758
|
+
*
|
|
2759
|
+
* Текста у такого комментария нет: сервер ждёт пустой `content` и одно аудиовложение
|
|
2760
|
+
* в формате `audio/ogg`.
|
|
2761
|
+
*
|
|
2762
|
+
* @example
|
|
2763
|
+
* ```ts
|
|
2764
|
+
* import { fromPath } from 'itd-api/node';
|
|
2765
|
+
*
|
|
2766
|
+
* await itd.posts.voiceComment(postId, fromPath('./answer.ogg'));
|
|
2767
|
+
* ```
|
|
2768
|
+
*/
|
|
2769
|
+
voiceComment(postId, audio, options = {}) {
|
|
2770
|
+
return this.comment(postId, {
|
|
2771
|
+
content: "",
|
|
2772
|
+
files: [audio]
|
|
2773
|
+
}, options);
|
|
2774
|
+
}
|
|
2775
|
+
/** Загружает файлы из входных данных и объединяет их с уже готовыми идентификаторами. */
|
|
2776
|
+
async #collectAttachments(data, options) {
|
|
2777
|
+
const existing = data.attachmentIds ?? [];
|
|
2778
|
+
const files = data.files ?? [];
|
|
2779
|
+
if (files.length === 0) return existing;
|
|
2780
|
+
const uploaded = await this.#uploadFiles(files, options);
|
|
2781
|
+
return [...existing, ...uploaded];
|
|
2782
|
+
}
|
|
2783
|
+
};
|
|
2784
|
+
//#endregion
|
|
2785
|
+
//#region src/builders/report.ts
|
|
2786
|
+
const REASONS = new Set(Object.values(ReportReason));
|
|
2787
|
+
const TARGET_TYPES = new Set(Object.values(ReportTargetType));
|
|
2788
|
+
/**
|
|
2789
|
+
* Проверяет данные жалобы.
|
|
2790
|
+
*
|
|
2791
|
+
* @throws {ItdConfigError} если объект жалобы или причина заданы неверно
|
|
2792
|
+
*/
|
|
2793
|
+
function validateReport(input) {
|
|
2794
|
+
if (!input?.targetId || typeof input.targetId !== "string") throw new ItdConfigError("Жалоба требует идентификатор объекта (targetId)");
|
|
2795
|
+
if (!TARGET_TYPES.has(input.targetType)) throw new ItdConfigError(`targetType должен быть одним из ${[...TARGET_TYPES].join(", ")}, получено: ${String(input.targetType)}`);
|
|
2796
|
+
if (!REASONS.has(input.reason)) throw new ItdConfigError(`Неизвестная причина жалобы «${String(input.reason)}». Допустимые: ${[...REASONS].join(", ")}`);
|
|
2797
|
+
return input;
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* Билдер жалобы.
|
|
2801
|
+
*
|
|
2802
|
+
* Точка входа задаёт объект жалобы и его тип одновременно, поэтому рассогласовать
|
|
2803
|
+
* `targetType` и `targetId` невозможно. Создаётся объектом {@link report}.
|
|
2804
|
+
*/
|
|
2805
|
+
var ReportBuilder = class ReportBuilder {
|
|
2806
|
+
/** @internal */
|
|
2807
|
+
[BUILDER] = true;
|
|
2808
|
+
#state;
|
|
2809
|
+
/** @internal Создавайте билдер через {@link report}. */
|
|
2810
|
+
constructor(state) {
|
|
2811
|
+
this.#state = state;
|
|
2812
|
+
}
|
|
2813
|
+
/** Указывает причину жалобы. */
|
|
2814
|
+
reason(reason) {
|
|
2815
|
+
return new ReportBuilder({
|
|
2816
|
+
...this.#state,
|
|
2817
|
+
reason
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
/** Добавляет пояснение в свободной форме. */
|
|
2821
|
+
description(text) {
|
|
2822
|
+
return new ReportBuilder({
|
|
2823
|
+
...this.#state,
|
|
2824
|
+
description: text
|
|
2825
|
+
});
|
|
2826
|
+
}
|
|
2827
|
+
build() {
|
|
2828
|
+
return validateReport(this.#state);
|
|
2829
|
+
}
|
|
2830
|
+
toJSON() {
|
|
2831
|
+
return this.build();
|
|
2832
|
+
}
|
|
2833
|
+
};
|
|
2834
|
+
function start(targetType, targetId) {
|
|
2835
|
+
return new ReportBuilder({
|
|
2836
|
+
targetType,
|
|
2837
|
+
targetId
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* Начинает сборку жалобы.
|
|
2842
|
+
*
|
|
2843
|
+
* Тип объекта выбирается точкой входа, так что указать идентификатор комментария
|
|
2844
|
+
* с типом «пост» нельзя в принципе.
|
|
2845
|
+
*
|
|
2846
|
+
* @example
|
|
2847
|
+
* ```ts
|
|
2848
|
+
* import { report, ReportReason } from 'itd-api';
|
|
2849
|
+
*
|
|
2850
|
+
* await itd.reports.create(report.post(postId).reason(ReportReason.Spam));
|
|
2851
|
+
* await itd.reports.create(report.user(userId).reason('fraud').description('пишет в личку'));
|
|
2852
|
+
* ```
|
|
2853
|
+
*/
|
|
2854
|
+
const report = Object.freeze({
|
|
2855
|
+
/** Жалоба на пост. */
|
|
2856
|
+
post: (postId) => start(ReportTargetType.Post, postId),
|
|
2857
|
+
/** Жалоба на комментарий. */
|
|
2858
|
+
comment: (commentId) => start(ReportTargetType.Comment, commentId),
|
|
2859
|
+
/** Жалоба на пользователя. */
|
|
2860
|
+
user: (userId) => start(ReportTargetType.User, userId)
|
|
2861
|
+
});
|
|
2862
|
+
/** Приводит любую форму входа к готовым данным жалобы. */
|
|
2863
|
+
function resolveReport(input) {
|
|
2864
|
+
return resolveInput(input, () => new ReportBuilder({}), validateReport);
|
|
2865
|
+
}
|
|
2866
|
+
//#endregion
|
|
2867
|
+
//#region src/resources/reports.ts
|
|
2868
|
+
/**
|
|
2869
|
+
* Жалобы на контент и пользователей.
|
|
2870
|
+
*
|
|
2871
|
+
* Доступна как `itd.reports`.
|
|
2872
|
+
*/
|
|
2873
|
+
var ReportsResource = class extends BaseResource {
|
|
2874
|
+
/**
|
|
2875
|
+
* Отправляет жалобу.
|
|
2876
|
+
*
|
|
2877
|
+
* Повторная жалоба на тот же объект отклоняется сервером с сообщением
|
|
2878
|
+
* «Вы уже отправляли жалобу на этот контент».
|
|
2879
|
+
*
|
|
2880
|
+
* @example
|
|
2881
|
+
* ```ts
|
|
2882
|
+
* await itd.reports.create(report.post(postId).reason('spam'));
|
|
2883
|
+
* await itd.reports.create({ targetType: 'user', targetId, reason: 'fraud' });
|
|
2884
|
+
* ```
|
|
2885
|
+
*/
|
|
2886
|
+
create(input, options = {}) {
|
|
2887
|
+
const data = resolveReport(input);
|
|
2888
|
+
return this.http.operation("reports.create", {
|
|
2889
|
+
path: "/api/reports",
|
|
2890
|
+
body: data,
|
|
2891
|
+
...options
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
};
|
|
2895
|
+
//#endregion
|
|
2896
|
+
//#region src/resources/search.ts
|
|
2897
|
+
/**
|
|
2898
|
+
* Глобальный поиск.
|
|
2899
|
+
*
|
|
2900
|
+
* Доступна как `itd.search`.
|
|
2901
|
+
*/
|
|
2902
|
+
var SearchResource = class extends BaseResource {
|
|
2903
|
+
/**
|
|
2904
|
+
* Ищет пользователей и хэштеги одним запросом.
|
|
2905
|
+
*
|
|
2906
|
+
* @example
|
|
2907
|
+
* ```ts
|
|
2908
|
+
* const { users, hashtags } = await itd.search.all('арт');
|
|
2909
|
+
* ```
|
|
2910
|
+
*/
|
|
2911
|
+
async all(query, options = {}) {
|
|
2912
|
+
const body = await this.http.operation("search.all", {
|
|
2913
|
+
path: "/api/search",
|
|
2914
|
+
query: { q: query },
|
|
2915
|
+
...options
|
|
2916
|
+
});
|
|
2917
|
+
return {
|
|
2918
|
+
users: pickArray(body, "users"),
|
|
2919
|
+
hashtags: pickArray(body, "hashtags")
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
};
|
|
2923
|
+
//#endregion
|
|
2924
|
+
//#region src/resources/subscription.ts
|
|
2925
|
+
/**
|
|
2926
|
+
* Подписка и способы оплаты.
|
|
2927
|
+
*
|
|
2928
|
+
* Доступна как `itd.subscription`.
|
|
2929
|
+
*/
|
|
2930
|
+
var SubscriptionResource = class extends BaseResource {
|
|
2931
|
+
/** Загружает состояние подписки и её цену. */
|
|
2932
|
+
status(options = {}) {
|
|
2933
|
+
return this.http.operation("subscription.status", {
|
|
2934
|
+
path: "/api/v1/subscription/",
|
|
2935
|
+
...options
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* Запускает оплату подписки.
|
|
2940
|
+
*
|
|
2941
|
+
* Форма ответа в документации API не описана, поэтому тип результата не уточняется.
|
|
2942
|
+
*/
|
|
2943
|
+
pay(options = {}) {
|
|
2944
|
+
return this.http.operation("subscription.pay", {
|
|
2945
|
+
path: "/api/v1/subscription/pay",
|
|
2946
|
+
...options
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
/** Включает или отключает автопродление. */
|
|
2950
|
+
setAutoRenewal(enabled, options = {}) {
|
|
2951
|
+
return this.http.operation("subscription.setAutoRenewal", {
|
|
2952
|
+
path: "/api/v1/subscription/auto-renewal",
|
|
2953
|
+
body: { enabled },
|
|
2954
|
+
...options
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
/** Запускает привязку карты. */
|
|
2958
|
+
bindCard(options = {}) {
|
|
2959
|
+
return this.http.operation("subscription.bindCard", {
|
|
2960
|
+
path: "/api/v1/subscription/bind-card",
|
|
2961
|
+
...options
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
/** Загружает список способов оплаты. Пустой массив, если карт нет. */
|
|
2965
|
+
async methods(options = {}) {
|
|
2966
|
+
const body = await this.http.operation("subscription.methods", {
|
|
2967
|
+
path: "/api/v1/subscription/methods",
|
|
2968
|
+
...options
|
|
2969
|
+
});
|
|
2970
|
+
return Array.isArray(body) ? body : [];
|
|
2971
|
+
}
|
|
2972
|
+
/** Делает способ оплаты основным. */
|
|
2973
|
+
setDefaultMethod(methodId, options = {}) {
|
|
2974
|
+
return this.http.operation("subscription.setDefaultMethod", {
|
|
2975
|
+
path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}/default`,
|
|
2976
|
+
...options
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
/** Удаляет способ оплаты. */
|
|
2980
|
+
removeMethod(methodId, options = {}) {
|
|
2981
|
+
return this.http.operation("subscription.removeMethod", {
|
|
2982
|
+
path: `/api/v1/subscription/methods/${encodePathSegment(methodId, "methodId")}`,
|
|
2983
|
+
...options
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
};
|
|
2987
|
+
//#endregion
|
|
2988
|
+
//#region src/resources/telemetry.ts
|
|
2989
|
+
const DEFAULT_BATCH_SIZE = 50;
|
|
2990
|
+
const SYSTEM_CLOCK = Object.freeze({ now: () => Date.now() });
|
|
2991
|
+
function assertNonEmpty(value, name) {
|
|
2992
|
+
if (value.trim().length === 0) throw new ItdConfigError(`${name} должен быть непустой строкой`);
|
|
2993
|
+
}
|
|
2994
|
+
function assertFinite(value, name) {
|
|
2995
|
+
if (!Number.isFinite(value)) throw new ItdConfigError(`${name} должен быть конечным числом`);
|
|
2996
|
+
}
|
|
2997
|
+
function assertNonNegative(value, name) {
|
|
2998
|
+
assertFinite(value, name);
|
|
2999
|
+
if (value < 0) throw new ItdConfigError(`${name} должен быть неотрицательным числом`);
|
|
3000
|
+
}
|
|
3001
|
+
function validateDwell(entry) {
|
|
3002
|
+
assertNonEmpty(entry.vs, "vs");
|
|
3003
|
+
assertFinite(entry.enterAt, "enterAt");
|
|
3004
|
+
assertFinite(entry.exitAt, "exitAt");
|
|
3005
|
+
if (entry.exitAt < entry.enterAt) throw new ItdConfigError("exitAt не может быть раньше enterAt");
|
|
3006
|
+
if (entry.durationMs !== void 0) assertNonNegative(entry.durationMs, "durationMs");
|
|
3007
|
+
return { ...entry };
|
|
3008
|
+
}
|
|
3009
|
+
function validateInteraction(entry) {
|
|
3010
|
+
assertNonEmpty(entry.vs, "vs");
|
|
3011
|
+
assertNonEmpty(entry.postId, "postId");
|
|
3012
|
+
if (entry.mediaIndex !== void 0) {
|
|
3013
|
+
assertNonNegative(entry.mediaIndex, "mediaIndex");
|
|
3014
|
+
if (!Number.isInteger(entry.mediaIndex)) throw new ItdConfigError("mediaIndex должен быть целым числом");
|
|
3015
|
+
}
|
|
3016
|
+
if (entry.positionMs !== void 0) assertNonNegative(entry.positionMs, "positionMs");
|
|
3017
|
+
if (entry.durationMs !== void 0) assertNonNegative(entry.durationMs, "durationMs");
|
|
3018
|
+
return { ...entry };
|
|
3019
|
+
}
|
|
3020
|
+
function photoOpenEntry(input) {
|
|
3021
|
+
return validateInteraction({
|
|
3022
|
+
type: InteractionType.PhotoOpen,
|
|
3023
|
+
...input
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
function videoProgressEntry(input) {
|
|
3027
|
+
return validateInteraction({
|
|
3028
|
+
type: InteractionType.VideoProgress,
|
|
3029
|
+
...input
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
var ViewTrackerImpl = class {
|
|
3033
|
+
enteredAt;
|
|
3034
|
+
#clock;
|
|
3035
|
+
#input;
|
|
3036
|
+
#submit;
|
|
3037
|
+
#result;
|
|
3038
|
+
constructor(input, clock, submit) {
|
|
3039
|
+
assertNonEmpty(input.vs, "vs");
|
|
3040
|
+
this.enteredAt = clock.now();
|
|
3041
|
+
assertFinite(this.enteredAt, "clock.now()");
|
|
3042
|
+
this.#input = { ...input };
|
|
3043
|
+
this.#clock = clock;
|
|
3044
|
+
this.#submit = submit;
|
|
3045
|
+
}
|
|
3046
|
+
get finished() {
|
|
3047
|
+
return this.#result !== void 0;
|
|
3048
|
+
}
|
|
3049
|
+
finish(reason) {
|
|
3050
|
+
if (this.#result) return this.#result;
|
|
3051
|
+
try {
|
|
3052
|
+
const exitAt = this.#clock.now();
|
|
3053
|
+
assertFinite(exitAt, "clock.now()");
|
|
3054
|
+
if (exitAt < this.enteredAt) throw new ItdConfigError("часы не могут вернуться назад во время измерения просмотра");
|
|
3055
|
+
this.#result = Promise.resolve(this.#submit({
|
|
3056
|
+
...this.#input,
|
|
3057
|
+
enterAt: this.enteredAt,
|
|
3058
|
+
exitAt,
|
|
3059
|
+
reason
|
|
3060
|
+
}));
|
|
3061
|
+
} catch (error) {
|
|
3062
|
+
this.#result = Promise.reject(error);
|
|
3063
|
+
}
|
|
3064
|
+
return this.#result;
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
var TelemetryBatchImpl = class {
|
|
3068
|
+
#sendDwell;
|
|
3069
|
+
#sendInteraction;
|
|
3070
|
+
#telemetryOptions;
|
|
3071
|
+
#requestOptions;
|
|
3072
|
+
#clock;
|
|
3073
|
+
#maxBatchSize;
|
|
3074
|
+
#onClose;
|
|
3075
|
+
#dwell = [];
|
|
3076
|
+
#interactions = [];
|
|
3077
|
+
#state = "open";
|
|
3078
|
+
#flushPromise;
|
|
3079
|
+
#closePromise;
|
|
3080
|
+
#disposeCleanup = false;
|
|
3081
|
+
constructor(sendDwell, sendInteraction, options, requestOptions, onClose) {
|
|
3082
|
+
const { maxBatchSize = DEFAULT_BATCH_SIZE, clock = SYSTEM_CLOCK, ...telemetryOptions } = options;
|
|
3083
|
+
if (!Number.isInteger(maxBatchSize) || maxBatchSize < 1) throw new ItdConfigError("maxBatchSize должен быть целым числом от 1");
|
|
3084
|
+
this.#sendDwell = sendDwell;
|
|
3085
|
+
this.#sendInteraction = sendInteraction;
|
|
3086
|
+
this.#telemetryOptions = telemetryOptions;
|
|
3087
|
+
this.#requestOptions = requestOptions;
|
|
3088
|
+
this.#clock = clock;
|
|
3089
|
+
this.#maxBatchSize = maxBatchSize;
|
|
3090
|
+
this.#onClose = onClose;
|
|
3091
|
+
}
|
|
3092
|
+
get pendingDwell() {
|
|
3093
|
+
return this.#dwell.length;
|
|
3094
|
+
}
|
|
3095
|
+
get pendingInteractions() {
|
|
3096
|
+
return this.#interactions.length;
|
|
3097
|
+
}
|
|
3098
|
+
get closed() {
|
|
3099
|
+
return this.#state === "closed";
|
|
3100
|
+
}
|
|
3101
|
+
dwell(entry) {
|
|
3102
|
+
this.#assertOpen();
|
|
3103
|
+
const entries = Array.isArray(entry) ? entry : [entry];
|
|
3104
|
+
this.#dwell.push(...entries.map(validateDwell));
|
|
3105
|
+
return this;
|
|
3106
|
+
}
|
|
3107
|
+
interaction(entry) {
|
|
3108
|
+
this.#assertOpen();
|
|
3109
|
+
const entries = Array.isArray(entry) ? entry : [entry];
|
|
3110
|
+
this.#interactions.push(...entries.map(validateInteraction));
|
|
3111
|
+
return this;
|
|
3112
|
+
}
|
|
3113
|
+
photoOpen(input) {
|
|
3114
|
+
return this.interaction(photoOpenEntry(input));
|
|
3115
|
+
}
|
|
3116
|
+
videoProgress(input) {
|
|
3117
|
+
return this.interaction(videoProgressEntry(input));
|
|
3118
|
+
}
|
|
3119
|
+
startView(input) {
|
|
3120
|
+
this.#assertOpen();
|
|
3121
|
+
return new ViewTrackerImpl(input, this.#clock, (entry) => {
|
|
3122
|
+
this.dwell(entry);
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
flush(options = {}) {
|
|
3126
|
+
if (this.#state === "closed") return Promise.resolve();
|
|
3127
|
+
if (this.#flushPromise) return this.#flushPromise;
|
|
3128
|
+
const tracked = this.#flushAll({
|
|
3129
|
+
...this.#requestOptions,
|
|
3130
|
+
...options
|
|
3131
|
+
}).finally(() => {
|
|
3132
|
+
if (this.#flushPromise === tracked) this.#flushPromise = void 0;
|
|
3133
|
+
});
|
|
3134
|
+
this.#flushPromise = tracked;
|
|
3135
|
+
return tracked;
|
|
3136
|
+
}
|
|
3137
|
+
close() {
|
|
3138
|
+
return this.#close(false);
|
|
3139
|
+
}
|
|
3140
|
+
/** Внутренняя финализация после перехода клиента в terminal state. */
|
|
3141
|
+
closeForDispose() {
|
|
3142
|
+
this.prepareForDispose();
|
|
3143
|
+
return this.#close(true);
|
|
3144
|
+
}
|
|
3145
|
+
/** Переключает ещё не начатые части flush на terminal-cleanup канал. */
|
|
3146
|
+
prepareForDispose() {
|
|
3147
|
+
this.#disposeCleanup = true;
|
|
3148
|
+
}
|
|
3149
|
+
#close(disposeCleanup) {
|
|
3150
|
+
this.#disposeCleanup ||= disposeCleanup;
|
|
3151
|
+
if (this.#state === "closed") return Promise.resolve();
|
|
3152
|
+
if (this.#closePromise) return this.#closePromise;
|
|
3153
|
+
this.#state = "closing";
|
|
3154
|
+
const tracked = this.flush().then(() => {
|
|
3155
|
+
this.#state = "closed";
|
|
3156
|
+
this.#onClose();
|
|
3157
|
+
}).catch((error) => {
|
|
3158
|
+
this.#state = "open";
|
|
3159
|
+
throw error;
|
|
3160
|
+
}).finally(() => {
|
|
3161
|
+
if (this.#closePromise === tracked) this.#closePromise = void 0;
|
|
3162
|
+
});
|
|
3163
|
+
this.#closePromise = tracked;
|
|
3164
|
+
return tracked;
|
|
3165
|
+
}
|
|
3166
|
+
async #flushAll(options) {
|
|
3167
|
+
while (this.#dwell.length > 0) {
|
|
3168
|
+
const chunk = this.#dwell.splice(0, this.#maxBatchSize);
|
|
3169
|
+
try {
|
|
3170
|
+
await this.#sendDwell(chunk, this.#telemetryOptions, options, this.#disposeCleanup);
|
|
3171
|
+
} catch (error) {
|
|
3172
|
+
this.#dwell.unshift(...chunk);
|
|
3173
|
+
throw error;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
while (this.#interactions.length > 0) {
|
|
3177
|
+
const chunk = this.#interactions.splice(0, this.#maxBatchSize);
|
|
3178
|
+
try {
|
|
3179
|
+
await this.#sendInteraction(chunk, this.#telemetryOptions, options, this.#disposeCleanup);
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
this.#interactions.unshift(...chunk);
|
|
3182
|
+
throw error;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
#assertOpen() {
|
|
3187
|
+
if (this.#state !== "open") throw new ItdConfigError("накопитель телеметрии уже закрывается или закрыт");
|
|
3188
|
+
}
|
|
3189
|
+
};
|
|
3190
|
+
const TELEMETRY_DISPOSE_CLOSERS = /* @__PURE__ */ new WeakMap();
|
|
3191
|
+
const TELEMETRY_DISPOSE_PREPARERS = /* @__PURE__ */ new WeakMap();
|
|
3192
|
+
/**
|
|
3193
|
+
* Телеметрия просмотров и взаимодействий.
|
|
3194
|
+
*
|
|
3195
|
+
* Ничего не отправляет автоматически: каждый запрос, tracker или накопитель создаётся
|
|
3196
|
+
* явным вызовом пользователя. Доступна как `itd.telemetry`.
|
|
3197
|
+
*/
|
|
3198
|
+
var TelemetryResource = class extends BaseResource {
|
|
3199
|
+
#sessionId;
|
|
3200
|
+
#batches = /* @__PURE__ */ new Set();
|
|
3201
|
+
constructor(http) {
|
|
3202
|
+
super(http);
|
|
3203
|
+
TELEMETRY_DISPOSE_PREPARERS.set(this, () => {
|
|
3204
|
+
for (const batch of this.#batches) batch.prepareForDispose();
|
|
3205
|
+
});
|
|
3206
|
+
TELEMETRY_DISPOSE_CLOSERS.set(this, () => this.#close(true));
|
|
3207
|
+
}
|
|
3208
|
+
/** Идентификатор сессии телеметрии, общий для всех событий этого ресурса. */
|
|
3209
|
+
get sessionId() {
|
|
3210
|
+
this.#sessionId ??= createDeviceId();
|
|
3211
|
+
return this.#sessionId;
|
|
3212
|
+
}
|
|
3213
|
+
/** Отправляет события просмотра постов (`POST /api/v1/i`). */
|
|
3214
|
+
dwell(entries, telemetryOptions = {}, requestOptions = {}) {
|
|
3215
|
+
const validated = entries.map(validateDwell);
|
|
3216
|
+
return this.#sendDwell(validated, telemetryOptions, requestOptions, false);
|
|
3217
|
+
}
|
|
3218
|
+
#sendDwell(entries, telemetryOptions, requestOptions, disposeCleanup) {
|
|
3219
|
+
const options = {
|
|
3220
|
+
path: "/api/v1/i",
|
|
3221
|
+
body: {
|
|
3222
|
+
sid: telemetryOptions.sid ?? this.sessionId,
|
|
3223
|
+
e: entries.map((entry) => ({
|
|
3224
|
+
md: entry.durationMs ?? entry.exitAt - entry.enterAt,
|
|
3225
|
+
et: entry.enterAt,
|
|
3226
|
+
xt: entry.exitAt,
|
|
3227
|
+
r: entry.reason,
|
|
3228
|
+
v: entry.vs,
|
|
3229
|
+
...entry.sourceContext !== void 0 ? { sc: entry.sourceContext } : {},
|
|
3230
|
+
...entry.source !== void 0 ? { s: entry.source } : {},
|
|
3231
|
+
...entry.repeat ? { b: 1 } : {}
|
|
3232
|
+
}))
|
|
3233
|
+
},
|
|
3234
|
+
...requestOptions
|
|
3235
|
+
};
|
|
3236
|
+
return disposeCleanup ? this.http.cleanupOperation("telemetry.dwell", options) : this.http.operation("telemetry.dwell", options);
|
|
3237
|
+
}
|
|
3238
|
+
/** Отправляет события взаимодействия с контентом (`POST /api/v1/x`). */
|
|
3239
|
+
interaction(entries, telemetryOptions = {}, requestOptions = {}) {
|
|
3240
|
+
const validated = entries.map(validateInteraction);
|
|
3241
|
+
return this.#sendInteraction(validated, telemetryOptions, requestOptions, false);
|
|
3242
|
+
}
|
|
3243
|
+
#sendInteraction(entries, telemetryOptions, requestOptions, disposeCleanup) {
|
|
3244
|
+
const options = {
|
|
3245
|
+
path: "/api/v1/x",
|
|
3246
|
+
body: {
|
|
3247
|
+
sid: telemetryOptions.sid ?? this.sessionId,
|
|
3248
|
+
e: entries.map((entry) => ({
|
|
3249
|
+
t: entry.type,
|
|
3250
|
+
v: entry.vs,
|
|
3251
|
+
ai: entry.postId,
|
|
3252
|
+
...entry.mediaIndex !== void 0 ? { mi: entry.mediaIndex } : {},
|
|
3253
|
+
...entry.source !== void 0 ? { s: entry.source } : {},
|
|
3254
|
+
...entry.positionMs !== void 0 ? { pm: Math.round(entry.positionMs) } : {},
|
|
3255
|
+
...entry.durationMs !== void 0 ? { dm: Math.round(entry.durationMs) } : {}
|
|
3256
|
+
}))
|
|
3257
|
+
},
|
|
3258
|
+
...requestOptions
|
|
3259
|
+
};
|
|
3260
|
+
return disposeCleanup ? this.http.cleanupOperation("telemetry.interaction", options) : this.http.operation("telemetry.interaction", options);
|
|
3261
|
+
}
|
|
3262
|
+
/** Начинает измерять время просмотра и отправляет результат после `finish()`. */
|
|
3263
|
+
startView(input, options = {}, requestOptions = {}) {
|
|
3264
|
+
const { clock = SYSTEM_CLOCK, ...telemetryOptions } = options;
|
|
3265
|
+
return new ViewTrackerImpl(input, clock, async (entry) => {
|
|
3266
|
+
await this.dwell([entry], telemetryOptions, requestOptions);
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3269
|
+
/** Отправляет событие открытия фотографии. */
|
|
3270
|
+
photoOpen(input, telemetryOptions = {}, requestOptions = {}) {
|
|
3271
|
+
return this.interaction([photoOpenEntry(input)], telemetryOptions, requestOptions);
|
|
3272
|
+
}
|
|
3273
|
+
/** Отправляет событие прогресса просмотра видео. */
|
|
3274
|
+
videoProgress(input, telemetryOptions = {}, requestOptions = {}) {
|
|
3275
|
+
return this.interaction([videoProgressEntry(input)], telemetryOptions, requestOptions);
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Создаёт накопитель с явными `flush()` и `close()`.
|
|
3279
|
+
*
|
|
3280
|
+
* Создание и добавление записей не выполняют сетевых запросов.
|
|
3281
|
+
*/
|
|
3282
|
+
batch(options = {}, requestOptions = {}) {
|
|
3283
|
+
let batch;
|
|
3284
|
+
batch = new TelemetryBatchImpl((entries, telemetry, request, cleanup) => this.#sendDwell(entries, telemetry, request, cleanup), (entries, telemetry, request, cleanup) => this.#sendInteraction(entries, telemetry, request, cleanup), options, requestOptions, () => {
|
|
3285
|
+
this.#batches.delete(batch);
|
|
3286
|
+
});
|
|
3287
|
+
this.#batches.add(batch);
|
|
3288
|
+
return batch;
|
|
3289
|
+
}
|
|
3290
|
+
/** Закрывает все созданные накопители, отправляя оставшиеся записи. */
|
|
3291
|
+
async close() {
|
|
3292
|
+
return this.#close(false);
|
|
3293
|
+
}
|
|
3294
|
+
async #close(disposeCleanup) {
|
|
3295
|
+
const errors = (await Promise.allSettled([...this.#batches].map((batch) => disposeCleanup ? batch.closeForDispose() : batch.close()))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
3296
|
+
if (errors.length > 0) throw new AggregateError(errors, "Не удалось отправить накопленную телеметрию");
|
|
3297
|
+
}
|
|
3298
|
+
};
|
|
3299
|
+
/** Помечает открытые накопители для terminal cleanup до первой асинхронной границы. @internal */
|
|
3300
|
+
function prepareTelemetryForDispose(resource) {
|
|
3301
|
+
if (resource) TELEMETRY_DISPOSE_PREPARERS.get(resource)?.();
|
|
3302
|
+
}
|
|
3303
|
+
/** Финализирует созданные накопители после перехода клиента в terminal state. @internal */
|
|
3304
|
+
function closeTelemetryForDispose(resource) {
|
|
3305
|
+
return resource ? TELEMETRY_DISPOSE_CLOSERS.get(resource)?.() ?? Promise.resolve() : Promise.resolve();
|
|
3306
|
+
}
|
|
3307
|
+
//#endregion
|
|
3308
|
+
//#region src/resources/users.ts
|
|
3309
|
+
/**
|
|
3310
|
+
* Пользователи: профили, подписки, блокировки, приватность.
|
|
3311
|
+
*
|
|
3312
|
+
* Доступна как `itd.users`.
|
|
3313
|
+
*/
|
|
3314
|
+
var UsersResource = class extends BaseResource {
|
|
3315
|
+
#uploadFile;
|
|
3316
|
+
constructor(http, deps) {
|
|
3317
|
+
super(http);
|
|
3318
|
+
this.#uploadFile = deps.uploadFile;
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* Списки пользователей: подписчики, подписки, заблокированные.
|
|
3322
|
+
*
|
|
3323
|
+
* Путь приходит в параметрах — так один описатель обслуживает все три эндпоинта. Имена
|
|
3324
|
+
* полей перечислены с запасом: списки приходят под `users`, но альтернативное имя ничего
|
|
3325
|
+
* не стоит и спасает, если эндпоинт назовёт список по-своему. `page` уходит в запрос, хотя
|
|
3326
|
+
* сервер его сейчас не читает (см. {@link followers}): когда починят — заработает само.
|
|
3327
|
+
*/
|
|
3328
|
+
#userList = this.paginated({
|
|
3329
|
+
operationId: (p) => p.operationId,
|
|
3330
|
+
path: (p) => p.path,
|
|
3331
|
+
query: (p) => ({ limit: p.limit }),
|
|
3332
|
+
start: (p) => p.page !== void 0 ? { page: p.page } : {},
|
|
3333
|
+
read: (body) => readPagedPage(body, "users", "followers", "following", "blocked"),
|
|
3334
|
+
mode: PaginationMode.Page
|
|
3335
|
+
});
|
|
3336
|
+
/** Загружает свой профиль — с подпиской и признаком подтверждённого телефона. */
|
|
3337
|
+
me(options = {}) {
|
|
3338
|
+
return this.http.operation("users.me", {
|
|
3339
|
+
path: "/api/users/me",
|
|
3340
|
+
...options
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
/** Обновляет свой профиль. Передавайте только изменяемые поля. */
|
|
3344
|
+
updateMe(input, options = {}) {
|
|
3345
|
+
return this.http.operation("users.updateMe", {
|
|
3346
|
+
path: "/api/users/me",
|
|
3347
|
+
body: input,
|
|
3348
|
+
...options
|
|
3349
|
+
});
|
|
3350
|
+
}
|
|
3351
|
+
/**
|
|
3352
|
+
* Загружает изображение и устанавливает его баннером профиля.
|
|
3353
|
+
*
|
|
3354
|
+
* Для установки используется идентификатор, полученный от `/api/files/upload`.
|
|
3355
|
+
* Если файл уже загружен, используйте {@link updateMe}: `{ bannerId: file.id }`.
|
|
3356
|
+
*
|
|
3357
|
+
* @example
|
|
3358
|
+
* ```ts
|
|
3359
|
+
* await itd.users.setBanner(file, { filename: 'banner.webp' });
|
|
3360
|
+
* ```
|
|
3361
|
+
*/
|
|
3362
|
+
async setBanner(file, uploadOptions = {}, requestOptions = {}) {
|
|
3363
|
+
const uploaded = await this.#uploadFile(file, uploadOptions, requestOptions);
|
|
3364
|
+
return this.updateMe({ bannerId: uploaded.id }, requestOptions);
|
|
3365
|
+
}
|
|
3366
|
+
/** Удаляет баннер профиля, устанавливая `bannerId` в `null`. */
|
|
3367
|
+
removeBanner(options = {}) {
|
|
3368
|
+
return this.updateMe({ bannerId: null }, options);
|
|
3369
|
+
}
|
|
3370
|
+
/** Деактивирует аккаунт. Вернуть его можно через {@link restore}. */
|
|
3371
|
+
deactivate(options = {}) {
|
|
3372
|
+
return this.http.operation("users.deactivate", {
|
|
3373
|
+
path: "/api/users/me",
|
|
3374
|
+
...options
|
|
3375
|
+
});
|
|
3376
|
+
}
|
|
3377
|
+
/** Восстанавливает деактивированный аккаунт. */
|
|
3378
|
+
restore(options = {}) {
|
|
3379
|
+
return this.http.operation("users.restore", {
|
|
3380
|
+
path: "/api/users/me/restore",
|
|
3381
|
+
...options
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
/** Создаёт профиль после регистрации. */
|
|
3385
|
+
createProfile(input, options = {}) {
|
|
3386
|
+
return this.http.operation("users.createProfile", {
|
|
3387
|
+
path: "/api/users/profile",
|
|
3388
|
+
body: input,
|
|
3389
|
+
...options
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Загружает профиль пользователя.
|
|
3394
|
+
*
|
|
3395
|
+
* @param user UUID **или** имя пользователя — подходит и то, и другое
|
|
3396
|
+
*
|
|
3397
|
+
* @example
|
|
3398
|
+
* ```ts
|
|
3399
|
+
* const profile = await itd.users.get('nowkie');
|
|
3400
|
+
* await itd.posts.create({ content: 'привет', wallRecipientId: profile.id });
|
|
3401
|
+
* ```
|
|
3402
|
+
*/
|
|
3403
|
+
get(user, options = {}) {
|
|
3404
|
+
return this.http.operation("users.get", {
|
|
3405
|
+
path: `/api/users/${encodePathSegment(user, "user")}`,
|
|
3406
|
+
...options
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
/** Проверяет, свободно ли имя пользователя. */
|
|
3410
|
+
async checkUsername(username, options = {}) {
|
|
3411
|
+
const body = await this.http.operation("users.checkUsername", {
|
|
3412
|
+
path: "/api/users/check-username",
|
|
3413
|
+
query: { username },
|
|
3414
|
+
...options
|
|
3415
|
+
});
|
|
3416
|
+
return pickBoolean(body, "available");
|
|
3417
|
+
}
|
|
3418
|
+
/** Ищет пользователей по строке запроса. */
|
|
3419
|
+
async search(query, params = {}, options = {}) {
|
|
3420
|
+
const body = await this.http.operation("users.search", {
|
|
3421
|
+
path: "/api/users/search",
|
|
3422
|
+
query: {
|
|
3423
|
+
q: query,
|
|
3424
|
+
limit: params.limit
|
|
3425
|
+
},
|
|
3426
|
+
...options
|
|
3427
|
+
});
|
|
3428
|
+
return pickArray(body, "users");
|
|
3429
|
+
}
|
|
3430
|
+
/** Загружает рекомендации, на кого подписаться. */
|
|
3431
|
+
async whoToFollow(options = {}) {
|
|
3432
|
+
const body = await this.http.operation("users.whoToFollow", {
|
|
3433
|
+
path: "/api/users/suggestions/who-to-follow",
|
|
3434
|
+
...options
|
|
3435
|
+
});
|
|
3436
|
+
return pickArray(body, "users");
|
|
3437
|
+
}
|
|
3438
|
+
/** Загружает рейтинг кланов. */
|
|
3439
|
+
async topClans(options = {}) {
|
|
3440
|
+
const body = await this.http.operation("users.topClans", {
|
|
3441
|
+
path: "/api/users/stats/top-clans",
|
|
3442
|
+
...options
|
|
3443
|
+
});
|
|
3444
|
+
return pickArray(body, "clans");
|
|
3445
|
+
}
|
|
3446
|
+
/**
|
|
3447
|
+
* Подписывается на пользователя.
|
|
3448
|
+
*
|
|
3449
|
+
* У закрытого профиля вместо подписки отправляется заявка — это видно по полю `status`.
|
|
3450
|
+
*/
|
|
3451
|
+
follow(user, options = {}) {
|
|
3452
|
+
return this.http.operation("users.follow", {
|
|
3453
|
+
path: `/api/users/${encodePathSegment(user, "user")}/follow`,
|
|
3454
|
+
body: {},
|
|
3455
|
+
...options
|
|
3456
|
+
});
|
|
3457
|
+
}
|
|
3458
|
+
/** Отписывается от пользователя. */
|
|
3459
|
+
unfollow(user, options = {}) {
|
|
3460
|
+
return this.http.operation("users.unfollow", {
|
|
3461
|
+
path: `/api/users/${encodePathSegment(user, "user")}/follow`,
|
|
3462
|
+
...options
|
|
3463
|
+
});
|
|
3464
|
+
}
|
|
3465
|
+
/**
|
|
3466
|
+
* Загружает подписчиков пользователя.
|
|
3467
|
+
*
|
|
3468
|
+
* ⚠️ **Сервер этот список не листает.** Возвращаются первые 20 записей и только они:
|
|
3469
|
+
* параметр `page` игнорируется (любая страница отдаёт те же записи и `pagination.page: 1`),
|
|
3470
|
+
* `limit` больше 20 молча уменьшается, а `hasMore` всегда `false`. Последнее честно —
|
|
3471
|
+
* получить продолжение нечем.
|
|
3472
|
+
*
|
|
3473
|
+
* Числу `total` доверять тоже не стоит: оно расходится с `followersCount` из профиля —
|
|
3474
|
+
* на проверенных аккаунтах занижено примерно на 1–4%.
|
|
3475
|
+
*/
|
|
3476
|
+
followers(user, params = {}, options = {}) {
|
|
3477
|
+
return this.#userPage("users.followers", `/api/users/${encodePathSegment(user, "user")}/followers`, params, options);
|
|
3478
|
+
}
|
|
3479
|
+
/**
|
|
3480
|
+
* Перебирает подписчиков.
|
|
3481
|
+
*
|
|
3482
|
+
* ⚠️ Перебор закончится после первых 20 записей: сервер список не листает —
|
|
3483
|
+
* см. {@link followers}. Метод оставлен на случай, если пагинацию починят.
|
|
3484
|
+
*/
|
|
3485
|
+
iterateFollowers(user, params = {}, options = {}) {
|
|
3486
|
+
return this.#userPaginator("users.followers", `/api/users/${encodePathSegment(user, "user")}/followers`, params, options);
|
|
3487
|
+
}
|
|
3488
|
+
/** Загружает подписки пользователя. Ограничения те же, что у {@link followers}. */
|
|
3489
|
+
following(user, params = {}, options = {}) {
|
|
3490
|
+
return this.#userPage("users.following", `/api/users/${encodePathSegment(user, "user")}/following`, params, options);
|
|
3491
|
+
}
|
|
3492
|
+
/** Перебирает подписки. Закончится после первых 20 записей — см. {@link followers}. */
|
|
3493
|
+
iterateFollowing(user, params = {}, options = {}) {
|
|
3494
|
+
return this.#userPaginator("users.following", `/api/users/${encodePathSegment(user, "user")}/following`, params, options);
|
|
3495
|
+
}
|
|
3496
|
+
/**
|
|
3497
|
+
* Проверяет, подписаны ли вы, сразу для нескольких пользователей.
|
|
3498
|
+
*
|
|
3499
|
+
* @returns объект «идентификатор пользователя → подписаны ли вы»
|
|
3500
|
+
*
|
|
3501
|
+
* @example
|
|
3502
|
+
* ```ts
|
|
3503
|
+
* const statuses = await itd.users.followStatus([userA, userB]);
|
|
3504
|
+
* // { 'b89dee4f-…': true, '35ea3059-…': false }
|
|
3505
|
+
* ```
|
|
3506
|
+
*/
|
|
3507
|
+
followStatus(userIds, options = {}) {
|
|
3508
|
+
return this.http.operation("users.followStatus", {
|
|
3509
|
+
path: "/api/users/follow-status",
|
|
3510
|
+
body: { userIds },
|
|
3511
|
+
...options
|
|
3512
|
+
});
|
|
3513
|
+
}
|
|
3514
|
+
/** Блокирует пользователя. */
|
|
3515
|
+
block(user, options = {}) {
|
|
3516
|
+
return this.http.operation("users.block", {
|
|
3517
|
+
path: `/api/users/${encodePathSegment(user, "user")}/block`,
|
|
3518
|
+
body: {},
|
|
3519
|
+
...options
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
/** Снимает блокировку. */
|
|
3523
|
+
unblock(user, options = {}) {
|
|
3524
|
+
return this.http.operation("users.unblock", {
|
|
3525
|
+
path: `/api/users/${encodePathSegment(user, "user")}/block`,
|
|
3526
|
+
...options
|
|
3527
|
+
});
|
|
3528
|
+
}
|
|
3529
|
+
/** Загружает заблокированных пользователей. Ограничения те же, что у {@link followers}. */
|
|
3530
|
+
blocked(params = {}, options = {}) {
|
|
3531
|
+
return this.#userPage("users.blocked", "/api/users/me/blocked", params, options);
|
|
3532
|
+
}
|
|
3533
|
+
/** Перебирает заблокированных. Закончится после первых 20 записей — см. {@link followers}. */
|
|
3534
|
+
iterateBlocked(params = {}, options = {}) {
|
|
3535
|
+
return this.#userPaginator("users.blocked", "/api/users/me/blocked", params, options);
|
|
3536
|
+
}
|
|
3537
|
+
/** Загружает настройки приватности. */
|
|
3538
|
+
getPrivacy(options = {}) {
|
|
3539
|
+
return this.http.operation("users.getPrivacy", {
|
|
3540
|
+
path: "/api/users/me/privacy",
|
|
3541
|
+
...options
|
|
3542
|
+
});
|
|
3543
|
+
}
|
|
3544
|
+
/** Обновляет настройки приватности. Передавайте только изменяемые поля. */
|
|
3545
|
+
updatePrivacy(input, options = {}) {
|
|
3546
|
+
return this.http.operation("users.updatePrivacy", {
|
|
3547
|
+
path: "/api/users/me/privacy",
|
|
3548
|
+
body: input,
|
|
3549
|
+
...options
|
|
3550
|
+
});
|
|
3551
|
+
}
|
|
3552
|
+
/**
|
|
3553
|
+
* Загружает значки профиля и выбранный из них.
|
|
3554
|
+
*
|
|
3555
|
+
* `activePin` — строка-идентификатор, а не объект.
|
|
3556
|
+
*/
|
|
3557
|
+
async pins(options = {}) {
|
|
3558
|
+
const body = await this.http.operation("users.pins", {
|
|
3559
|
+
path: "/api/users/me/pins",
|
|
3560
|
+
...options
|
|
3561
|
+
});
|
|
3562
|
+
return {
|
|
3563
|
+
pins: pickArray(body, "pins"),
|
|
3564
|
+
activePin: pickString(body, "activePin") ?? null
|
|
3565
|
+
};
|
|
3566
|
+
}
|
|
3567
|
+
/** Выбирает активный значок профиля. */
|
|
3568
|
+
setPin(slug, options = {}) {
|
|
3569
|
+
return this.http.operation("users.setPin", {
|
|
3570
|
+
path: "/api/users/me/pin",
|
|
3571
|
+
body: { slug },
|
|
3572
|
+
...options
|
|
3573
|
+
});
|
|
3574
|
+
}
|
|
3575
|
+
/** Снимает активный значок. */
|
|
3576
|
+
removePin(options = {}) {
|
|
3577
|
+
return this.http.operation("users.removePin", {
|
|
3578
|
+
path: "/api/users/me/pin",
|
|
3579
|
+
...options
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
#userPage(operationId, path, params, options) {
|
|
3583
|
+
return this.#userList.list({
|
|
3584
|
+
...params,
|
|
3585
|
+
operationId,
|
|
3586
|
+
path
|
|
3587
|
+
}, options);
|
|
3588
|
+
}
|
|
3589
|
+
#userPaginator(operationId, path, params, options) {
|
|
3590
|
+
return this.#userList.iterate({
|
|
3591
|
+
...params,
|
|
3592
|
+
operationId,
|
|
3593
|
+
path
|
|
3594
|
+
}, options);
|
|
3595
|
+
}
|
|
3596
|
+
};
|
|
3597
|
+
//#endregion
|
|
3598
|
+
//#region src/resources/verification.ts
|
|
3599
|
+
/**
|
|
3600
|
+
* Верификация профиля.
|
|
3601
|
+
*
|
|
3602
|
+
* Доступна как `itd.verification`.
|
|
3603
|
+
*/
|
|
3604
|
+
var VerificationResource = class extends BaseResource {
|
|
3605
|
+
/** Загружает статус заявки. Значение `none` означает, что заявка не подавалась. */
|
|
3606
|
+
status(options = {}) {
|
|
3607
|
+
return this.http.operation("verification.status", {
|
|
3608
|
+
path: "/api/verification/status",
|
|
3609
|
+
...options
|
|
3610
|
+
});
|
|
3611
|
+
}
|
|
3612
|
+
/** Подаёт заявку на верификацию с видео. */
|
|
3613
|
+
submit(videoUrl, options = {}) {
|
|
3614
|
+
return this.http.operation("verification.submit", {
|
|
3615
|
+
path: "/api/verification/submit",
|
|
3616
|
+
body: { videoUrl },
|
|
3617
|
+
...options
|
|
3618
|
+
});
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
//#endregion
|
|
3622
|
+
//#region src/rest/resources.ts
|
|
3623
|
+
/**
|
|
3624
|
+
* Собирает набор ресурсов, создавая каждый при первом обращении.
|
|
3625
|
+
*
|
|
3626
|
+
* Ленивость существенна: клиенту редко нужны все двенадцать разом, а закрытие не должно
|
|
3627
|
+
* поднимать накопитель телеметрии только ради того, чтобы его тут же закрыть.
|
|
3628
|
+
*
|
|
3629
|
+
* Набор общий для полного клиента и минимального REST-клиента — иначе список ресурсов
|
|
3630
|
+
* и их зависимости разъезжались бы по двум фасадам.
|
|
3631
|
+
*/
|
|
3632
|
+
function createResources(deps) {
|
|
3633
|
+
let users;
|
|
3634
|
+
let posts;
|
|
3635
|
+
let comments;
|
|
3636
|
+
let files;
|
|
3637
|
+
let notifications;
|
|
3638
|
+
let hashtags;
|
|
3639
|
+
let search;
|
|
3640
|
+
let reports;
|
|
3641
|
+
let verification;
|
|
3642
|
+
let subscription;
|
|
3643
|
+
let platform;
|
|
3644
|
+
let telemetry;
|
|
3645
|
+
const bag = {
|
|
3646
|
+
get users() {
|
|
3647
|
+
users ??= new UsersResource(deps.http, { uploadFile });
|
|
3648
|
+
return users;
|
|
3649
|
+
},
|
|
3650
|
+
get posts() {
|
|
3651
|
+
posts ??= new PostsResource(deps.http, { uploadFiles });
|
|
3652
|
+
return posts;
|
|
3653
|
+
},
|
|
3654
|
+
get comments() {
|
|
3655
|
+
comments ??= new CommentsResource(deps.http, { uploadFiles });
|
|
3656
|
+
return comments;
|
|
3657
|
+
},
|
|
3658
|
+
get files() {
|
|
3659
|
+
files ??= new FilesResource(deps.http, { fetch: deps.fetch });
|
|
3660
|
+
return files;
|
|
3661
|
+
},
|
|
3662
|
+
get notifications() {
|
|
3663
|
+
notifications ??= new NotificationsResource(deps.http);
|
|
3664
|
+
return notifications;
|
|
3665
|
+
},
|
|
3666
|
+
get hashtags() {
|
|
3667
|
+
hashtags ??= new HashtagsResource(deps.http);
|
|
3668
|
+
return hashtags;
|
|
3669
|
+
},
|
|
3670
|
+
get search() {
|
|
3671
|
+
search ??= new SearchResource(deps.http);
|
|
3672
|
+
return search;
|
|
3673
|
+
},
|
|
3674
|
+
get reports() {
|
|
3675
|
+
reports ??= new ReportsResource(deps.http);
|
|
3676
|
+
return reports;
|
|
3677
|
+
},
|
|
3678
|
+
get verification() {
|
|
3679
|
+
verification ??= new VerificationResource(deps.http);
|
|
3680
|
+
return verification;
|
|
3681
|
+
},
|
|
3682
|
+
get subscription() {
|
|
3683
|
+
subscription ??= new SubscriptionResource(deps.http);
|
|
3684
|
+
return subscription;
|
|
3685
|
+
},
|
|
3686
|
+
get platform() {
|
|
3687
|
+
platform ??= new PlatformResource(deps.http);
|
|
3688
|
+
return platform;
|
|
3689
|
+
},
|
|
3690
|
+
get telemetry() {
|
|
3691
|
+
telemetry ??= new TelemetryResource(deps.http);
|
|
3692
|
+
return telemetry;
|
|
3693
|
+
},
|
|
3694
|
+
closeTelemetry: (forDispose) => forDispose ? closeTelemetryForDispose(telemetry) : telemetry?.close() ?? Promise.resolve(),
|
|
3695
|
+
prepareTelemetryClose: () => prepareTelemetryForDispose(telemetry)
|
|
3696
|
+
};
|
|
3697
|
+
const uploadFile = (file, uploadOptions, requestOptions) => bag.files.upload(file, uploadOptions ?? {}, requestOptions ?? {});
|
|
3698
|
+
const uploadFiles = (input, requestOptions) => bag.files.uploadMany(input, {}, requestOptions ?? {});
|
|
3699
|
+
return bag;
|
|
3700
|
+
}
|
|
3701
|
+
//#endregion
|
|
3702
|
+
//#region src/core/attachments/factories.ts
|
|
3703
|
+
function fromUrl(url, options = {}) {
|
|
3704
|
+
if (resolveFileStreamOptions(options, 104857600).mode === FileTransferMode.Stream) return { open: (context) => openUrlFile(url, options, context) };
|
|
3705
|
+
return { load: (context) => downloadFile(url, options, context) };
|
|
3706
|
+
}
|
|
3707
|
+
/**
|
|
3708
|
+
* Создаёт повторяемый пользовательский поток.
|
|
3709
|
+
*
|
|
3710
|
+
* Фабрика вызывается заново для каждой попытки; возвращать один и тот же поток нельзя.
|
|
3711
|
+
*/
|
|
3712
|
+
function fromStream(factory, options = {}) {
|
|
3713
|
+
const resolved = resolveFileStreamOptions({
|
|
3714
|
+
...options,
|
|
3715
|
+
mode: FileTransferMode.Stream
|
|
3716
|
+
}, void 0);
|
|
3717
|
+
optionalBytes(options.size, "size");
|
|
3718
|
+
return { open: async (context) => {
|
|
3719
|
+
let opened;
|
|
3720
|
+
try {
|
|
3721
|
+
opened = await factory(context);
|
|
3722
|
+
} catch (error) {
|
|
3723
|
+
if (error instanceof ItdFileError || error instanceof ItdConfigError || context.signal?.aborted) throw error;
|
|
3724
|
+
throw new ItdFileError("не удалось открыть поток вложения", {
|
|
3725
|
+
reason: ItdFileErrorReason.Read,
|
|
3726
|
+
retryable: true,
|
|
3727
|
+
cause: error
|
|
3728
|
+
});
|
|
3729
|
+
}
|
|
3730
|
+
const content = isReadableByteStream(opened) ? { stream: opened } : opened;
|
|
3731
|
+
if (!content || !isReadableByteStream(content.stream)) throw new ItdConfigError("fromStream должен вернуть ReadableStream или { stream }");
|
|
3732
|
+
const size = options.size ?? content.size;
|
|
3733
|
+
optionalBytes(size, "size");
|
|
3734
|
+
if (resolved.maxBytes !== void 0 && size !== void 0 && size > resolved.maxBytes) {
|
|
3735
|
+
await content.close?.();
|
|
3736
|
+
throw fileTooLarge(void 0, resolved.maxBytes, size);
|
|
3737
|
+
}
|
|
3738
|
+
const contentType = normalizeMimeType(options.contentType ?? content.contentType);
|
|
3739
|
+
return {
|
|
3740
|
+
stream: boundedFileStream(content.stream, {
|
|
3741
|
+
...resolved.maxBytes !== void 0 ? { maxBytes: resolved.maxBytes } : {},
|
|
3742
|
+
streamBufferBytes: resolved.streamBufferBytes,
|
|
3743
|
+
...context.signal ? { signal: context.signal } : {},
|
|
3744
|
+
retryableRead: true
|
|
3745
|
+
}),
|
|
3746
|
+
...options.filename ?? content.filename ? { filename: options.filename ?? content.filename } : {},
|
|
3747
|
+
...contentType ? { contentType } : {},
|
|
3748
|
+
...size !== void 0 ? { size } : {},
|
|
3749
|
+
...content.close ? { close: content.close } : {}
|
|
3750
|
+
};
|
|
3751
|
+
} };
|
|
3752
|
+
}
|
|
3753
|
+
//#endregion
|
|
3754
|
+
//#region src/models/guards.ts
|
|
3755
|
+
/**
|
|
3756
|
+
* Свой ли это профиль.
|
|
3757
|
+
*
|
|
3758
|
+
* @example
|
|
3759
|
+
* ```ts
|
|
3760
|
+
* if (isMyProfile(profile)) console.log(profile.subscription.isActive);
|
|
3761
|
+
* ```
|
|
3762
|
+
*/
|
|
3763
|
+
function isMyProfile(profile) {
|
|
3764
|
+
return "subscription" in profile;
|
|
3765
|
+
}
|
|
3766
|
+
//#endregion
|
|
3767
|
+
//#region src/models/status-helpers.ts
|
|
3768
|
+
const STATUS_WINDOW_DAYS = 90;
|
|
3769
|
+
/**
|
|
3770
|
+
* Разворачивает историю сервиса в массив на 90 суток.
|
|
3771
|
+
* Сутки без данных становятся `null`.
|
|
3772
|
+
*
|
|
3773
|
+
* @returns массив, где индекс — сколько суток назад: `[0]` — сегодня
|
|
3774
|
+
*
|
|
3775
|
+
* @example
|
|
3776
|
+
* ```ts
|
|
3777
|
+
* const status = await itd.platform.status();
|
|
3778
|
+
* const days = statusDays(status.services[0]);
|
|
3779
|
+
*
|
|
3780
|
+
* days[0]?.uptime; // доступность за сегодня
|
|
3781
|
+
* days.filter((day) => day === null).length; // за сколько суток данных нет
|
|
3782
|
+
* ```
|
|
3783
|
+
*/
|
|
3784
|
+
function statusDays(service) {
|
|
3785
|
+
return Array.from({ length: STATUS_WINDOW_DAYS }, (_, index) => service.days[String(index)] ?? null);
|
|
3786
|
+
}
|
|
3787
|
+
//#endregion
|
|
3788
|
+
//#region src/spans/render.ts
|
|
3789
|
+
/** Формат результата {@link renderSpans}. */
|
|
3790
|
+
const SpanRenderFormat = Object.freeze({
|
|
3791
|
+
Html: "html",
|
|
3792
|
+
Markdown: "markdown",
|
|
3793
|
+
Ansi: "ansi"
|
|
3794
|
+
});
|
|
3795
|
+
function normalizedSpans(content, spans) {
|
|
3796
|
+
const result = [];
|
|
3797
|
+
for (const span of spans ?? []) {
|
|
3798
|
+
if (!span || !Number.isFinite(span.offset) || !Number.isFinite(span.length) || span.length <= 0) continue;
|
|
3799
|
+
const start = Math.max(0, Math.trunc(span.offset));
|
|
3800
|
+
const end = Math.min(content.length, Math.trunc(span.offset + span.length));
|
|
3801
|
+
if (start < end && start < content.length) result.push({
|
|
3802
|
+
span,
|
|
3803
|
+
start,
|
|
3804
|
+
end
|
|
3805
|
+
});
|
|
3806
|
+
}
|
|
3807
|
+
return result;
|
|
3808
|
+
}
|
|
3809
|
+
function escapeHtml(value) {
|
|
3810
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3811
|
+
}
|
|
3812
|
+
function safeHttpUrl(value) {
|
|
3813
|
+
if (!value) return null;
|
|
3814
|
+
try {
|
|
3815
|
+
const url = new URL(value);
|
|
3816
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
3817
|
+
} catch {
|
|
3818
|
+
return null;
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
function safeNavigationUrl(value) {
|
|
3822
|
+
if (!value) return null;
|
|
3823
|
+
try {
|
|
3824
|
+
const url = new URL(value, "https://itd.invalid");
|
|
3825
|
+
return url.protocol === "http:" || url.protocol === "https:" ? value : null;
|
|
3826
|
+
} catch {
|
|
3827
|
+
return null;
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
function entityValue(span, content, kind) {
|
|
3831
|
+
const offset = Number.isFinite(span.offset) ? Math.max(0, Math.trunc(span.offset)) : 0;
|
|
3832
|
+
const end = Number.isFinite(span.length) ? Math.min(content.length, offset + Math.max(0, Math.trunc(span.length))) : offset;
|
|
3833
|
+
const text = content.slice(offset, end);
|
|
3834
|
+
if (kind === "mention") return span.username ?? span.tag ?? text.replace(/^@/u, "");
|
|
3835
|
+
return span.tag ?? text.replace(/^#/u, "");
|
|
3836
|
+
}
|
|
3837
|
+
function mentionUrl(username, options) {
|
|
3838
|
+
return options.mentionUrl ? options.mentionUrl(username) : `/@${encodeURIComponent(username)}`;
|
|
3839
|
+
}
|
|
3840
|
+
function hashtagUrl(tag, options) {
|
|
3841
|
+
return options.hashtagUrl ? options.hashtagUrl(tag) : `/hashtag/${encodeURIComponent(tag)}`;
|
|
3842
|
+
}
|
|
3843
|
+
function classAttribute(name, options) {
|
|
3844
|
+
const prefix = options.classPrefix === void 0 ? "itd" : options.classPrefix;
|
|
3845
|
+
return prefix ? ` class="${escapeHtml(`${prefix}-${name}`)}"` : "";
|
|
3846
|
+
}
|
|
3847
|
+
function has(active, type) {
|
|
3848
|
+
return active.some((span) => span.type === type);
|
|
3849
|
+
}
|
|
3850
|
+
function last(active, type) {
|
|
3851
|
+
for (let index = active.length - 1; index >= 0; index -= 1) {
|
|
3852
|
+
const span = active[index];
|
|
3853
|
+
if (span?.type === type) return span;
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
function renderHtmlSegment(text, active, content, options) {
|
|
3857
|
+
let rendered = escapeHtml(text).replace(/\n/g, "<br>");
|
|
3858
|
+
if (has(active, SpanType.Bold)) rendered = `<strong>${rendered}</strong>`;
|
|
3859
|
+
if (has(active, SpanType.Italic)) rendered = `<em>${rendered}</em>`;
|
|
3860
|
+
if (has(active, SpanType.Underline)) rendered = `<u>${rendered}</u>`;
|
|
3861
|
+
if (has(active, SpanType.Strike)) rendered = `<s>${rendered}</s>`;
|
|
3862
|
+
if (has(active, SpanType.Monospace)) rendered = `<code>${rendered}</code>`;
|
|
3863
|
+
if (has(active, SpanType.Quote)) rendered = `<span${classAttribute("quote", options)}>${rendered}</span>`;
|
|
3864
|
+
if (has(active, SpanType.Spoiler)) rendered = `<span${classAttribute("spoiler", options)} data-spoiler="true">${rendered}</span>`;
|
|
3865
|
+
const url = safeHttpUrl(last(active, SpanType.Link)?.url);
|
|
3866
|
+
if (url) rendered = `<a href="${escapeHtml(url)}" rel="noopener noreferrer">${rendered}</a>`;
|
|
3867
|
+
const mention = last(active, SpanType.Mention);
|
|
3868
|
+
if (mention) {
|
|
3869
|
+
const href = safeNavigationUrl(mentionUrl(entityValue(mention, content, "mention"), options));
|
|
3870
|
+
if (href) rendered = `<a href="${escapeHtml(href)}"${classAttribute("mention", options)}>${rendered}</a>`;
|
|
3871
|
+
}
|
|
3872
|
+
const hashtag = last(active, SpanType.Hashtag);
|
|
3873
|
+
if (hashtag) {
|
|
3874
|
+
const href = safeNavigationUrl(hashtagUrl(entityValue(hashtag, content, "hashtag"), options));
|
|
3875
|
+
if (href) rendered = `<a href="${escapeHtml(href)}"${classAttribute("hashtag", options)}>${rendered}</a>`;
|
|
3876
|
+
}
|
|
3877
|
+
return rendered;
|
|
3878
|
+
}
|
|
3879
|
+
function escapeMarkdown(value, startsAtLineStart) {
|
|
3880
|
+
return value.replace(/([\\`*_[\]<>~|&])/g, "\\$1").split("\n").map((line, index) => {
|
|
3881
|
+
if (index === 0 && !startsAtLineStart) return line;
|
|
3882
|
+
return line.replace(/^ {0,3}(?=#{1,6}(?:[ \t]|$))/u, "$&\\").replace(/^ {0,3}(?=[+-][ \t])/u, "$&\\").replace(/^(\s{0,3}\d{1,9})([.)])(?=[ \t])/u, "$1\\$2").replace(/^(\s{0,3})(?=-(?:[ \t]*-){2,}[ \t]*$)/u, "$1\\");
|
|
3883
|
+
}).join("\n");
|
|
3884
|
+
}
|
|
3885
|
+
function markdownCodeSpan(text) {
|
|
3886
|
+
const normalized = text.replace(/\r\n?|\n/g, " ");
|
|
3887
|
+
const longestRun = Math.max(0, ...Array.from(normalized.matchAll(/`+/g), (match) => match[0].length));
|
|
3888
|
+
const fence = "`".repeat(longestRun + 1);
|
|
3889
|
+
const allSpaces = /^ +$/u.test(normalized);
|
|
3890
|
+
return `${fence}${normalized.startsWith("`") || normalized.endsWith("`") || !allSpaces && normalized.startsWith(" ") && normalized.endsWith(" ") ? ` ${normalized} ` : normalized}${fence}`;
|
|
3891
|
+
}
|
|
3892
|
+
function markdownUrl(value) {
|
|
3893
|
+
return value.replace(/([\\)])/g, "\\$1");
|
|
3894
|
+
}
|
|
3895
|
+
function renderMarkdownSegment(segment, content, options) {
|
|
3896
|
+
const { text, active } = segment;
|
|
3897
|
+
const startsAtLineStart = segment.start === 0 || content[segment.start - 1] === "\n";
|
|
3898
|
+
let rendered = has(active, SpanType.Monospace) ? markdownCodeSpan(text) : escapeMarkdown(text, startsAtLineStart);
|
|
3899
|
+
if (has(active, SpanType.Bold)) rendered = `**${rendered}**`;
|
|
3900
|
+
if (has(active, SpanType.Italic)) rendered = `*${rendered}*`;
|
|
3901
|
+
if (has(active, SpanType.Underline)) rendered = `<u>${rendered}</u>`;
|
|
3902
|
+
if (has(active, SpanType.Strike)) rendered = `~~${rendered}~~`;
|
|
3903
|
+
if (has(active, SpanType.Spoiler)) rendered = `||${rendered}||`;
|
|
3904
|
+
const url = safeHttpUrl(last(active, SpanType.Link)?.url);
|
|
3905
|
+
if (url) rendered = `[${rendered}](${markdownUrl(url)})`;
|
|
3906
|
+
const mention = last(active, SpanType.Mention);
|
|
3907
|
+
if (mention) {
|
|
3908
|
+
const href = safeNavigationUrl(mentionUrl(entityValue(mention, content, "mention"), options));
|
|
3909
|
+
if (href) rendered = `[${rendered}](${markdownUrl(href)})`;
|
|
3910
|
+
}
|
|
3911
|
+
const hashtag = last(active, SpanType.Hashtag);
|
|
3912
|
+
if (hashtag) {
|
|
3913
|
+
const href = safeNavigationUrl(hashtagUrl(entityValue(hashtag, content, "hashtag"), options));
|
|
3914
|
+
if (href) rendered = `[${rendered}](${markdownUrl(href)})`;
|
|
3915
|
+
}
|
|
3916
|
+
return rendered;
|
|
3917
|
+
}
|
|
3918
|
+
function renderMarkdown(segments, content, options) {
|
|
3919
|
+
const groups = [];
|
|
3920
|
+
for (const segment of segments) {
|
|
3921
|
+
const quoted = has(segment.active, SpanType.Quote);
|
|
3922
|
+
const rendered = renderMarkdownSegment(segment, content, options);
|
|
3923
|
+
const previous = groups[groups.length - 1];
|
|
3924
|
+
if (previous?.quoted === quoted) previous.text += rendered;
|
|
3925
|
+
else groups.push({
|
|
3926
|
+
quoted,
|
|
3927
|
+
text: rendered
|
|
3928
|
+
});
|
|
3929
|
+
}
|
|
3930
|
+
let result = "";
|
|
3931
|
+
for (const [index, group] of groups.entries()) {
|
|
3932
|
+
const rendered = group.quoted ? group.text.split("\n").map((line) => `> ${line}`).join("\n") : group.text;
|
|
3933
|
+
const previous = groups[index - 1];
|
|
3934
|
+
if (result !== "" && (group.quoted || previous?.quoted) && !result.endsWith("\n") && !rendered.startsWith("\n")) result += "\n";
|
|
3935
|
+
result += rendered;
|
|
3936
|
+
}
|
|
3937
|
+
return result;
|
|
3938
|
+
}
|
|
3939
|
+
function renderAnsiSegment(text, active) {
|
|
3940
|
+
const codes = [];
|
|
3941
|
+
if (has(active, SpanType.Bold)) codes.push(1);
|
|
3942
|
+
if (has(active, SpanType.Italic)) codes.push(3);
|
|
3943
|
+
if (has(active, SpanType.Underline) || has(active, SpanType.Link)) codes.push(4);
|
|
3944
|
+
if (has(active, SpanType.Strike)) codes.push(9);
|
|
3945
|
+
if (has(active, SpanType.Spoiler)) codes.push(8);
|
|
3946
|
+
if (has(active, SpanType.Quote)) codes.push(2);
|
|
3947
|
+
if (has(active, SpanType.Mention) || has(active, SpanType.Hashtag)) codes.push(36);
|
|
3948
|
+
return codes.length === 0 ? text : `\u001b[${codes.join(";")}m${text}\u001b[0m`;
|
|
3949
|
+
}
|
|
3950
|
+
/**
|
|
3951
|
+
* Преобразует текст и wire-разметку API в безопасный HTML, Markdown или ANSI.
|
|
3952
|
+
*
|
|
3953
|
+
* Некорректные серверные spans игнорируются либо обрезаются по границам строки. Пересекающиеся
|
|
3954
|
+
* spans разбиваются на независимые сегменты, поэтому HTML остаётся корректно вложенным.
|
|
3955
|
+
* Отсутствующий массив считается пустым; формат по умолчанию — HTML.
|
|
3956
|
+
*/
|
|
3957
|
+
function renderSpans(content, spans = [], options = {}) {
|
|
3958
|
+
const normalized = normalizedSpans(content, spans);
|
|
3959
|
+
const boundaries = /* @__PURE__ */ new Set([0, content.length]);
|
|
3960
|
+
for (const item of normalized) {
|
|
3961
|
+
boundaries.add(item.start);
|
|
3962
|
+
boundaries.add(item.end);
|
|
3963
|
+
}
|
|
3964
|
+
const points = [...boundaries].sort((left, right) => left - right);
|
|
3965
|
+
const segments = [];
|
|
3966
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
3967
|
+
const start = points[index];
|
|
3968
|
+
const end = points[index + 1];
|
|
3969
|
+
if (start === void 0 || end === void 0 || start === end) continue;
|
|
3970
|
+
const text = content.slice(start, end);
|
|
3971
|
+
const active = normalized.filter((item) => item.start <= start && item.end >= end).map((item) => item.span);
|
|
3972
|
+
segments.push({
|
|
3973
|
+
start,
|
|
3974
|
+
text,
|
|
3975
|
+
active
|
|
3976
|
+
});
|
|
3977
|
+
}
|
|
3978
|
+
const format = options.format ?? SpanRenderFormat.Html;
|
|
3979
|
+
if (format === SpanRenderFormat.Markdown) return renderMarkdown(segments, content, options);
|
|
3980
|
+
let result = "";
|
|
3981
|
+
for (const segment of segments) switch (format) {
|
|
3982
|
+
case SpanRenderFormat.Html:
|
|
3983
|
+
result += renderHtmlSegment(segment.text, segment.active, content, options);
|
|
3984
|
+
break;
|
|
3985
|
+
case SpanRenderFormat.Ansi: result += renderAnsiSegment(segment.text, segment.active);
|
|
3986
|
+
}
|
|
3987
|
+
return result;
|
|
3988
|
+
}
|
|
3989
|
+
//#endregion
|
|
3990
|
+
export { BaseResource as C, mapPage as E, isBuilder as S, Paginator as T, ALLOWED_MIME_TYPES as _, fromStream as a, VIDEO_MIME_TYPES as b, report as c, autoSpans as d, markup as f, utcStampToIso as g, toDate as h, isMyProfile as i, post as l, parseMarkdown as m, renderSpans as n, fromUrl as o, parseHtml as p, statusDays as r, createResources as s, SpanRenderFormat as t, poll as u, AUDIO_MIME_TYPES as v, PaginationMode as w, comment as x, IMAGE_MIME_TYPES as y };
|
|
3991
|
+
|
|
3992
|
+
//# sourceMappingURL=render-vtLixIiU.js.map
|