confluence-exporter 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +18 -0
- package/.github/copilot-instructions.md +3 -0
- package/.github/prompts/analyze.prompt.md +101 -0
- package/.github/prompts/clarify.prompt.md +158 -0
- package/.github/prompts/constitution.prompt.md +73 -0
- package/.github/prompts/implement.prompt.md +56 -0
- package/.github/prompts/plan.prompt.md +50 -0
- package/.github/prompts/specify.prompt.md +21 -0
- package/.github/prompts/tasks.prompt.md +69 -0
- package/LICENSE +21 -0
- package/README.md +332 -0
- package/agents.md +1174 -0
- package/dist/api.d.ts +73 -0
- package/dist/api.js +387 -0
- package/dist/api.js.map +1 -0
- package/dist/commands/download.command.d.ts +18 -0
- package/dist/commands/download.command.js +257 -0
- package/dist/commands/download.command.js.map +1 -0
- package/dist/commands/executor.d.ts +22 -0
- package/dist/commands/executor.js +52 -0
- package/dist/commands/executor.js.map +1 -0
- package/dist/commands/help.command.d.ts +8 -0
- package/dist/commands/help.command.js +68 -0
- package/dist/commands/help.command.js.map +1 -0
- package/dist/commands/index.command.d.ts +14 -0
- package/dist/commands/index.command.js +95 -0
- package/dist/commands/index.command.js.map +1 -0
- package/dist/commands/index.d.ts +13 -0
- package/dist/commands/index.js +13 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/plan.command.d.ts +54 -0
- package/dist/commands/plan.command.js +272 -0
- package/dist/commands/plan.command.js.map +1 -0
- package/dist/commands/registry.d.ts +12 -0
- package/dist/commands/registry.js +32 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/transform.command.d.ts +69 -0
- package/dist/commands/transform.command.js +951 -0
- package/dist/commands/transform.command.js.map +1 -0
- package/dist/commands/types.d.ts +12 -0
- package/dist/commands/types.js +5 -0
- package/dist/commands/types.js.map +1 -0
- package/dist/commands/update.command.d.ts +10 -0
- package/dist/commands/update.command.js +201 -0
- package/dist/commands/update.command.js.map +1 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +110 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +52 -0
- package/dist/logger.js.map +1 -0
- package/dist/types.d.ts +167 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +56 -0
- package/dist/utils.js +178 -0
- package/dist/utils.js.map +1 -0
- package/eslint.config.js +29 -0
- package/jest.config.cjs +25 -0
- package/migrate-meta.js +132 -0
- package/package.json +53 -0
- package/src/api.ts +469 -0
- package/src/commands/download.command.ts +324 -0
- package/src/commands/executor.ts +62 -0
- package/src/commands/help.command.ts +72 -0
- package/src/commands/index.command.ts +111 -0
- package/src/commands/index.ts +14 -0
- package/src/commands/plan.command.ts +318 -0
- package/src/commands/registry.ts +39 -0
- package/src/commands/transform.command.ts +1103 -0
- package/src/commands/types.ts +16 -0
- package/src/commands/update.command.ts +229 -0
- package/src/constants.ts +0 -0
- package/src/index.ts +120 -0
- package/src/logger.ts +60 -0
- package/src/test.sh +66 -0
- package/src/types.ts +176 -0
- package/src/utils.ts +204 -0
- package/tests/commands/README.md +123 -0
- package/tests/commands/download.command.test.ts +8 -0
- package/tests/commands/help.command.test.ts +8 -0
- package/tests/commands/index.command.test.ts +8 -0
- package/tests/commands/plan.command.test.ts +15 -0
- package/tests/commands/transform.command.test.ts +8 -0
- package/tests/fixtures/_index.yaml +38 -0
- package/tests/fixtures/mock-pages.ts +62 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +45 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Confluence API client
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Page,
|
|
7
|
+
PageMetadata,
|
|
8
|
+
PaginatedResponse,
|
|
9
|
+
ConfluenceConfig,
|
|
10
|
+
User,
|
|
11
|
+
PageResponse,
|
|
12
|
+
RawPage,
|
|
13
|
+
ListPagesResponse,
|
|
14
|
+
ChildPageResponse,
|
|
15
|
+
ChildPagesResponse,
|
|
16
|
+
AttachmentResult,
|
|
17
|
+
AttachmentResponse
|
|
18
|
+
} from './types.js';
|
|
19
|
+
import { logger } from './logger.js';
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { readFileSync, writeFile } from 'fs';
|
|
23
|
+
|
|
24
|
+
export class ConfluenceApi {
|
|
25
|
+
private baseUrl: string;
|
|
26
|
+
private authHeader: string;
|
|
27
|
+
private userCache: Map<string, User> = new Map();
|
|
28
|
+
private cacheFile: string;
|
|
29
|
+
|
|
30
|
+
// Default network timeout for fetch requests (ms)
|
|
31
|
+
private DEFAULT_TIMEOUT = 15000;
|
|
32
|
+
|
|
33
|
+
constructor(config: ConfluenceConfig) {
|
|
34
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
35
|
+
const credentials = Buffer.from(`${config.username}:${config.password}`).toString('base64');
|
|
36
|
+
this.authHeader = `Basic ${credentials}`;
|
|
37
|
+
|
|
38
|
+
// Set up user cache file
|
|
39
|
+
this.cacheFile = path.join(config.outputDir, '_user_cache.json');
|
|
40
|
+
this.loadCache();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load user cache from filesystem
|
|
45
|
+
*/
|
|
46
|
+
private loadCache(): void {
|
|
47
|
+
try {
|
|
48
|
+
const data = readFileSync(this.cacheFile, 'utf8');
|
|
49
|
+
const parsed = JSON.parse(data);
|
|
50
|
+
this.userCache = new Map(Object.entries(parsed));
|
|
51
|
+
logger.debug(`Loaded ${this.userCache.size} users from cache`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// Ignore if file doesn't exist or is invalid
|
|
54
|
+
logger.debug('No existing user cache found, starting fresh');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Save user cache to filesystem
|
|
60
|
+
*/
|
|
61
|
+
private async saveCache(): Promise<void> {
|
|
62
|
+
try {
|
|
63
|
+
const obj = Object.fromEntries(this.userCache);
|
|
64
|
+
await fs.writeFile(this.cacheFile, JSON.stringify(obj, null, 2));
|
|
65
|
+
logger.debug(`Saved ${this.userCache.size} users to cache`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger.warn('Failed to save user cache:', err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Helper to perform fetch with a timeout using AbortController
|
|
73
|
+
*/
|
|
74
|
+
private async fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, timeoutMs?: number): Promise<Response> {
|
|
75
|
+
const timeout = typeof timeoutMs === 'number' ? timeoutMs : this.DEFAULT_TIMEOUT;
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
78
|
+
try {
|
|
79
|
+
logger.debug('fetchWithTimeout ->', input, 'timeoutMs=', timeout);
|
|
80
|
+
const res = await fetch(input, { ...(init || {}), signal: controller.signal } as RequestInit);
|
|
81
|
+
logger.debug('fetchWithTimeout <-', input, 'status=', res.status);
|
|
82
|
+
return res;
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch a page with its content
|
|
90
|
+
*/
|
|
91
|
+
async getPage(pageId: string): Promise<Page> {
|
|
92
|
+
const url = `${this.baseUrl}/rest/api/content/${pageId}?expand=body.storage,version,history.lastUpdated`;
|
|
93
|
+
|
|
94
|
+
const response = await this.fetchWithTimeout(url, {
|
|
95
|
+
headers: {
|
|
96
|
+
'Authorization': this.authHeader,
|
|
97
|
+
'Accept': 'application/json'
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
throw new Error(`Failed to fetch page ${pageId}: ${response.status} ${response.statusText}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const data = await response.json() as PageResponse;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id: data.id,
|
|
109
|
+
title: data.title,
|
|
110
|
+
body: data.body?.storage?.value || '',
|
|
111
|
+
version: data.version?.number,
|
|
112
|
+
parentId: data.ancestors?.[data.ancestors.length - 1]?.id,
|
|
113
|
+
modifiedDate: data.version?.when || data.history?.lastUpdated?.when
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* List all pages in a space
|
|
119
|
+
*/
|
|
120
|
+
async listPages(spaceKey: string, start: number = 0, limit: number = 100): Promise<PaginatedResponse<Page>> {
|
|
121
|
+
const url = `${this.baseUrl}/rest/api/content?spaceKey=${spaceKey}&type=page&expand=body.storage,version,history.lastUpdated,ancestors&start=${start}&limit=${limit}`;
|
|
122
|
+
|
|
123
|
+
const response = await this.fetchWithTimeout(url, {
|
|
124
|
+
headers: {
|
|
125
|
+
'Authorization': this.authHeader,
|
|
126
|
+
'Accept': 'application/json'
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw new Error(`Failed to list pages (${url}): ${response.status} ${response.statusText}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const data = await response.json() as ListPagesResponse;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
results: data.results.map((item: RawPage) => ({
|
|
138
|
+
id: item.id,
|
|
139
|
+
title: item.title,
|
|
140
|
+
body: item.body?.storage?.value || '',
|
|
141
|
+
version: item.version?.number,
|
|
142
|
+
parentId: item.ancestors?.[item.ancestors.length - 1]?.id,
|
|
143
|
+
modifiedDate: item.version?.when || item.history?.lastUpdated?.when
|
|
144
|
+
})),
|
|
145
|
+
start: data.start,
|
|
146
|
+
limit: data.limit,
|
|
147
|
+
size: data.size,
|
|
148
|
+
_links: data._links
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Search pages using CQL query (metadata only, no body content)
|
|
154
|
+
* Useful for finding pages modified after a specific date
|
|
155
|
+
*/
|
|
156
|
+
async searchPages(cql: string, pageSize: number = 100): Promise<PageMetadata[]> {
|
|
157
|
+
const results: PageMetadata[] = [];
|
|
158
|
+
let start = 0;
|
|
159
|
+
|
|
160
|
+
while (true) {
|
|
161
|
+
const url = `${this.baseUrl}/rest/api/content/search?cql=${encodeURIComponent(cql)}&expand=version,history.lastUpdated,ancestors&start=${start}&limit=${pageSize}`;
|
|
162
|
+
|
|
163
|
+
logger.debug('downloadAttachment: metadata URL ->', url);
|
|
164
|
+
const response = await this.fetchWithTimeout(url, {
|
|
165
|
+
headers: {
|
|
166
|
+
'Authorization': this.authHeader,
|
|
167
|
+
'Accept': 'application/json'
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new Error(`Failed to search pages (${url}): ${response.status} ${response.statusText}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const data = await response.json() as ListPagesResponse;
|
|
176
|
+
|
|
177
|
+
for (const item of data.results) {
|
|
178
|
+
results.push({
|
|
179
|
+
id: item.id,
|
|
180
|
+
title: item.title,
|
|
181
|
+
version: item.version?.number,
|
|
182
|
+
parentId: item.ancestors?.[item.ancestors.length - 1]?.id,
|
|
183
|
+
modifiedDate: item.version?.when || item.history?.lastUpdated?.when
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check if there are more pages
|
|
188
|
+
if (data.results.length < pageSize || !data._links?.next) {
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
start += pageSize;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* List pages metadata only (no body content) - more efficient for checking updates
|
|
200
|
+
*/
|
|
201
|
+
async listPagesMetadata(spaceKey: string, start: number = 0, limit: number = 100): Promise<PaginatedResponse<PageMetadata>> {
|
|
202
|
+
const url = `${this.baseUrl}/rest/api/content?spaceKey=${spaceKey}&type=page&expand=version,history.lastUpdated,ancestors&start=${start}&limit=${limit}`;
|
|
203
|
+
|
|
204
|
+
const response = await this.fetchWithTimeout(url, {
|
|
205
|
+
headers: {
|
|
206
|
+
'Authorization': this.authHeader,
|
|
207
|
+
'Accept': 'application/json'
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
throw new Error(`Failed to list pages metadata (${url}): ${response.status} ${response.statusText}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const data = await response.json() as ListPagesResponse;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
results: data.results.map((item: RawPage) => ({
|
|
219
|
+
id: item.id,
|
|
220
|
+
title: item.title,
|
|
221
|
+
version: item.version?.number,
|
|
222
|
+
parentId: item.ancestors?.[item.ancestors.length - 1]?.id,
|
|
223
|
+
modifiedDate: item.version?.when || item.history?.lastUpdated?.when
|
|
224
|
+
})),
|
|
225
|
+
start: data.start,
|
|
226
|
+
limit: data.limit,
|
|
227
|
+
size: data.size,
|
|
228
|
+
_links: data._links
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Fetch all pages metadata from a space (handles pagination) - no body content
|
|
234
|
+
*/
|
|
235
|
+
async *getAllPagesMetadata(spaceKey: string, pageSize: number = 100): AsyncGenerator<PageMetadata & { apiPageNumber: number }> {
|
|
236
|
+
let start = 0;
|
|
237
|
+
const limit = pageSize;
|
|
238
|
+
let apiPageNumber = 1;
|
|
239
|
+
|
|
240
|
+
while (true) {
|
|
241
|
+
const response = await this.listPagesMetadata(spaceKey, start, limit);
|
|
242
|
+
|
|
243
|
+
for (const page of response.results) {
|
|
244
|
+
yield { ...page, apiPageNumber };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check if there are more pages
|
|
248
|
+
if (response.results.length < limit || !response._links?.next) {
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
start += limit;
|
|
253
|
+
apiPageNumber++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Fetch all pages from a space (handles pagination)
|
|
259
|
+
*/
|
|
260
|
+
async *getAllPages(spaceKey: string, pageSize: number = 25, startFrom: number = 0): AsyncGenerator<Page & { apiPageNumber: number }> {
|
|
261
|
+
let start = startFrom;
|
|
262
|
+
const limit = pageSize;
|
|
263
|
+
let apiPageNumber = Math.floor(startFrom / pageSize) + 1;
|
|
264
|
+
|
|
265
|
+
while (true) {
|
|
266
|
+
const response = await this.listPages(spaceKey, start, limit);
|
|
267
|
+
|
|
268
|
+
for (const page of response.results) {
|
|
269
|
+
yield { ...page, apiPageNumber };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check if there are more pages
|
|
273
|
+
if (response.results.length < limit || !response._links?.next) {
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
start += limit;
|
|
278
|
+
apiPageNumber++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get a page by its title in a space
|
|
284
|
+
*/
|
|
285
|
+
async getPageByTitle(spaceKey: string, title: string): Promise<Page | null> {
|
|
286
|
+
const url = `${this.baseUrl}/rest/api/content?spaceKey=${spaceKey}&title=${encodeURIComponent(title)}&expand=body.storage,version`;
|
|
287
|
+
|
|
288
|
+
const response = await this.fetchWithTimeout(url, {
|
|
289
|
+
headers: {
|
|
290
|
+
'Authorization': this.authHeader,
|
|
291
|
+
'Accept': 'application/json'
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
logger.warn(`Failed to fetch page by title "${title}": ${response.status}`);
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const data = await response.json() as ListPagesResponse;
|
|
301
|
+
|
|
302
|
+
if (data.results.length === 0) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const page = data.results[0];
|
|
307
|
+
return {
|
|
308
|
+
id: page.id,
|
|
309
|
+
title: page.title,
|
|
310
|
+
body: page.body?.storage?.value || '',
|
|
311
|
+
version: page.version?.number,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get child pages of a parent page
|
|
317
|
+
*/
|
|
318
|
+
async getChildPages(pageId: string): Promise<Page[]> {
|
|
319
|
+
const url = `${this.baseUrl}/rest/api/content/${pageId}/child/page?expand=version`;
|
|
320
|
+
|
|
321
|
+
const response = await fetch(url, {
|
|
322
|
+
headers: {
|
|
323
|
+
'Authorization': this.authHeader,
|
|
324
|
+
'Accept': 'application/json'
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
logger.warn(`Failed to fetch child pages for ${pageId}: ${response.status}`);
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const data = await response.json() as ChildPagesResponse;
|
|
334
|
+
|
|
335
|
+
return data.results.map(child => ({
|
|
336
|
+
id: child.id,
|
|
337
|
+
title: child.title,
|
|
338
|
+
body: '', // Don't fetch body for child page lists
|
|
339
|
+
version: child.version?.number,
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Download an attachment from a page
|
|
345
|
+
*/
|
|
346
|
+
async downloadAttachment(pageId: string, filename: string): Promise<Buffer | null> {
|
|
347
|
+
try {
|
|
348
|
+
// First, get the attachment metadata to get the download URL
|
|
349
|
+
const url = `${this.baseUrl}/rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}`;
|
|
350
|
+
|
|
351
|
+
const response = await this.fetchWithTimeout(url, {
|
|
352
|
+
headers: {
|
|
353
|
+
'Authorization': this.authHeader,
|
|
354
|
+
'Accept': 'application/json'
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
logger.warn(`Failed to fetch attachment metadata for ${filename}: ${response.status}`);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const data = await response.json() as AttachmentResponse;
|
|
364
|
+
logger.debug('downloadAttachment: metadata results length ->', data.results.length);
|
|
365
|
+
|
|
366
|
+
if (data.results.length === 0) {
|
|
367
|
+
logger.warn(`Attachment not found: ${filename}`);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Download the actual file
|
|
372
|
+
const downloadUrl = `${this.baseUrl}${data.results[0]._links.download}`;
|
|
373
|
+
logger.debug('downloadAttachment: download URL ->', downloadUrl);
|
|
374
|
+
const downloadResponse = await this.fetchWithTimeout(downloadUrl, {
|
|
375
|
+
headers: {
|
|
376
|
+
'Authorization': this.authHeader
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if (!downloadResponse.ok) {
|
|
381
|
+
logger.warn(`Failed to download attachment ${filename}: ${downloadResponse.status}`);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const arrayBuffer = await downloadResponse.arrayBuffer();
|
|
386
|
+
return Buffer.from(arrayBuffer);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
logger.warn(`Error downloading attachment ${filename}:`, error);
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Get user information by username (with caching)
|
|
395
|
+
*/
|
|
396
|
+
async getUserByUsername(username: string): Promise<User | null> {
|
|
397
|
+
// Check cache first
|
|
398
|
+
const cached = this.userCache.get(username);
|
|
399
|
+
if (cached) {
|
|
400
|
+
return cached;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const url = `${this.baseUrl}/rest/api/user?username=${encodeURIComponent(username)}&expand=details.personal`;
|
|
405
|
+
|
|
406
|
+
const response = await this.fetchWithTimeout(url, {
|
|
407
|
+
headers: {
|
|
408
|
+
'Authorization': this.authHeader,
|
|
409
|
+
'Accept': 'application/json'
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
if (!response.ok) {
|
|
414
|
+
logger.warn(`Failed to fetch user ${username}: ${response.status}`);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const data = await response.json() as User;
|
|
419
|
+
|
|
420
|
+
// Cache the result
|
|
421
|
+
this.userCache.set(username, data);
|
|
422
|
+
await this.saveCache();
|
|
423
|
+
|
|
424
|
+
return data;
|
|
425
|
+
} catch (error) {
|
|
426
|
+
logger.warn(`Error fetching user ${username}:`, error);
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get user information by user key (with caching)
|
|
433
|
+
*/
|
|
434
|
+
async getUserByKey(userKey: string): Promise<User | null> {
|
|
435
|
+
// Check cache first
|
|
436
|
+
const cacheKey = `key:${userKey}`;
|
|
437
|
+
const cached = this.userCache.get(cacheKey);
|
|
438
|
+
if (cached) {
|
|
439
|
+
return cached;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const url = `${this.baseUrl}/rest/api/user?key=${encodeURIComponent(userKey)}&expand=details.personal`;
|
|
444
|
+
|
|
445
|
+
const response = await this.fetchWithTimeout(url, {
|
|
446
|
+
headers: {
|
|
447
|
+
'Authorization': this.authHeader,
|
|
448
|
+
'Accept': 'application/json'
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
if (!response.ok) {
|
|
453
|
+
logger.warn(`Failed to fetch user by key ${userKey}: ${response.status}`);
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const data = await response.json() as User;
|
|
458
|
+
|
|
459
|
+
// Cache the result
|
|
460
|
+
this.userCache.set(cacheKey, data);
|
|
461
|
+
await this.saveCache();
|
|
462
|
+
|
|
463
|
+
return data;
|
|
464
|
+
} catch (error) {
|
|
465
|
+
logger.warn(`Error fetching user by key ${userKey}:`, error);
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|