A social knowledge tool for researchers built on ATProto

Merge pull request #191 from cosmik-network/development

Development

authored by Wesley Finck and committed by GitHub b7603a4d 8f64a594

Changed files
+282 -4
.vscode
src
modules
cards
infrastructure
repositories
query-services
tests
webapp
app
bookmarklet
+1 -1
.vscode/settings.json
··· 1 1 { 2 2 "jest.runMode": "on-demand" 3 - } 3 + }
+5 -1
src/modules/cards/infrastructure/repositories/query-services/UrlCardQueryService.ts
··· 359 359 options: CardQueryOptions, 360 360 ): Promise<PaginatedQueryResult<LibraryForUrlDTO>> { 361 361 try { 362 - const { page, limit } = options; 362 + const { page, limit, sortBy, sortOrder } = options; 363 363 const offset = (page - 1) * limit; 364 + 365 + // Build the sort order 366 + const orderDirection = sortOrder === SortOrder.ASC ? asc : desc; 364 367 365 368 // Get all URL cards with this URL and their library memberships 366 369 const librariesQuery = this.db ··· 376 379 .from(libraryMemberships) 377 380 .innerJoin(cards, eq(libraryMemberships.cardId, cards.id)) 378 381 .where(and(eq(cards.url, url), eq(cards.type, CardTypeEnum.URL))) 382 + .orderBy(orderDirection(this.getSortColumn(sortBy))) 379 383 .limit(limit) 380 384 .offset(offset); 381 385
+276
src/modules/cards/tests/infrastructure/DrizzleCardQueryRepository.getLibrariesForUrl.integration.test.ts
··· 15 15 import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; 16 16 import { createTestSchema } from '../test-utils/createTestSchema'; 17 17 import { CardTypeEnum } from '../../domain/value-objects/CardType'; 18 + import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 18 19 19 20 describe('DrizzleCardQueryRepository - getLibrariesForUrl', () => { 20 21 let container: StartedPostgreSqlContainer; ··· 281 282 // Should return empty since card is not in any library 282 283 expect(result.items).toHaveLength(0); 283 284 expect(result.totalCount).toBe(0); 285 + }); 286 + }); 287 + 288 + describe('sorting', () => { 289 + it('should sort by createdAt in descending order by default', async () => { 290 + const testUrl = 'https://example.com/sort-test'; 291 + const url = URL.create(testUrl).unwrap(); 292 + 293 + // Create cards with different creation times 294 + const card1 = new CardBuilder() 295 + .withCuratorId(curator1.value) 296 + .withType(CardTypeEnum.URL) 297 + .withUrl(url) 298 + .buildOrThrow(); 299 + 300 + await new Promise((resolve) => setTimeout(resolve, 1000)); 301 + const card2 = new CardBuilder() 302 + .withCuratorId(curator2.value) 303 + .withType(CardTypeEnum.URL) 304 + .withUrl(url) 305 + .buildOrThrow(); 306 + 307 + await new Promise((resolve) => setTimeout(resolve, 1000)); 308 + const card3 = new CardBuilder() 309 + .withCuratorId(curator3.value) 310 + .withType(CardTypeEnum.URL) 311 + .withUrl(url) 312 + .buildOrThrow(); 313 + 314 + card1.addToLibrary(curator1); 315 + card2.addToLibrary(curator2); 316 + card3.addToLibrary(curator3); 317 + 318 + // Save cards with slight delays to ensure different timestamps 319 + await cardRepository.save(card1); 320 + await new Promise((resolve) => setTimeout(resolve, 10)); 321 + await cardRepository.save(card2); 322 + await new Promise((resolve) => setTimeout(resolve, 10)); 323 + await cardRepository.save(card3); 324 + 325 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 326 + page: 1, 327 + limit: 10, 328 + sortBy: CardSortField.CREATED_AT, 329 + sortOrder: SortOrder.DESC, 330 + }); 331 + 332 + expect(result.items).toHaveLength(3); 333 + 334 + // Should be sorted by creation time, newest first 335 + const cardIds = result.items.map((lib) => lib.card.id); 336 + expect(cardIds[0]).toBe(card3.cardId.getStringValue()); // Most recent 337 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Middle 338 + expect(cardIds[2]).toBe(card1.cardId.getStringValue()); // Oldest 339 + }); 340 + 341 + it('should sort by createdAt in ascending order when specified', async () => { 342 + const testUrl = 'https://example.com/sort-asc-test'; 343 + const url = URL.create(testUrl).unwrap(); 344 + 345 + // Create cards with different creation times 346 + const card1 = new CardBuilder() 347 + .withCuratorId(curator1.value) 348 + .withType(CardTypeEnum.URL) 349 + .withUrl(url) 350 + .buildOrThrow(); 351 + 352 + const card2 = new CardBuilder() 353 + .withCuratorId(curator2.value) 354 + .withType(CardTypeEnum.URL) 355 + .withUrl(url) 356 + .buildOrThrow(); 357 + 358 + card1.addToLibrary(curator1); 359 + card2.addToLibrary(curator2); 360 + 361 + // Save cards with slight delay to ensure different timestamps 362 + await cardRepository.save(card1); 363 + await new Promise((resolve) => setTimeout(resolve, 10)); 364 + await cardRepository.save(card2); 365 + 366 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 367 + page: 1, 368 + limit: 10, 369 + sortBy: CardSortField.CREATED_AT, 370 + sortOrder: SortOrder.ASC, 371 + }); 372 + 373 + expect(result.items).toHaveLength(2); 374 + 375 + // Should be sorted by creation time, oldest first 376 + const cardIds = result.items.map((lib) => lib.card.id); 377 + expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Oldest 378 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Newest 379 + }); 380 + 381 + it('should sort by updatedAt in descending order', async () => { 382 + const testUrl = 'https://example.com/sort-updated-test'; 383 + const url = URL.create(testUrl).unwrap(); 384 + 385 + // Create cards 386 + const card1 = new CardBuilder() 387 + .withCuratorId(curator1.value) 388 + .withType(CardTypeEnum.URL) 389 + .withUrl(url) 390 + .buildOrThrow(); 391 + 392 + const card2 = new CardBuilder() 393 + .withCuratorId(curator2.value) 394 + .withType(CardTypeEnum.URL) 395 + .withUrl(url) 396 + .buildOrThrow(); 397 + 398 + card1.addToLibrary(curator1); 399 + card2.addToLibrary(curator2); 400 + 401 + // Save cards 402 + await cardRepository.save(card1); 403 + await cardRepository.save(card2); 404 + 405 + // Update card1 to have a more recent updatedAt 406 + await new Promise((resolve) => setTimeout(resolve, 1000)); 407 + card1.markAsPublished( 408 + PublishedRecordId.create({ 409 + uri: 'at://did:plc:publishedrecord1', 410 + cid: 'bafyreicpublishedrecord1', 411 + }), 412 + ); 413 + await cardRepository.save(card1); // This should update the updatedAt timestamp 414 + 415 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 416 + page: 1, 417 + limit: 10, 418 + sortBy: CardSortField.UPDATED_AT, 419 + sortOrder: SortOrder.DESC, 420 + }); 421 + 422 + expect(result.items).toHaveLength(2); 423 + 424 + // card1 should be first since it was updated more recently 425 + const cardIds = result.items.map((lib) => lib.card.id); 426 + expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Most recently updated 427 + expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Less recently updated 428 + }); 429 + 430 + it('should sort by libraryCount in descending order', async () => { 431 + const testUrl = 'https://example.com/sort-library-count-test'; 432 + const url = URL.create(testUrl).unwrap(); 433 + 434 + // Create cards 435 + const card1 = new CardBuilder() 436 + .withCuratorId(curator1.value) 437 + .withType(CardTypeEnum.URL) 438 + .withUrl(url) 439 + .buildOrThrow(); 440 + 441 + const card2 = new CardBuilder() 442 + .withCuratorId(curator2.value) 443 + .withType(CardTypeEnum.URL) 444 + .withUrl(url) 445 + .buildOrThrow(); 446 + 447 + const card3 = new CardBuilder() 448 + .withCuratorId(curator3.value) 449 + .withType(CardTypeEnum.URL) 450 + .withUrl(url) 451 + .buildOrThrow(); 452 + 453 + // Add cards to libraries with different counts 454 + card1.addToLibrary(curator1); 455 + 456 + card2.addToLibrary(curator2); 457 + card2.addToLibrary(curator1); // card2 has 2 library memberships 458 + 459 + card3.addToLibrary(curator3); 460 + card3.addToLibrary(curator1); // card3 has 3 library memberships 461 + card3.addToLibrary(curator2); 462 + 463 + await cardRepository.save(card1); 464 + await cardRepository.save(card2); 465 + await cardRepository.save(card3); 466 + 467 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 468 + page: 1, 469 + limit: 10, 470 + sortBy: CardSortField.LIBRARY_COUNT, 471 + sortOrder: SortOrder.DESC, 472 + }); 473 + 474 + // Should return all library memberships, but sorted by the card's library count 475 + expect(result.items.length).toBeGreaterThan(0); 476 + 477 + // Group by card ID to check sorting 478 + const cardGroups = new Map<string, any[]>(); 479 + result.items.forEach((item) => { 480 + const cardId = item.card.id; 481 + if (!cardGroups.has(cardId)) { 482 + cardGroups.set(cardId, []); 483 + } 484 + cardGroups.get(cardId)!.push(item); 485 + }); 486 + 487 + // Get the first occurrence of each card to check library count ordering 488 + const uniqueCards = Array.from(cardGroups.entries()).map( 489 + ([cardId, items]) => ({ 490 + cardId, 491 + libraryCount: items[0]!.card.libraryCount, 492 + }), 493 + ); 494 + 495 + // Should be sorted by library count descending 496 + for (let i = 0; i < uniqueCards.length - 1; i++) { 497 + expect(uniqueCards[i]!.libraryCount).toBeGreaterThanOrEqual( 498 + uniqueCards[i + 1]!.libraryCount, 499 + ); 500 + } 501 + }); 502 + 503 + it('should sort by libraryCount in ascending order when specified', async () => { 504 + const testUrl = 'https://example.com/sort-library-count-asc-test'; 505 + const url = URL.create(testUrl).unwrap(); 506 + 507 + // Create cards with different library counts 508 + const card1 = new CardBuilder() 509 + .withCuratorId(curator1.value) 510 + .withType(CardTypeEnum.URL) 511 + .withUrl(url) 512 + .buildOrThrow(); 513 + 514 + const card2 = new CardBuilder() 515 + .withCuratorId(curator2.value) 516 + .withType(CardTypeEnum.URL) 517 + .withUrl(url) 518 + .buildOrThrow(); 519 + 520 + // card1 has 1 library membership, card2 has 2 521 + card1.addToLibrary(curator1); 522 + card2.addToLibrary(curator2); 523 + card2.addToLibrary(curator1); 524 + 525 + await cardRepository.save(card1); 526 + await cardRepository.save(card2); 527 + 528 + const result = await queryRepository.getLibrariesForUrl(testUrl, { 529 + page: 1, 530 + limit: 10, 531 + sortBy: CardSortField.LIBRARY_COUNT, 532 + sortOrder: SortOrder.ASC, 533 + }); 534 + 535 + expect(result.items.length).toBeGreaterThan(0); 536 + 537 + // Group by card ID and check ascending order 538 + const cardGroups = new Map<string, any[]>(); 539 + result.items.forEach((item) => { 540 + const cardId = item.card.id; 541 + if (!cardGroups.has(cardId)) { 542 + cardGroups.set(cardId, []); 543 + } 544 + cardGroups.get(cardId)!.push(item); 545 + }); 546 + 547 + const uniqueCards = Array.from(cardGroups.entries()).map( 548 + ([cardId, items]) => ({ 549 + cardId, 550 + libraryCount: items[0]!.card.libraryCount, 551 + }), 552 + ); 553 + 554 + // Should be sorted by library count ascending 555 + for (let i = 0; i < uniqueCards.length - 1; i++) { 556 + expect(uniqueCards[i]!.libraryCount).toBeLessThanOrEqual( 557 + uniqueCards[i + 1]!.libraryCount, 558 + ); 559 + } 284 560 }); 285 561 }); 286 562
-2
src/webapp/app/bookmarklet/page.tsx
··· 15 15 Anchor, 16 16 CopyButton, 17 17 } from '@mantine/core'; 18 - import { useState } from 'react'; 19 - import { BiInfoCircle } from 'react-icons/bi'; 20 18 import SembleLogo from '@/assets/semble-logo.svg'; 21 19 import Link from 'next/link'; 22 20