@hrarsha/evand-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/evand.js +948 -0
- package/dist/evand.js.map +1 -0
- package/dist/format.js +163 -0
- package/dist/format.js.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/resolver.js +320 -0
- package/dist/resolver.js.map +1 -0
- package/dist/server.js +287 -0
- package/dist/server.js.map +1 -0
- package/package.json +58 -4
package/dist/evand.js
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import { formatTehranDate, htmlToText, normalizeIsoDate, normalizePrice, truncateText, } from "./format.js";
|
|
2
|
+
import { flattenCategories, resolveCategory, resolveCity, suggestCategories, suggestCities, } from "./resolver.js";
|
|
3
|
+
const DEFAULT_API_BASE = "https://api.evand.com";
|
|
4
|
+
const DEFAULT_WEB_BASE = "https://evand.com";
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
6
|
+
const METADATA_TTL_MS = 10 * 60 * 1000;
|
|
7
|
+
const MAX_DESCRIPTION_CHARS = 40_000;
|
|
8
|
+
const EVENT_INCLUDES = [
|
|
9
|
+
"tags",
|
|
10
|
+
"type",
|
|
11
|
+
"city",
|
|
12
|
+
"prices",
|
|
13
|
+
"category",
|
|
14
|
+
"category.parent",
|
|
15
|
+
"showtimes",
|
|
16
|
+
"organization",
|
|
17
|
+
"affiliate",
|
|
18
|
+
"tickets",
|
|
19
|
+
];
|
|
20
|
+
export class EvandApiError extends Error {
|
|
21
|
+
status;
|
|
22
|
+
constructor(message, status = null) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "EvandApiError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function asRecord(value) {
|
|
29
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
30
|
+
? value
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
function asString(value) {
|
|
34
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
35
|
+
}
|
|
36
|
+
function asNumber(value) {
|
|
37
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
38
|
+
}
|
|
39
|
+
function truthy(value) {
|
|
40
|
+
return value === true || value === "yes" || value === "true" || value === 1 || value === "1";
|
|
41
|
+
}
|
|
42
|
+
function sleep(ms) {
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
}
|
|
45
|
+
function coverUrl(value) {
|
|
46
|
+
if (!value)
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof value === "string")
|
|
49
|
+
return value || null;
|
|
50
|
+
return value.original ?? null;
|
|
51
|
+
}
|
|
52
|
+
function pickRecord(value) {
|
|
53
|
+
const record = asRecord(value);
|
|
54
|
+
if (!record)
|
|
55
|
+
return null;
|
|
56
|
+
const data = asRecord(record.data);
|
|
57
|
+
return data ?? record;
|
|
58
|
+
}
|
|
59
|
+
function joinUrl(base, path) {
|
|
60
|
+
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
61
|
+
}
|
|
62
|
+
function eventUrl(webBase, slug) {
|
|
63
|
+
return joinUrl(webBase, `events/${encodeURIComponent(slug)}`);
|
|
64
|
+
}
|
|
65
|
+
function organizationUrl(webBase, slug) {
|
|
66
|
+
return joinUrl(webBase, `organizations/${encodeURIComponent(slug)}`);
|
|
67
|
+
}
|
|
68
|
+
function normalizeSocials(raw) {
|
|
69
|
+
if (!raw)
|
|
70
|
+
return {};
|
|
71
|
+
const keyMap = {
|
|
72
|
+
tel: "phone",
|
|
73
|
+
phone: "phone",
|
|
74
|
+
mobile: "phone",
|
|
75
|
+
website: "website",
|
|
76
|
+
instagram: "instagram",
|
|
77
|
+
linkedin: "linkedin",
|
|
78
|
+
twitter: "twitter",
|
|
79
|
+
x: "twitter",
|
|
80
|
+
facebook: "facebook",
|
|
81
|
+
telegram: "telegram",
|
|
82
|
+
telegram_channel: "telegram",
|
|
83
|
+
telegramChannel: "telegram",
|
|
84
|
+
youtube: "youtube",
|
|
85
|
+
github: "github",
|
|
86
|
+
};
|
|
87
|
+
const socials = {};
|
|
88
|
+
for (const [key, rawValue] of Object.entries(raw)) {
|
|
89
|
+
const value = asString(rawValue);
|
|
90
|
+
if (!value || value === "undefined" || value === "null")
|
|
91
|
+
continue;
|
|
92
|
+
const normalizedKey = keyMap[key] ?? key;
|
|
93
|
+
socials[normalizedKey] = value;
|
|
94
|
+
}
|
|
95
|
+
return socials;
|
|
96
|
+
}
|
|
97
|
+
function normalizeOrganizationRef(value, webBase) {
|
|
98
|
+
const record = asRecord(value);
|
|
99
|
+
if (!record)
|
|
100
|
+
return null;
|
|
101
|
+
const data = asRecord(record.data) ?? record;
|
|
102
|
+
const name = asString(data.name);
|
|
103
|
+
const slug = asString(data.slug);
|
|
104
|
+
if (!name && !slug)
|
|
105
|
+
return null;
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
slug,
|
|
109
|
+
url: slug ? organizationUrl(webBase, slug) : null,
|
|
110
|
+
logo: coverUrl(data.logo),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function parsePagination(meta) {
|
|
114
|
+
const metaRecord = asRecord(meta);
|
|
115
|
+
const pagination = asRecord(metaRecord?.pagination);
|
|
116
|
+
const currentPage = asNumber(pagination?.current_page) ?? 1;
|
|
117
|
+
const totalPages = asNumber(pagination?.total_pages);
|
|
118
|
+
return {
|
|
119
|
+
total: asNumber(pagination?.total),
|
|
120
|
+
count: asNumber(pagination?.count),
|
|
121
|
+
per_page: asNumber(pagination?.per_page),
|
|
122
|
+
current_page: currentPage,
|
|
123
|
+
total_pages: totalPages,
|
|
124
|
+
has_more: totalPages !== null ? currentPage < totalPages : false,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function parseEventDate(value, field) {
|
|
128
|
+
if (!value)
|
|
129
|
+
return null;
|
|
130
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
131
|
+
throw new EvandApiError(`Invalid ${field} "${value}". Use YYYY-MM-DD (for example 2026-11-20).`);
|
|
132
|
+
}
|
|
133
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
134
|
+
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
|
|
135
|
+
throw new EvandApiError(`Invalid ${field} "${value}". Use a real calendar date in YYYY-MM-DD format.`);
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
export class EvandClient {
|
|
140
|
+
apiBase;
|
|
141
|
+
webBase;
|
|
142
|
+
timeoutMs;
|
|
143
|
+
debug;
|
|
144
|
+
citiesCache = null;
|
|
145
|
+
citiesPromise = null;
|
|
146
|
+
categoriesCache = null;
|
|
147
|
+
categoriesPromise = null;
|
|
148
|
+
constructor(options = {}) {
|
|
149
|
+
this.apiBase = (options.apiBase ?? process.env.EVAND_API_BASE_URL ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
150
|
+
this.webBase = (options.webBase ?? process.env.EVAND_WEB_BASE_URL ?? DEFAULT_WEB_BASE).replace(/\/+$/, "");
|
|
151
|
+
this.timeoutMs = options.timeoutMs ?? Number(process.env.EVAND_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS);
|
|
152
|
+
this.debug = options.debug ?? process.env.EVAND_MCP_DEBUG === "1";
|
|
153
|
+
}
|
|
154
|
+
log(message, details) {
|
|
155
|
+
if (!this.debug)
|
|
156
|
+
return;
|
|
157
|
+
if (details === undefined) {
|
|
158
|
+
console.error(`[evand-mcp] ${message}`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
console.error(`[evand-mcp] ${message}`, details);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async request(path, params = {}, attempt = 0) {
|
|
165
|
+
const url = new URL(path, `${this.apiBase}/`);
|
|
166
|
+
for (const [key, value] of Object.entries(params)) {
|
|
167
|
+
if (value === undefined || value === null || value === "")
|
|
168
|
+
continue;
|
|
169
|
+
url.searchParams.set(key, String(value));
|
|
170
|
+
}
|
|
171
|
+
const timeoutMs = Number.isFinite(this.timeoutMs)
|
|
172
|
+
? Math.min(60_000, Math.max(1_000, this.timeoutMs))
|
|
173
|
+
: DEFAULT_TIMEOUT_MS;
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
176
|
+
let response;
|
|
177
|
+
try {
|
|
178
|
+
response = await fetch(url, {
|
|
179
|
+
headers: {
|
|
180
|
+
Accept: "application/json",
|
|
181
|
+
"User-Agent": "evand-mcp/0.1.0 (+https://evand.com)",
|
|
182
|
+
},
|
|
183
|
+
signal: controller.signal,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
clearTimeout(timeout);
|
|
188
|
+
const isAbort = controller.signal.aborted;
|
|
189
|
+
if (attempt < 2) {
|
|
190
|
+
this.log(`${isAbort ? "Timeout" : "Network error"} on ${url.pathname}; retrying`);
|
|
191
|
+
await sleep(500 * 2 ** attempt);
|
|
192
|
+
return this.request(path, params, attempt + 1);
|
|
193
|
+
}
|
|
194
|
+
const reason = isAbort ? `timed out after ${timeoutMs}ms` : error.message;
|
|
195
|
+
throw new EvandApiError(`Could not reach the Evand API (${reason}). Try again shortly.`);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
clearTimeout(timeout);
|
|
199
|
+
}
|
|
200
|
+
const text = await response.text();
|
|
201
|
+
let body = null;
|
|
202
|
+
if (text) {
|
|
203
|
+
try {
|
|
204
|
+
body = JSON.parse(text);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
body = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const record = asRecord(body);
|
|
212
|
+
const apiMessage = asString(record?.message);
|
|
213
|
+
const errors = asRecord(record?.errors);
|
|
214
|
+
const validationMessage = errors
|
|
215
|
+
? Object.values(errors)
|
|
216
|
+
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
|
217
|
+
.map((value) => String(value))
|
|
218
|
+
.join("; ")
|
|
219
|
+
: null;
|
|
220
|
+
const message = validationMessage || apiMessage || response.statusText || "Unknown error";
|
|
221
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
222
|
+
if (retryable && attempt < 2) {
|
|
223
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
224
|
+
const delay = Number.isFinite(retryAfter) && retryAfter > 0
|
|
225
|
+
? Math.min(5_000, retryAfter * 1_000)
|
|
226
|
+
: 500 * 2 ** attempt;
|
|
227
|
+
this.log(`HTTP ${response.status} on ${url.pathname}; retrying in ${delay}ms`);
|
|
228
|
+
await sleep(delay);
|
|
229
|
+
return this.request(path, params, attempt + 1);
|
|
230
|
+
}
|
|
231
|
+
throw new EvandApiError(`Evand API error (HTTP ${response.status}): ${message}`, response.status);
|
|
232
|
+
}
|
|
233
|
+
if (body === null) {
|
|
234
|
+
throw new EvandApiError(`Evand API returned an invalid JSON response for ${url.pathname}.`, response.status);
|
|
235
|
+
}
|
|
236
|
+
return body;
|
|
237
|
+
}
|
|
238
|
+
async getCities(forceRefresh = false) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
if (!forceRefresh && this.citiesCache && this.citiesCache.expiresAt > now) {
|
|
241
|
+
return this.citiesCache.data;
|
|
242
|
+
}
|
|
243
|
+
if (!forceRefresh && this.citiesPromise)
|
|
244
|
+
return this.citiesPromise;
|
|
245
|
+
const promise = this.request("/cities", {
|
|
246
|
+
include: "province",
|
|
247
|
+
sort: "eventCount",
|
|
248
|
+
})
|
|
249
|
+
.then((response) => {
|
|
250
|
+
const cities = Array.isArray(response.data) ? response.data : [];
|
|
251
|
+
this.citiesCache = { expiresAt: Date.now() + METADATA_TTL_MS, data: cities };
|
|
252
|
+
return cities;
|
|
253
|
+
})
|
|
254
|
+
.catch((error) => {
|
|
255
|
+
this.citiesPromise = null;
|
|
256
|
+
throw error;
|
|
257
|
+
})
|
|
258
|
+
.finally(() => {
|
|
259
|
+
this.citiesPromise = null;
|
|
260
|
+
});
|
|
261
|
+
this.citiesPromise = promise;
|
|
262
|
+
return promise;
|
|
263
|
+
}
|
|
264
|
+
async getCategories(forceRefresh = false) {
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
if (!forceRefresh && this.categoriesCache && this.categoriesCache.expiresAt > now) {
|
|
267
|
+
return this.categoriesCache.data;
|
|
268
|
+
}
|
|
269
|
+
if (!forceRefresh && this.categoriesPromise)
|
|
270
|
+
return this.categoriesPromise;
|
|
271
|
+
const promise = this.request("/categories", {
|
|
272
|
+
include: "children",
|
|
273
|
+
order: "-1",
|
|
274
|
+
sort: "eventCount",
|
|
275
|
+
})
|
|
276
|
+
.then((response) => {
|
|
277
|
+
const categories = Array.isArray(response.data) ? response.data : [];
|
|
278
|
+
this.categoriesCache = { expiresAt: Date.now() + METADATA_TTL_MS, data: categories };
|
|
279
|
+
return categories;
|
|
280
|
+
})
|
|
281
|
+
.catch((error) => {
|
|
282
|
+
this.categoriesPromise = null;
|
|
283
|
+
throw error;
|
|
284
|
+
})
|
|
285
|
+
.finally(() => {
|
|
286
|
+
this.categoriesPromise = null;
|
|
287
|
+
});
|
|
288
|
+
this.categoriesPromise = promise;
|
|
289
|
+
return promise;
|
|
290
|
+
}
|
|
291
|
+
async resolveFilters(input) {
|
|
292
|
+
const cities = [];
|
|
293
|
+
const cityInputs = (input.city ?? "")
|
|
294
|
+
.split(",")
|
|
295
|
+
.map((value) => value.trim())
|
|
296
|
+
.filter(Boolean);
|
|
297
|
+
if (cityInputs.length > 0) {
|
|
298
|
+
const allCities = await this.getCities();
|
|
299
|
+
for (const cityInput of cityInputs) {
|
|
300
|
+
const city = resolveCity(allCities, cityInput);
|
|
301
|
+
if (!city) {
|
|
302
|
+
throw new EvandApiError(`City "${cityInput}" was not found on Evand. Try one of: ${suggestCities(allCities, cityInput).join(", ")}.`);
|
|
303
|
+
}
|
|
304
|
+
cities.push(city);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
let category = null;
|
|
308
|
+
if (input.category?.trim()) {
|
|
309
|
+
const allCategories = flattenCategories(await this.getCategories());
|
|
310
|
+
category = resolveCategory(allCategories, input.category);
|
|
311
|
+
if (!category) {
|
|
312
|
+
throw new EvandApiError(`Category "${input.category}" was not found on Evand. Try one of: ${suggestCategories(allCategories, input.category).join(", ")}.`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const appliedCity = cities.length === 1 && cities[0]
|
|
316
|
+
? { name: cities[0].name, slug: cities[0].slug }
|
|
317
|
+
: null;
|
|
318
|
+
return {
|
|
319
|
+
cities,
|
|
320
|
+
category,
|
|
321
|
+
appliedCity,
|
|
322
|
+
appliedCategory: category
|
|
323
|
+
? { title: category.title, slug: category.slug, parent: category.parentTitle }
|
|
324
|
+
: null,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async searchEvents(input) {
|
|
328
|
+
const page = Math.max(1, Math.trunc(input.page ?? 1));
|
|
329
|
+
const perPage = Math.min(50, Math.max(1, Math.trunc(input.per_page ?? 12)));
|
|
330
|
+
const from = parseEventDate(input.start_date_from, "start_date_from");
|
|
331
|
+
const to = parseEventDate(input.start_date_to, "start_date_to");
|
|
332
|
+
if (from && to && from > to) {
|
|
333
|
+
throw new EvandApiError("start_date_from must be before or equal to start_date_to.");
|
|
334
|
+
}
|
|
335
|
+
if (input.min_price !== undefined &&
|
|
336
|
+
input.max_price !== undefined &&
|
|
337
|
+
input.min_price > input.max_price) {
|
|
338
|
+
throw new EvandApiError("min_price must be less than or equal to max_price.");
|
|
339
|
+
}
|
|
340
|
+
const resolved = await this.resolveFilters(input);
|
|
341
|
+
const sort = input.sort ?? "nearest";
|
|
342
|
+
const sortParam = sort === "nearest"
|
|
343
|
+
? undefined
|
|
344
|
+
: sort === "trending"
|
|
345
|
+
? "trending"
|
|
346
|
+
: sort === "bestsellers"
|
|
347
|
+
? "bestsellers"
|
|
348
|
+
: sort === "randomness"
|
|
349
|
+
? "randomness"
|
|
350
|
+
: "start_date";
|
|
351
|
+
const response = await this.request("/v2/events", {
|
|
352
|
+
per_page: perPage,
|
|
353
|
+
page,
|
|
354
|
+
q: input.query?.trim() || undefined,
|
|
355
|
+
cities: resolved.cities.length ? resolved.cities.map((city) => city.slug).join(",") : undefined,
|
|
356
|
+
categories: resolved.category?.slug,
|
|
357
|
+
online: input.online === true ? "yes" : input.online === false ? "no" : undefined,
|
|
358
|
+
archive: input.include_past === true ? "yes" : undefined,
|
|
359
|
+
min_price: input.min_price,
|
|
360
|
+
max_price: input.max_price,
|
|
361
|
+
min_start_date: from ?? undefined,
|
|
362
|
+
max_start_date: to ?? undefined,
|
|
363
|
+
sort: sortParam,
|
|
364
|
+
});
|
|
365
|
+
const events = (Array.isArray(response.data) ? response.data : [])
|
|
366
|
+
.map((event) => this.normalizeEventSummary(event))
|
|
367
|
+
.filter((event) => event !== null);
|
|
368
|
+
return {
|
|
369
|
+
filters: {
|
|
370
|
+
query: input.query?.trim() || null,
|
|
371
|
+
city: resolved.appliedCity,
|
|
372
|
+
category: resolved.appliedCategory,
|
|
373
|
+
online: input.online ?? null,
|
|
374
|
+
include_past: input.include_past ?? false,
|
|
375
|
+
price: input.min_price !== undefined || input.max_price !== undefined
|
|
376
|
+
? {
|
|
377
|
+
min: input.min_price ?? null,
|
|
378
|
+
max: input.max_price ?? null,
|
|
379
|
+
currency: "Toman",
|
|
380
|
+
}
|
|
381
|
+
: null,
|
|
382
|
+
start_date: from || to
|
|
383
|
+
? { from: from ?? null, to: to ?? null, timezone: "Asia/Tehran" }
|
|
384
|
+
: null,
|
|
385
|
+
sort,
|
|
386
|
+
},
|
|
387
|
+
pagination: parsePagination(response.meta),
|
|
388
|
+
events,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
async getEvent(slug, options = {}) {
|
|
392
|
+
const includeDescription = options.includeDescription !== false;
|
|
393
|
+
const maxDescriptionChars = Math.min(MAX_DESCRIPTION_CHARS, Math.max(0, Math.trunc(options.maxDescriptionChars ?? 8_000)));
|
|
394
|
+
let eventResponse;
|
|
395
|
+
try {
|
|
396
|
+
eventResponse = await this.request(`/events/${encodeURIComponent(slug)}`, { include: EVENT_INCLUDES.join(",") });
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error instanceof EvandApiError && error.status === 404) {
|
|
400
|
+
throw new EvandApiError(`Event "${slug}" was not found on Evand. Use search_events to find the correct slug, or pass a full https://evand.com/events/<slug> URL.`, 404);
|
|
401
|
+
}
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
if (!eventResponse?.data) {
|
|
405
|
+
throw new EvandApiError(`Event "${slug}" was not found on Evand.`, 404);
|
|
406
|
+
}
|
|
407
|
+
if (!includeDescription || maxDescriptionChars <= 0) {
|
|
408
|
+
return normalizeEventDetail(eventResponse.data, this.webBase, null);
|
|
409
|
+
}
|
|
410
|
+
const descriptionResponse = await this.request(`/events/${encodeURIComponent(slug)}/description`).catch((error) => {
|
|
411
|
+
this.log(`Could not load description for ${slug}`, error.message);
|
|
412
|
+
return { data: [] };
|
|
413
|
+
});
|
|
414
|
+
const description = normalizeDescription(descriptionResponse.data, maxDescriptionChars);
|
|
415
|
+
return normalizeEventDetail(eventResponse.data, this.webBase, description);
|
|
416
|
+
}
|
|
417
|
+
async getSimilarEvents(slug, limit = 6) {
|
|
418
|
+
const perPage = Math.min(12, Math.max(1, Math.trunc(limit)));
|
|
419
|
+
let response;
|
|
420
|
+
try {
|
|
421
|
+
response = await this.request(`/recommendation/events/${encodeURIComponent(slug)}/similar`, { per_page: perPage });
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (error instanceof EvandApiError && error.status === 404) {
|
|
425
|
+
throw new EvandApiError(`Event "${slug}" was not found on Evand, so similar events cannot be loaded. Use search_events to find the correct slug.`, 404);
|
|
426
|
+
}
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
return (Array.isArray(response.data) ? response.data : [])
|
|
430
|
+
.slice(0, perPage)
|
|
431
|
+
.map((event) => this.normalizeSimilarEvent(event))
|
|
432
|
+
.filter((event) => event !== null);
|
|
433
|
+
}
|
|
434
|
+
async listCities(query, limit = 30, includeEmpty = false) {
|
|
435
|
+
const allCities = await this.getCities();
|
|
436
|
+
const needle = query?.trim().toLowerCase() ?? "";
|
|
437
|
+
const filtered = allCities.filter((city) => {
|
|
438
|
+
if (!includeEmpty && (city.events_count ?? 0) <= 0)
|
|
439
|
+
return false;
|
|
440
|
+
if (!needle)
|
|
441
|
+
return true;
|
|
442
|
+
const haystack = [city.name, city.slug, city.province?.data?.name ?? ""]
|
|
443
|
+
.join(" ")
|
|
444
|
+
.toLowerCase();
|
|
445
|
+
return haystack.includes(needle);
|
|
446
|
+
});
|
|
447
|
+
let results = filtered;
|
|
448
|
+
if (needle && results.length === 0) {
|
|
449
|
+
const resolved = resolveCity(allCities, query.trim());
|
|
450
|
+
if (resolved)
|
|
451
|
+
results = [resolved];
|
|
452
|
+
}
|
|
453
|
+
const max = Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
454
|
+
return {
|
|
455
|
+
total: results.length,
|
|
456
|
+
cities: results.slice(0, max).map((city) => ({
|
|
457
|
+
id: city.id,
|
|
458
|
+
name: city.name,
|
|
459
|
+
slug: city.slug,
|
|
460
|
+
events_count: city.events_count ?? 0,
|
|
461
|
+
province: city.province?.data?.name ?? null,
|
|
462
|
+
url: joinUrl(this.webBase, `city/${encodeURIComponent(city.slug)}`),
|
|
463
|
+
})),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
async listCategories(query, limit = 100) {
|
|
467
|
+
const flat = flattenCategories(await this.getCategories());
|
|
468
|
+
const needle = query?.trim().toLowerCase() ?? "";
|
|
469
|
+
let filtered = needle
|
|
470
|
+
? flat.filter((category) => [category.title, category.slug, category.parentTitle ?? ""]
|
|
471
|
+
.join(" ")
|
|
472
|
+
.toLowerCase()
|
|
473
|
+
.includes(needle))
|
|
474
|
+
: flat;
|
|
475
|
+
if (needle && filtered.length === 0) {
|
|
476
|
+
const resolved = resolveCategory(flat, query.trim());
|
|
477
|
+
if (resolved)
|
|
478
|
+
filtered = [resolved];
|
|
479
|
+
}
|
|
480
|
+
const max = Math.min(300, Math.max(1, Math.trunc(limit)));
|
|
481
|
+
return {
|
|
482
|
+
total: filtered.length,
|
|
483
|
+
categories: filtered.slice(0, max).map((category) => ({
|
|
484
|
+
id: category.id,
|
|
485
|
+
title: category.title,
|
|
486
|
+
slug: category.slug,
|
|
487
|
+
parent: category.parentTitle,
|
|
488
|
+
events_count: category.events_count,
|
|
489
|
+
url: joinUrl(this.webBase, `categories/${encodeURIComponent(category.slug)}`),
|
|
490
|
+
})),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
async searchOrganizations(input = {}) {
|
|
494
|
+
const page = Math.max(1, Math.trunc(input.page ?? 1));
|
|
495
|
+
const perPage = Math.min(50, Math.max(1, Math.trunc(input.per_page ?? 12)));
|
|
496
|
+
const sort = input.sort === "bestsellers"
|
|
497
|
+
? "bestsellers"
|
|
498
|
+
: input.sort === "partnerships"
|
|
499
|
+
? "partnerships"
|
|
500
|
+
: "followersCount";
|
|
501
|
+
const response = await this.request("/organizations", {
|
|
502
|
+
q: input.query?.trim() || undefined,
|
|
503
|
+
sort,
|
|
504
|
+
page,
|
|
505
|
+
per_page: perPage,
|
|
506
|
+
});
|
|
507
|
+
return {
|
|
508
|
+
pagination: parsePagination(response.meta),
|
|
509
|
+
organizations: (Array.isArray(response.data) ? response.data : [])
|
|
510
|
+
.map((organization) => this.normalizeOrganizationSummary(organization))
|
|
511
|
+
.filter((organization) => organization !== null),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
async getOrganization(slug, options = {}) {
|
|
515
|
+
let response;
|
|
516
|
+
try {
|
|
517
|
+
response = await this.request(`/organizations/${encodeURIComponent(slug)}`);
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error instanceof EvandApiError && error.status === 404) {
|
|
521
|
+
throw new EvandApiError(`Organization "${slug}" was not found on Evand. Use search_organizations to find the correct slug, or pass a full https://evand.com/organizations/<slug> URL.`, 404);
|
|
522
|
+
}
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
if (!response?.data) {
|
|
526
|
+
throw new EvandApiError(`Organization "${slug}" was not found on Evand.`, 404);
|
|
527
|
+
}
|
|
528
|
+
const includeEvents = options.includeEvents !== false;
|
|
529
|
+
const eventsLimit = Math.min(30, Math.max(1, Math.trunc(options.eventsLimit ?? 12)));
|
|
530
|
+
const maxDescriptionChars = Math.min(20_000, Math.max(0, Math.trunc(options.maxDescriptionChars ?? 4_000)));
|
|
531
|
+
let events = [];
|
|
532
|
+
if (includeEvents) {
|
|
533
|
+
const orgId = asString(response.data.id);
|
|
534
|
+
if (orgId) {
|
|
535
|
+
const eventResponse = await this.request("/v2/events", {
|
|
536
|
+
organization_id: orgId,
|
|
537
|
+
per_page: eventsLimit,
|
|
538
|
+
archive: options.includePastEvents ? "yes" : undefined,
|
|
539
|
+
sort: options.includePastEvents ? "start_date" : undefined,
|
|
540
|
+
}).catch((error) => {
|
|
541
|
+
this.log(`Could not load events for organization ${slug}`, error.message);
|
|
542
|
+
return { data: [] };
|
|
543
|
+
});
|
|
544
|
+
events = (Array.isArray(eventResponse.data) ? eventResponse.data : [])
|
|
545
|
+
.map((event) => this.normalizeEventSummary(event))
|
|
546
|
+
.filter((event) => event !== null);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const summary = this.normalizeOrganizationSummary(response.data, maxDescriptionChars);
|
|
550
|
+
if (!summary) {
|
|
551
|
+
throw new EvandApiError(`Organization "${slug}" could not be parsed.`, 404);
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
...summary,
|
|
555
|
+
events,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
normalizeEventSummary(event) {
|
|
559
|
+
if (!event || typeof event.id !== "number" || !event.slug)
|
|
560
|
+
return null;
|
|
561
|
+
const organization = normalizeOrganizationRef(event.organization, this.webBase);
|
|
562
|
+
const price = normalizePrice(event.minimum_ticket_price ?? event.prices?.data?.min, event.maximum_ticket_price ?? event.prices?.data?.max);
|
|
563
|
+
return {
|
|
564
|
+
id: event.id,
|
|
565
|
+
name: event.name ?? "",
|
|
566
|
+
slug: event.slug,
|
|
567
|
+
url: eventUrl(this.webBase, event.slug),
|
|
568
|
+
start_date: normalizeIsoDate(event.start_date),
|
|
569
|
+
end_date: normalizeIsoDate(event.end_date),
|
|
570
|
+
start_date_tehran: formatTehranDate(event.start_date),
|
|
571
|
+
end_date_tehran: formatTehranDate(event.end_date),
|
|
572
|
+
online: truthy(event.online),
|
|
573
|
+
city: asString(event.city_name),
|
|
574
|
+
address: asString(event.address),
|
|
575
|
+
organization,
|
|
576
|
+
price,
|
|
577
|
+
status: {
|
|
578
|
+
ended: event.ended === true,
|
|
579
|
+
sold_out: event.soldout === true,
|
|
580
|
+
cancelled: event.cancelled === true,
|
|
581
|
+
partnership: asString(event.partnership?.status),
|
|
582
|
+
},
|
|
583
|
+
cover: coverUrl(event.cover),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
normalizeSimilarEvent(event) {
|
|
587
|
+
if (!event || !event.slug)
|
|
588
|
+
return null;
|
|
589
|
+
const price = normalizePrice(event.minimum_ticket_price, event.maximum_ticket_price);
|
|
590
|
+
return {
|
|
591
|
+
id: event.id ?? null,
|
|
592
|
+
name: event.name ?? "",
|
|
593
|
+
slug: event.slug,
|
|
594
|
+
url: eventUrl(this.webBase, event.slug),
|
|
595
|
+
start_date: normalizeIsoDate(event.start_date),
|
|
596
|
+
end_date: normalizeIsoDate(event.end_date),
|
|
597
|
+
start_date_tehran: formatTehranDate(event.start_date),
|
|
598
|
+
end_date_tehran: formatTehranDate(event.end_date),
|
|
599
|
+
online: truthy(event.online),
|
|
600
|
+
city: asString(event.city_name),
|
|
601
|
+
address: null,
|
|
602
|
+
organization: event.organization_slug || event.organization_name
|
|
603
|
+
? {
|
|
604
|
+
name: asString(event.organization_name),
|
|
605
|
+
slug: asString(event.organization_slug),
|
|
606
|
+
url: event.organization_slug ? organizationUrl(this.webBase, event.organization_slug) : null,
|
|
607
|
+
logo: asString(event.organization_logo),
|
|
608
|
+
}
|
|
609
|
+
: null,
|
|
610
|
+
price,
|
|
611
|
+
status: {
|
|
612
|
+
ended: false,
|
|
613
|
+
sold_out: false,
|
|
614
|
+
cancelled: false,
|
|
615
|
+
partnership: null,
|
|
616
|
+
},
|
|
617
|
+
cover: coverUrl(event.cover),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
normalizeOrganizationSummary(organization, descriptionLimit = 400) {
|
|
621
|
+
const slug = asString(organization.slug);
|
|
622
|
+
const name = asString(organization.name);
|
|
623
|
+
if (!slug && !name)
|
|
624
|
+
return null;
|
|
625
|
+
const descriptionHtml = asString(organization.description) ?? asString(organization.striped_description) ?? "";
|
|
626
|
+
const description = htmlToText(descriptionHtml);
|
|
627
|
+
const recommended = organization;
|
|
628
|
+
const recommendedPercent = asNumber(recommended.recommended?.value?.percent_of_recommended);
|
|
629
|
+
const effectiveSlug = slug ?? name;
|
|
630
|
+
return {
|
|
631
|
+
id: asString(organization.id),
|
|
632
|
+
name: name ?? effectiveSlug,
|
|
633
|
+
slug: effectiveSlug,
|
|
634
|
+
url: organizationUrl(this.webBase, effectiveSlug),
|
|
635
|
+
logo: coverUrl(organization.logo),
|
|
636
|
+
cover: coverUrl(organization.cover),
|
|
637
|
+
description: truncateText(description, descriptionLimit).text,
|
|
638
|
+
followers_count: asNumber(organization.followers_count),
|
|
639
|
+
events_count: asNumber(organization.events_count),
|
|
640
|
+
active_events_count: asNumber(organization.active_events_count),
|
|
641
|
+
partnership: asString(organization.partnership?.status),
|
|
642
|
+
recommended_percent: recommendedPercent,
|
|
643
|
+
socials: normalizeSocials(organization.socials),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function normalizeDescriptionSections(raw) {
|
|
648
|
+
const sections = [];
|
|
649
|
+
let truncated = false;
|
|
650
|
+
for (const section of Array.isArray(raw) ? raw : []) {
|
|
651
|
+
const type = asString(section.type) ?? "description";
|
|
652
|
+
const title = asString(section.title);
|
|
653
|
+
const body = htmlToText(section.description) ||
|
|
654
|
+
(typeof section.content === "string" ? htmlToText(section.content) : "");
|
|
655
|
+
const content = section.content;
|
|
656
|
+
let items = null;
|
|
657
|
+
if (Array.isArray(content)) {
|
|
658
|
+
items = content
|
|
659
|
+
.map((item) => {
|
|
660
|
+
if (typeof item === "string" || typeof item === "number") {
|
|
661
|
+
return { name: String(item) };
|
|
662
|
+
}
|
|
663
|
+
const itemRecord = asRecord(item);
|
|
664
|
+
if (!itemRecord)
|
|
665
|
+
return null;
|
|
666
|
+
const normalized = {};
|
|
667
|
+
const preferredKeys = [
|
|
668
|
+
"name",
|
|
669
|
+
"title",
|
|
670
|
+
"position",
|
|
671
|
+
"role",
|
|
672
|
+
"description",
|
|
673
|
+
"image",
|
|
674
|
+
"logo",
|
|
675
|
+
"website",
|
|
676
|
+
"linkedin",
|
|
677
|
+
"instagram",
|
|
678
|
+
"telegram",
|
|
679
|
+
"twitter",
|
|
680
|
+
"facebook",
|
|
681
|
+
"github",
|
|
682
|
+
];
|
|
683
|
+
for (const key of preferredKeys) {
|
|
684
|
+
const value = asString(itemRecord[key]);
|
|
685
|
+
if (!value)
|
|
686
|
+
continue;
|
|
687
|
+
normalized[key] =
|
|
688
|
+
key === "description" ? truncateText(htmlToText(value), 400).text : value;
|
|
689
|
+
}
|
|
690
|
+
return Object.keys(normalized).length > 0 ? normalized : null;
|
|
691
|
+
})
|
|
692
|
+
.filter((item) => item !== null);
|
|
693
|
+
if (items.length === 0)
|
|
694
|
+
items = null;
|
|
695
|
+
}
|
|
696
|
+
if (!title && !body && !items)
|
|
697
|
+
continue;
|
|
698
|
+
sections.push({ type, title, text: body || null, items });
|
|
699
|
+
}
|
|
700
|
+
return { sections, truncated };
|
|
701
|
+
}
|
|
702
|
+
function normalizeDescription(raw, maxChars) {
|
|
703
|
+
try {
|
|
704
|
+
const { sections } = normalizeDescriptionSections(raw);
|
|
705
|
+
const fullText = sections
|
|
706
|
+
.map((section) => {
|
|
707
|
+
const parts = [];
|
|
708
|
+
if (section.title)
|
|
709
|
+
parts.push(section.title);
|
|
710
|
+
if (section.text)
|
|
711
|
+
parts.push(section.text);
|
|
712
|
+
if (section.items) {
|
|
713
|
+
for (const item of section.items) {
|
|
714
|
+
const label = item.name ?? item.title ?? null;
|
|
715
|
+
const position = item.position ?? item.role ?? null;
|
|
716
|
+
if (label)
|
|
717
|
+
parts.push(position ? `${label} — ${position}` : label);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return parts.join("\n");
|
|
721
|
+
})
|
|
722
|
+
.filter(Boolean)
|
|
723
|
+
.join("\n\n");
|
|
724
|
+
const budgeted = truncateText(fullText, maxChars);
|
|
725
|
+
let remaining = maxChars;
|
|
726
|
+
const trimmedSections = [];
|
|
727
|
+
for (const section of sections) {
|
|
728
|
+
if (remaining <= 0)
|
|
729
|
+
break;
|
|
730
|
+
const heading = section.title ?? "";
|
|
731
|
+
const body = section.text ?? "";
|
|
732
|
+
const combined = [heading, body].filter(Boolean).join("\n");
|
|
733
|
+
if (combined.length <= remaining) {
|
|
734
|
+
trimmedSections.push(section);
|
|
735
|
+
remaining -= combined.length;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (remaining > 20) {
|
|
739
|
+
trimmedSections.push({
|
|
740
|
+
...section,
|
|
741
|
+
title: heading.length <= remaining ? heading : null,
|
|
742
|
+
text: truncateText(body || heading, Math.max(20, remaining - Math.min(heading.length, 100))).text,
|
|
743
|
+
items: null,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
remaining = 0;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
text: budgeted.text,
|
|
751
|
+
sections: trimmedSections,
|
|
752
|
+
truncated: budgeted.truncated,
|
|
753
|
+
error: null,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
return {
|
|
758
|
+
text: "",
|
|
759
|
+
sections: [],
|
|
760
|
+
truncated: false,
|
|
761
|
+
error: error.message,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function normalizeEventDetail(event, webBase, description) {
|
|
766
|
+
if (!event || typeof event.id !== "number" || !event.slug) {
|
|
767
|
+
throw new EvandApiError("Evand returned an event without an id or slug.");
|
|
768
|
+
}
|
|
769
|
+
const organizationRaw = pickRecord(event.organization);
|
|
770
|
+
const cityRaw = pickRecord(event.city);
|
|
771
|
+
const categoryRaw = pickRecord(event.category);
|
|
772
|
+
const parentCategoryRaw = categoryRaw ? pickRecord(categoryRaw.parent) : null;
|
|
773
|
+
const typeRaw = pickRecord(event.type);
|
|
774
|
+
const pricesRaw = pickRecord(event.prices);
|
|
775
|
+
const tagsRaw = Array.isArray(event.tags?.data) ? event.tags.data : [];
|
|
776
|
+
const ticketsRaw = Array.isArray(event.tickets?.data) ? event.tickets.data : [];
|
|
777
|
+
const showtimesRaw = Array.isArray(event.showtimes?.data) ? event.showtimes.data : [];
|
|
778
|
+
const circlesRaw = Array.isArray(event.circles) ? event.circles : [];
|
|
779
|
+
const minPrice = asNumber(pricesRaw?.min) ?? asNumber(event.minimum_ticket_price);
|
|
780
|
+
const maxPrice = asNumber(pricesRaw?.max) ?? asNumber(event.maximum_ticket_price);
|
|
781
|
+
const price = normalizePrice(minPrice, maxPrice, event.is_free === true);
|
|
782
|
+
const tickets = ticketsRaw
|
|
783
|
+
.filter((ticket) => {
|
|
784
|
+
if (!ticket)
|
|
785
|
+
return false;
|
|
786
|
+
if (ticket.active === undefined || ticket.active === null)
|
|
787
|
+
return true;
|
|
788
|
+
return truthy(ticket.active);
|
|
789
|
+
})
|
|
790
|
+
.map((ticket) => ({
|
|
791
|
+
id: ticket.id,
|
|
792
|
+
title: asString(ticket.title) ?? "Ticket",
|
|
793
|
+
type: asString(ticket.type) ?? "normal",
|
|
794
|
+
price: asNumber(ticket.price) ?? 0,
|
|
795
|
+
price_display: normalizePrice(asNumber(ticket.price) ?? 0, asNumber(ticket.price) ?? 0).display,
|
|
796
|
+
available_count: asNumber(ticket.available_count),
|
|
797
|
+
capacity: asNumber(ticket.capacity),
|
|
798
|
+
min_count: asNumber(ticket.min_count),
|
|
799
|
+
max_count: asNumber(ticket.max_count),
|
|
800
|
+
active: truthy(ticket.active),
|
|
801
|
+
showtime_id: asNumber(ticket.showtime_id),
|
|
802
|
+
sale_start: normalizeIsoDate(ticket.start_date),
|
|
803
|
+
sale_end: normalizeIsoDate(ticket.end_date),
|
|
804
|
+
description: htmlToText(ticket.description),
|
|
805
|
+
login_required: truthy(ticket.login_required),
|
|
806
|
+
}));
|
|
807
|
+
const availableTickets = tickets
|
|
808
|
+
.map((ticket) => ticket.available_count)
|
|
809
|
+
.filter((value) => value !== null);
|
|
810
|
+
const ticketPrices = tickets.map((ticket) => ticket.price).filter((value) => Number.isFinite(value));
|
|
811
|
+
const showtimes = showtimesRaw.map((showtime) => ({
|
|
812
|
+
id: showtime.id,
|
|
813
|
+
label: asString(showtime.label) ?? "Showtime",
|
|
814
|
+
start_date: normalizeIsoDate(showtime.start_date),
|
|
815
|
+
end_date: normalizeIsoDate(showtime.end_date),
|
|
816
|
+
start_date_tehran: formatTehranDate(showtime.start_date),
|
|
817
|
+
end_date_tehran: formatTehranDate(showtime.end_date),
|
|
818
|
+
description: htmlToText(showtime.description),
|
|
819
|
+
has_purchasable_ticket: showtime.has_purchasable_ticket === true,
|
|
820
|
+
}));
|
|
821
|
+
const categoryTitle = categoryRaw ? asString(categoryRaw.title) : null;
|
|
822
|
+
const parentCategoryTitle = parentCategoryRaw ? asString(parentCategoryRaw.title) : null;
|
|
823
|
+
const categoryPath = [parentCategoryTitle, categoryTitle].filter((value) => Boolean(value));
|
|
824
|
+
const organization = organizationRaw
|
|
825
|
+
? {
|
|
826
|
+
name: asString(organizationRaw.name),
|
|
827
|
+
slug: asString(organizationRaw.slug),
|
|
828
|
+
url: asString(organizationRaw.slug)
|
|
829
|
+
? organizationUrl(webBase, asString(organizationRaw.slug))
|
|
830
|
+
: null,
|
|
831
|
+
logo: coverUrl(organizationRaw.logo),
|
|
832
|
+
description: truncateText(htmlToText(asString(organizationRaw.description)), 600).text || null,
|
|
833
|
+
followers_count: asNumber(organizationRaw.followers_count),
|
|
834
|
+
events_count: asNumber(organizationRaw.events_count),
|
|
835
|
+
socials: normalizeSocials(organizationRaw.socials),
|
|
836
|
+
}
|
|
837
|
+
: null;
|
|
838
|
+
const latitude = asNumber(event.latitude);
|
|
839
|
+
const longitude = asNumber(event.longitude);
|
|
840
|
+
const mapUrl = latitude !== null && longitude !== null && (latitude !== 0 || longitude !== 0)
|
|
841
|
+
? `https://www.google.com/maps?q=${latitude},${longitude}`
|
|
842
|
+
: null;
|
|
843
|
+
return {
|
|
844
|
+
id: event.id,
|
|
845
|
+
name: event.name ?? "",
|
|
846
|
+
slug: event.slug,
|
|
847
|
+
url: eventUrl(webBase, event.slug),
|
|
848
|
+
start_date: normalizeIsoDate(event.start_date),
|
|
849
|
+
end_date: normalizeIsoDate(event.end_date),
|
|
850
|
+
start_date_tehran: formatTehranDate(event.start_date),
|
|
851
|
+
end_date_tehran: formatTehranDate(event.end_date),
|
|
852
|
+
online: truthy(event.online),
|
|
853
|
+
city: asString(cityRaw?.name) ?? asString(event.city_name),
|
|
854
|
+
address: asString(event.address),
|
|
855
|
+
organization,
|
|
856
|
+
price,
|
|
857
|
+
status: {
|
|
858
|
+
ended: event.ended === true,
|
|
859
|
+
sold_out: event.soldout === true,
|
|
860
|
+
cancelled: event.cancelled === true,
|
|
861
|
+
partnership: asString(event.partnership?.status),
|
|
862
|
+
},
|
|
863
|
+
cover: coverUrl(event.cover),
|
|
864
|
+
website: asString(event.website),
|
|
865
|
+
socials: normalizeSocials(event.socials),
|
|
866
|
+
category: categoryRaw && categoryTitle
|
|
867
|
+
? {
|
|
868
|
+
id: asNumber(categoryRaw.id) ?? 0,
|
|
869
|
+
title: categoryTitle,
|
|
870
|
+
slug: asString(categoryRaw.slug) ?? "",
|
|
871
|
+
path: categoryPath,
|
|
872
|
+
}
|
|
873
|
+
: null,
|
|
874
|
+
type: typeRaw ? asString(typeRaw.title) : null,
|
|
875
|
+
tags: tagsRaw
|
|
876
|
+
.map((tag) => asString(tag?.name))
|
|
877
|
+
.filter((tag) => Boolean(tag)),
|
|
878
|
+
location: {
|
|
879
|
+
online: truthy(event.online),
|
|
880
|
+
city: asString(cityRaw?.name) ?? asString(event.city_name),
|
|
881
|
+
city_slug: asString(cityRaw?.slug),
|
|
882
|
+
address: asString(event.address),
|
|
883
|
+
latitude,
|
|
884
|
+
longitude,
|
|
885
|
+
map_url: mapUrl,
|
|
886
|
+
},
|
|
887
|
+
showtimes,
|
|
888
|
+
tickets,
|
|
889
|
+
ticket_summary: {
|
|
890
|
+
active_tickets: tickets.length,
|
|
891
|
+
available_tickets: availableTickets.length > 0
|
|
892
|
+
? availableTickets.reduce((sum, count) => sum + count, 0)
|
|
893
|
+
: null,
|
|
894
|
+
min_price: ticketPrices.length ? Math.min(...ticketPrices) : price.min,
|
|
895
|
+
max_price: ticketPrices.length ? Math.max(...ticketPrices) : price.max,
|
|
896
|
+
sold_out: event.soldout === true || (tickets.length > 0 && tickets.every((ticket) => ticket.available_count === 0)),
|
|
897
|
+
},
|
|
898
|
+
circles: circlesRaw
|
|
899
|
+
.filter((circle) => asString(circle?.name) || asString(circle?.slug))
|
|
900
|
+
.map((circle) => ({
|
|
901
|
+
name: asString(circle.name) ?? asString(circle.slug) ?? "",
|
|
902
|
+
slug: asString(circle.slug) ?? asString(circle.name) ?? "",
|
|
903
|
+
logo: coverUrl(circle.logo),
|
|
904
|
+
})),
|
|
905
|
+
timing_status: asString(event.timing_status),
|
|
906
|
+
published: asString(event.published),
|
|
907
|
+
refund_policy: truncateText(htmlToText(event.refund_policy), 2_000).text || null,
|
|
908
|
+
description,
|
|
909
|
+
registration_url: eventUrl(webBase, event.slug),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/** Turns a slug, a relative /events/... path, or a full Evand URL into a slug. */
|
|
913
|
+
export function parseEventSlug(input) {
|
|
914
|
+
const trimmed = input.trim();
|
|
915
|
+
if (!trimmed)
|
|
916
|
+
throw new EvandApiError("An event slug or URL is required.");
|
|
917
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
918
|
+
let url;
|
|
919
|
+
try {
|
|
920
|
+
url = new URL(trimmed);
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
throw new EvandApiError(`"${input}" is not a valid URL.`);
|
|
924
|
+
}
|
|
925
|
+
const hostname = url.hostname.toLowerCase();
|
|
926
|
+
if (hostname !== "evand.com" && !hostname.endsWith(".evand.com")) {
|
|
927
|
+
throw new EvandApiError(`Expected a URL on evand.com, got ${url.hostname}.`);
|
|
928
|
+
}
|
|
929
|
+
const match = url.pathname.match(/^\/events\/([^/]+)\/?$/);
|
|
930
|
+
if (!match?.[1]) {
|
|
931
|
+
throw new EvandApiError(`The URL does not point to an Evand event page: ${input}`);
|
|
932
|
+
}
|
|
933
|
+
return safeDecodeURIComponent(match[1]);
|
|
934
|
+
}
|
|
935
|
+
return safeDecodeURIComponent(trimmed
|
|
936
|
+
.replace(/^\/+/, "")
|
|
937
|
+
.replace(/^events\//i, "")
|
|
938
|
+
.replace(/\/+$/, ""));
|
|
939
|
+
}
|
|
940
|
+
function safeDecodeURIComponent(value) {
|
|
941
|
+
try {
|
|
942
|
+
return decodeURIComponent(value);
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return value;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
//# sourceMappingURL=evand.js.map
|