A social knowledge tool for researchers built on ATProto
1import {
2 ICardQueryRepository,
3 CardQueryOptions,
4 UrlCardQueryResultDTO,
5 CollectionCardQueryResultDTO,
6 UrlCardViewDTO,
7 PaginatedQueryResult,
8 CardSortField,
9 SortOrder,
10 LibraryForUrlDTO,
11 NoteCardForUrlDTO,
12} from '../../domain/ICardQueryRepository';
13import { CardTypeEnum } from '../../domain/value-objects/CardType';
14import { InMemoryCardRepository } from './InMemoryCardRepository';
15import { InMemoryCollectionRepository } from './InMemoryCollectionRepository';
16import { Card } from '../../domain/Card';
17import { CollectionId } from '../../domain/value-objects/CollectionId';
18import { CuratorId } from '../../domain/value-objects/CuratorId';
19
20export class InMemoryCardQueryRepository implements ICardQueryRepository {
21 constructor(
22 private cardRepository: InMemoryCardRepository,
23 private collectionRepository: InMemoryCollectionRepository,
24 ) {}
25
26 async getUrlCardsOfUser(
27 userId: string,
28 options: CardQueryOptions,
29 ): Promise<PaginatedQueryResult<UrlCardQueryResultDTO>> {
30 try {
31 // Get all cards and filter by user's library membership
32 const allCards = this.cardRepository.getAllCards();
33 const userCards = allCards
34 .filter(
35 (card) =>
36 card.isUrlCard &&
37 card.isInLibrary(CuratorId.create(userId).unwrap()),
38 )
39 .map((card) => this.cardToUrlCardQueryResult(card));
40
41 // Sort cards
42 const sortedCards = this.sortCards(
43 userCards,
44 options.sortBy,
45 options.sortOrder,
46 );
47
48 // Apply pagination
49 const startIndex = (options.page - 1) * options.limit;
50 const endIndex = startIndex + options.limit;
51 const paginatedCards = sortedCards.slice(startIndex, endIndex);
52
53 return {
54 items: paginatedCards,
55 totalCount: userCards.length,
56 hasMore: endIndex < userCards.length,
57 };
58 } catch (error) {
59 throw new Error(
60 `Failed to query URL cards: ${error instanceof Error ? error.message : String(error)}`,
61 );
62 }
63 }
64
65 private sortCards(
66 cards: UrlCardQueryResultDTO[],
67 sortBy: CardSortField,
68 sortOrder: SortOrder,
69 ): UrlCardQueryResultDTO[] {
70 const sorted = [...cards].sort((a, b) => {
71 let comparison = 0;
72
73 switch (sortBy) {
74 case CardSortField.CREATED_AT:
75 comparison = a.createdAt.getTime() - b.createdAt.getTime();
76 break;
77 case CardSortField.UPDATED_AT:
78 comparison = a.updatedAt.getTime() - b.updatedAt.getTime();
79 break;
80 case CardSortField.LIBRARY_COUNT:
81 comparison = a.libraryCount - b.libraryCount;
82 break;
83 default:
84 comparison = 0;
85 }
86
87 return sortOrder === SortOrder.DESC ? -comparison : comparison;
88 });
89
90 return sorted;
91 }
92
93 private cardToUrlCardQueryResult(card: Card): UrlCardQueryResultDTO {
94 if (!card.isUrlCard || !card.content.urlContent) {
95 throw new Error('Card is not a URL card');
96 }
97
98 // Find collections this card belongs to by querying the collection repository
99 const allCollections = this.collectionRepository.getAllCollections();
100 const collections: { id: string; name: string; authorId: string }[] = [];
101
102 for (const collection of allCollections) {
103 if (
104 collection.cardIds.some(
105 (cardId) => cardId.getStringValue() === card.cardId.getStringValue(),
106 )
107 ) {
108 collections.push({
109 id: collection.collectionId.getStringValue(),
110 name: collection.name.value,
111 authorId: collection.authorId.value,
112 });
113 }
114 }
115
116 // Find note cards with matching URL
117 const allCards = this.cardRepository.getAllCards();
118 const noteCard = allCards.find(
119 (c) => c.type.value === 'NOTE' && c.url?.value === card.url?.value,
120 );
121
122 const note = noteCard
123 ? {
124 id: noteCard.cardId.getStringValue(),
125 text: noteCard.content.noteContent?.text || '',
126 }
127 : undefined;
128
129 return {
130 id: card.cardId.getStringValue(),
131 type: CardTypeEnum.URL,
132 url: card.content.urlContent.url.value,
133 cardContent: {
134 url: card.content.urlContent.url.value,
135 title: card.content.urlContent.metadata?.title,
136 description: card.content.urlContent.metadata?.description,
137 author: card.content.urlContent.metadata?.author,
138 thumbnailUrl: card.content.urlContent.metadata?.imageUrl,
139 },
140 libraryCount: this.getLibraryCountForCard(card.cardId.getStringValue()),
141 createdAt: card.createdAt,
142 updatedAt: card.updatedAt,
143 collections,
144 note,
145 };
146 }
147
148 private getLibraryCountForCard(cardId: string): number {
149 const card = this.cardRepository.getStoredCard({
150 getStringValue: () => cardId,
151 } as any);
152 return card ? card.libraryMembershipCount : 0;
153 }
154
155 async getCardsInCollection(
156 collectionId: string,
157 options: CardQueryOptions,
158 ): Promise<PaginatedQueryResult<CollectionCardQueryResultDTO>> {
159 try {
160 // Get the collection from the repository
161 const collectionIdObj = CollectionId.createFromString(collectionId);
162 if (collectionIdObj.isErr()) {
163 throw new Error(`Invalid collection ID: ${collectionId}`);
164 }
165
166 const collectionResult = await this.collectionRepository.findById(
167 collectionIdObj.value,
168 );
169 if (collectionResult.isErr()) {
170 throw collectionResult.error;
171 }
172
173 const collection = collectionResult.value;
174 if (!collection) {
175 return {
176 items: [],
177 totalCount: 0,
178 hasMore: false,
179 };
180 }
181
182 // Get cards that are in this collection
183 const allCards = this.cardRepository.getAllCards();
184 const collectionCardIds = new Set(
185 collection.cardIds.map((id) => id.getStringValue()),
186 );
187 const collectionCards = allCards
188 .filter(
189 (card) =>
190 collectionCardIds.has(card.cardId.getStringValue()) &&
191 card.isUrlCard,
192 )
193 .map((card) =>
194 this.toCollectionCardQueryResult(this.cardToUrlCardQueryResult(card)),
195 );
196
197 // Sort cards
198 const sortedCards = this.sortCollectionCards(
199 collectionCards,
200 options.sortBy,
201 options.sortOrder,
202 );
203
204 // Apply pagination
205 const startIndex = (options.page - 1) * options.limit;
206 const endIndex = startIndex + options.limit;
207 const paginatedCards = sortedCards.slice(startIndex, endIndex);
208
209 return {
210 items: paginatedCards,
211 totalCount: collectionCards.length,
212 hasMore: endIndex < collectionCards.length,
213 };
214 } catch (error) {
215 throw new Error(
216 `Failed to query collection cards: ${error instanceof Error ? error.message : String(error)}`,
217 );
218 }
219 }
220
221 private sortCollectionCards(
222 cards: CollectionCardQueryResultDTO[],
223 sortBy: CardSortField,
224 sortOrder: SortOrder,
225 ): CollectionCardQueryResultDTO[] {
226 const sorted = [...cards].sort((a, b) => {
227 let comparison = 0;
228
229 switch (sortBy) {
230 case CardSortField.CREATED_AT:
231 comparison = a.createdAt.getTime() - b.createdAt.getTime();
232 break;
233 case CardSortField.UPDATED_AT:
234 comparison = a.updatedAt.getTime() - b.updatedAt.getTime();
235 break;
236 case CardSortField.LIBRARY_COUNT:
237 comparison = a.libraryCount - b.libraryCount;
238 break;
239 default:
240 comparison = 0;
241 }
242
243 return sortOrder === SortOrder.DESC ? -comparison : comparison;
244 });
245
246 return sorted;
247 }
248
249 private toCollectionCardQueryResult(
250 card: UrlCardQueryResultDTO,
251 ): CollectionCardQueryResultDTO {
252 return {
253 id: card.id,
254 type: CardTypeEnum.URL,
255 url: card.url,
256 cardContent: card.cardContent,
257 libraryCount: card.libraryCount,
258 createdAt: card.createdAt,
259 updatedAt: card.updatedAt,
260 note: card.note,
261 };
262 }
263
264 async getUrlCardView(cardId: string): Promise<UrlCardViewDTO | null> {
265 const allCards = this.cardRepository.getAllCards();
266 const card = allCards.find((c) => c.cardId.getStringValue() === cardId);
267 if (!card || !card.isUrlCard) {
268 return null;
269 }
270
271 const urlCardResult = this.cardToUrlCardQueryResult(card);
272
273 // Get library memberships from the card itself
274 const libraries = card.libraryMemberships.map((membership) => ({
275 userId: membership.curatorId.value,
276 }));
277
278 // Find note cards with matching URL
279 const noteCard = allCards.find(
280 (c) => c.type.value === 'NOTE' && c.url?.value === card.url?.value,
281 );
282
283 const note = noteCard
284 ? {
285 id: noteCard.cardId.getStringValue(),
286 text: noteCard.content.noteContent?.text || '',
287 }
288 : undefined;
289
290 return {
291 ...urlCardResult,
292 libraries,
293 note,
294 };
295 }
296
297 async getLibrariesForCard(cardId: string): Promise<string[]> {
298 const allCards = this.cardRepository.getAllCards();
299 const card = allCards.find((c) => c.cardId.getStringValue() === cardId);
300
301 if (!card) {
302 return [];
303 }
304
305 return card.libraryMemberships.map(
306 (membership) => membership.curatorId.value,
307 );
308 }
309
310 async getLibrariesForUrl(
311 url: string,
312 options: CardQueryOptions,
313 ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> {
314 try {
315 // Get all cards and filter by URL
316 const allCards = this.cardRepository.getAllCards();
317 const urlCards = allCards.filter(
318 (card) => card.isUrlCard && card.url?.value === url,
319 );
320
321 // Create library entries for each card
322 const libraries: LibraryForUrlDTO[] = [];
323 for (const card of urlCards) {
324 for (const membership of card.libraryMemberships) {
325 libraries.push({
326 userId: membership.curatorId.value,
327 cardId: card.cardId.getStringValue(),
328 });
329 }
330 }
331
332 // Sort libraries (by userId for consistency)
333 const sortedLibraries = this.sortLibraries(
334 libraries,
335 options.sortBy,
336 options.sortOrder,
337 );
338
339 // Apply pagination
340 const startIndex = (options.page - 1) * options.limit;
341 const endIndex = startIndex + options.limit;
342 const paginatedLibraries = sortedLibraries.slice(startIndex, endIndex);
343
344 return {
345 items: paginatedLibraries,
346 totalCount: libraries.length,
347 hasMore: endIndex < libraries.length,
348 };
349 } catch (error) {
350 throw new Error(
351 `Failed to query libraries for URL: ${error instanceof Error ? error.message : String(error)}`,
352 );
353 }
354 }
355
356 private sortLibraries(
357 libraries: LibraryForUrlDTO[],
358 sortBy: CardSortField,
359 sortOrder: SortOrder,
360 ): LibraryForUrlDTO[] {
361 const sorted = [...libraries].sort((a, b) => {
362 let comparison = 0;
363
364 switch (sortBy) {
365 case CardSortField.CREATED_AT:
366 case CardSortField.UPDATED_AT:
367 case CardSortField.LIBRARY_COUNT:
368 // For libraries, we'll sort by userId as a fallback
369 comparison = a.userId.localeCompare(b.userId);
370 break;
371 default:
372 comparison = a.userId.localeCompare(b.userId);
373 }
374
375 return sortOrder === SortOrder.DESC ? -comparison : comparison;
376 });
377
378 return sorted;
379 }
380
381 async getNoteCardsForUrl(
382 url: string,
383 options: CardQueryOptions,
384 ): Promise<PaginatedQueryResult<NoteCardForUrlDTO>> {
385 try {
386 // Get all note cards with the specified URL
387 const allCards = this.cardRepository.getAllCards();
388 const noteCards = allCards
389 .filter((card) => card.isNoteCard && card.url?.value === url)
390 .map((card) => ({
391 id: card.cardId.getStringValue(),
392 note: card.content.noteContent?.text || '',
393 authorId: card.curatorId.value,
394 createdAt: card.createdAt,
395 updatedAt: card.updatedAt,
396 }));
397
398 // Sort note cards
399 const sortedNotes = this.sortNoteCards(
400 noteCards,
401 options.sortBy,
402 options.sortOrder,
403 );
404
405 // Apply pagination
406 const startIndex = (options.page - 1) * options.limit;
407 const endIndex = startIndex + options.limit;
408 const paginatedNotes = sortedNotes.slice(startIndex, endIndex);
409
410 return {
411 items: paginatedNotes,
412 totalCount: noteCards.length,
413 hasMore: endIndex < noteCards.length,
414 };
415 } catch (error) {
416 throw new Error(
417 `Failed to query note cards for URL: ${error instanceof Error ? error.message : String(error)}`,
418 );
419 }
420 }
421
422 private sortNoteCards(
423 notes: NoteCardForUrlDTO[],
424 sortBy: CardSortField,
425 sortOrder: SortOrder,
426 ): NoteCardForUrlDTO[] {
427 const sorted = [...notes].sort((a, b) => {
428 let comparison = 0;
429
430 switch (sortBy) {
431 case CardSortField.CREATED_AT:
432 comparison = a.createdAt.getTime() - b.createdAt.getTime();
433 break;
434 case CardSortField.UPDATED_AT:
435 comparison = a.updatedAt.getTime() - b.updatedAt.getTime();
436 break;
437 case CardSortField.LIBRARY_COUNT:
438 // For note cards, sort by authorId as fallback
439 comparison = a.authorId.localeCompare(b.authorId);
440 break;
441 default:
442 comparison = 0;
443 }
444
445 return sortOrder === SortOrder.DESC ? -comparison : comparison;
446 });
447
448 return sorted;
449 }
450
451 clear(): void {
452 // No separate state to clear
453 }
454}