From 48fd77c46bea84936558ba22e2612e7fe1deceb0 Mon Sep 17 00:00:00 2001 From: Timothy Atapagra Date: Fri, 21 Nov 2025 18:05:55 -0500 Subject: [PATCH] Om 18 arc 010 reading progress highlights (#18) * feat(queue): refactor Redis config in the queue module to utilize a single URL for both local and Docker envs * feat(api-nest): implement sentinel-based reading progress tracking, deprecate library item reading progress - Introduced a new ReadingProgress module to manage user reading positions using sentinel-based tracking, enhancing content versioning capabilities. - Updated GraphQL schema to include new types and inputs for reading progress, replacing deprecated fields related to scroll percentages. - Refactored LibraryItem entity to remove obsolete reading progress fields and added a content hash for version tracking. - Enhanced the LibraryResolver and related services to support the new reading progress functionality, ensuring backward compatibility. - Implemented comprehensive tests for the new reading progress features, validating functionality across various scenarios. * feat(api-nest, web-vite): enhance highlighting features and UI components - Introduced robust anchored selectors for highlights, allowing multi-strategy text positioning through JSONB storage in the database. - Updated GraphQL schema to include new fields for selectors and content versioning in highlight entities. - Refactored highlight-related services and resolvers to support the new selectors functionality, ensuring backward compatibility. - Enhanced the HighlightSidebar and NotebookModal components for improved user interaction and note-taking capabilities. - Implemented comprehensive tests for the new highlighting features, validating functionality across various scenarios. * refactor(api-nest): replace string injection tokens with symbols for improved type safety - Introduced a new `injection-tokens.ts` file to define repository injection tokens as Symbols, enhancing type safety and preventing token collisions. - Updated the `HighlightService`, `LabelService`, `LibraryService`, and `ReadingProgressService` to use the new Symbol-based tokens for dependency injection. - Refactored the `RepositoriesModule` to provide the new Symbol tokens, ensuring consistent usage across the application. * feat(api-nest): implement DataLoader service for efficient batching of GraphQL queries - Introduced a new DataLoaderService to batch and cache GraphQL queries, addressing N+1 query issues for labels and highlights. - Updated the GraphqlModule to provide DataLoaderFactory, enabling request-scoped DataLoader instances. - Refactored LibraryResolver to utilize DataLoader for fetching labels, improving performance and reducing database load. - Enhanced entity-label and highlight repositories with batch fetching methods to support DataLoader functionality. - Added reusable GraphQL fragments for labels, highlights, and library items to streamline query structure and maintainability. * chore: add ESLint, Prettier, and commitlint configurations for improved code quality - Introduced a base ESLint configuration to enforce consistent coding standards across the codebase. - Added lint-staged configuration to run ESLint and Prettier on staged files before commits. - Created a Prettier ignore file to exclude unnecessary files from formatting. - Configured commitlint to enforce conventional commit messages, allowing detailed messages while maintaining type safety. - Updated package.json scripts to include linting commands for easier development workflow. - Enhanced ESLint configurations in specific packages to align with the base settings while allowing for custom rules. * Update packages/api-nest/src/highlight/entities/highlight.entity.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update packages/web-vite/vite.config.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update packages/web-vite/vite.config.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update packages/web-vite/src/components/HighlightSidebar.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * refactor(api-nest): improve code formatting and consistency across GraphQL module and library resolver * Update packages/api-nest/src/database/seeds/library-items.seed.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat(api-nest): integrate graphql-scalars for enhanced JSON handling in highlights - Added graphql-scalars package to support JSON scalar type in GraphQL schema. - Updated Highlight entity and resolver to utilize JSON type for selectors, improving data structure and validation. - Refactored highlight input and output types to accommodate new JSON handling, ensuring compatibility with existing data. - Enhanced database module to include structured logging for better query performance tracking. - Improved highlight service to handle selectors as objects directly, streamlining highlight creation and updates. - Updated ReaderPage component to manage selectors as objects, enhancing user experience and data consistency. * chore(web-vite): update ESLint and Prettier configurations for improved code quality - Removed redundant Prettier commands from lint-staged configuration for TypeScript files. - Integrated simple-import-sort plugin into ESLint configuration to enforce import sorting. - Updated package.json to include simple-import-sort plugin dependency. - Enhanced ESLint rules to include simple-import-sort for better code organization. - Refactored various components and pages to ensure consistent import order and formatting. * Update packages/api-nest/src/database/database.module.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update packages/api-nest/src/highlight/entities/highlight-selector.interface.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * style(web-vite): enhance HighlightSidebar and ReaderPage styles for improved UI - Updated HighlightSidebar.css to add color-coded backgrounds for quotes and improved hover effects. - Modified ReaderPage.css to adjust layout properties, including padding, width, and flexbox settings for better responsiveness. - Enhanced various button styles and input fields for a more consistent user experience across the application. * chore(web-vite): refine ESLint and Prettier configurations for improved code quality - Updated lint-staged configuration to include simple-import-sort rules for TypeScript and JSX files. - Removed the simple-import-sort plugin from ESLint configuration as it is no longer needed. - Ensured consistent formatting and linting across JavaScript and TypeScript files. * feat: Add total_sentinels column to library_item for reading progress calculation - Created migration to add total_sentinels column to omnivore.library_item. - Added index for efficient queries on total_sentinels. - Updated LibraryItemCard component to use readingProgressPercent from item. - Modified GraphQL client and fragments to include totalSentinels in reading progress input. - Updated LibraryPage to handle reading progress updates and label management. - Enhanced ReaderPage to track and update reading progress based on sentinels. - Removed deprecated deploy script and associated hooks. * feat: update DataLoader service to use AuthenticatedRequest for user context and improve GraphQL context handling * feat: update highlight selectors to follow W3C Web Annotation Data Model * chore: refine ESLint and Prettier configurations for improved code quality - Updated ESLint rules for TypeScript to issue warnings for empty interfaces and usage of 'any'. - Modified lint-staged configuration to streamline JavaScript and JSX file handling. - Enhanced database module for consistent connection options and improved logging. - Added detailed comments and improved type handling in ReaderPage and LibraryPage components. - Updated styles in HighlightSidebar for better visual consistency. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .eslintrc.base.json | 118 ++ .husky/commit-msg | 4 + .husky/pre-commit | 5 + .lintstagedrc.json | 15 + .prettierignore | 42 + .prettierrc | 4 +- commitlint.config.js | 16 + package.json | 86 +- packages/api-nest/.eslintrc.json | 8 + packages/api-nest/package.json | 3 + packages/api-nest/schema.graphql | 106 +- packages/api-nest/src/app/app.module.ts | 4 + packages/api-nest/src/config/test.config.ts | 2 + .../api-nest/src/database/database.module.ts | 123 +- .../src/database/seeds/library-items.seed.ts | 28 +- .../src/graphql/dataloader.service.ts | 152 +++ .../api-nest/src/graphql/graphql.module.ts | 64 +- .../highlight/dto/highlight-inputs.type.ts | 53 +- .../src/highlight/dto/highlight.type.ts | 39 +- .../entities/highlight-selector.interface.ts | 90 ++ .../highlight/entities/highlight.entity.ts | 44 +- .../src/highlight/highlight.resolver.ts | 5 +- .../src/highlight/highlight.service.ts | 42 +- packages/api-nest/src/label/label.service.ts | 7 +- .../src/library/dto/library-inputs.type.ts | 47 +- .../src/library/dto/library-item.type.ts | 59 +- .../library/entities/library-item.entity.ts | 33 +- .../api-nest/src/library/library.resolver.ts | 99 +- .../api-nest/src/library/library.service.ts | 108 +- .../processors/content-processor.service.ts | 9 +- .../dto/reading-progress-inputs.type.ts | 46 + .../dto/reading-progress.type.ts | 45 + .../entities/reading-progress.entity.ts | 95 ++ .../reading-progress.module.ts | 19 + .../reading-progress.resolver.ts | 81 ++ .../reading-progress.service.ts | 170 +++ .../repositories/entity-label.repository.ts | 30 +- .../src/repositories/highlight.repository.ts | 50 +- .../src/repositories/injection-tokens.ts | 16 + .../entity-label-repository.interface.ts | 8 + .../highlight-repository.interface.ts | 12 + .../library-item-repository.interface.ts | 28 +- .../reading-progress-repository.interface.ts | 76 ++ .../repositories/library-item.repository.ts | 47 +- .../reading-progress.repository.ts | 140 +++ .../src/repositories/repositories.module.ts | 25 +- .../test/factories-example.e2e-spec.ts | 28 +- .../test/factories/highlight.factory.ts | 21 +- .../test/factories/library-item.factory.ts | 8 +- packages/api-nest/test/highlight.e2e-spec.ts | 287 ++++- packages/api-nest/test/jest-e2e.json | 4 +- .../api-nest/test/library-arc009.e2e-spec.ts | 6 +- packages/api-nest/test/library.e2e-spec.ts | 126 +- packages/api-nest/test/notebook.e2e-spec.ts | 6 +- .../test/reading-progress.e2e-spec.ts | 465 +++++++ .../test/setup/jest-environment-setup.ts | 1 + .../api-nest/test/setup/test-logger-config.ts | 71 ++ .../api-nest/test/setup/testcontainers.ts | 3 + .../0193.do.add_highlight_selectors.sql | 86 ++ .../0193.undo.add_highlight_selectors.sql | 35 + .../0194.do.sentinel_reading_progress.sql | 180 +++ .../0194.undo.sentinel_reading_progress.sql | 66 + .../0195.do.uppercase_highlight_colors.sql | 34 + .../0195.undo.uppercase_highlight_colors.sql | 34 + .../0196.do.add_total_sentinels.sql | 22 + .../0196.undo.add_total_sentinels.sql | 12 + packages/db/package.json | 5 +- packages/db/tsconfig.json | 18 +- packages/web-vite/eslint.config.js | 114 +- packages/web-vite/index.html | 20 +- packages/web-vite/package.json | 1 + packages/web-vite/src/App.tsx | 6 +- packages/web-vite/src/__tests__/basic.test.ts | 2 +- .../web-vite/src/components/AddLinkModal.tsx | 9 +- .../web-vite/src/components/AuthWrapper.tsx | 3 +- .../web-vite/src/components/CardSkeleton.tsx | 6 +- .../web-vite/src/components/EditInfoModal.tsx | 7 +- .../web-vite/src/components/ErrorBoundary.tsx | 3 +- .../web-vite/src/components/FlairBadge.tsx | 6 +- .../src/components/HighlightSidebar.tsx | 266 ++++ .../web-vite/src/components/LabelPicker.tsx | 7 +- .../src/components/LabelPickerModal.tsx | 8 +- .../src/components/LeftNavigation.tsx | 5 +- .../src/components/LibraryItemCard.tsx | 237 +++- .../src/components/LibraryItemRow.tsx | 201 ++- .../src/components/MultiSelectActionBar.tsx | 4 +- .../web-vite/src/components/NotebookModal.tsx | 220 ++++ .../src/lib/__tests__/api-client.test.ts | 7 +- .../web-vite/src/lib/anchoredHighlights.ts | 427 +++++++ packages/web-vite/src/lib/api-client.ts | 4 + packages/web-vite/src/lib/contentHash.ts | 83 ++ packages/web-vite/src/lib/graphql-client.ts | 683 +++++++--- .../web-vite/src/lib/graphql-fragments.ts | 121 ++ packages/web-vite/src/lib/markdown.ts | 148 +++ packages/web-vite/src/lib/reading-time.ts | 6 + packages/web-vite/src/lib/validation.ts | 2 +- packages/web-vite/src/main.tsx | 7 +- .../web-vite/src/pages/EmailLoginPage.tsx | 9 +- packages/web-vite/src/pages/LabelsPage.tsx | 25 +- packages/web-vite/src/pages/LandingPage.tsx | 10 +- packages/web-vite/src/pages/LibraryPage.tsx | 441 ++++--- packages/web-vite/src/pages/LoginPage.tsx | 5 +- packages/web-vite/src/pages/ReaderPage.tsx | 911 ++++++++++++-- packages/web-vite/src/pages/RegisterPage.tsx | 9 +- packages/web-vite/src/pages/index.tsx | 6 +- packages/web-vite/src/router/AppRouter.tsx | 7 +- .../src/stores/__tests__/auth.test.ts | 8 +- packages/web-vite/src/stores/index.ts | 5 +- .../web-vite/src/styles/HighlightSidebar.css | 398 ++++++ .../web-vite/src/styles/NotebookModal.css | 460 +++++++ packages/web-vite/src/styles/ReaderPage.css | 317 +++++ packages/web-vite/src/styles/index.css | 30 + packages/web-vite/src/test/setup.ts | 5 +- packages/web-vite/src/test/utils.tsx | 4 +- packages/web-vite/src/types/api.ts | 82 +- packages/web-vite/vite.config.ts | 94 +- packages/web-vite/vitest.config.ts | 2 +- packages/web/pages/settings/labels.tsx | 32 +- scripts/deploy.sh | 15 - scripts/hooks.json | 17 - yarn.lock | 1097 ++++++++++------- 121 files changed, 8781 insertions(+), 1642 deletions(-) create mode 100644 .eslintrc.base.json create mode 100644 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 .lintstagedrc.json create mode 100644 .prettierignore create mode 100644 commitlint.config.js create mode 100644 packages/api-nest/.eslintrc.json create mode 100644 packages/api-nest/src/graphql/dataloader.service.ts create mode 100644 packages/api-nest/src/highlight/entities/highlight-selector.interface.ts create mode 100644 packages/api-nest/src/reading-progress/dto/reading-progress-inputs.type.ts create mode 100644 packages/api-nest/src/reading-progress/dto/reading-progress.type.ts create mode 100644 packages/api-nest/src/reading-progress/entities/reading-progress.entity.ts create mode 100644 packages/api-nest/src/reading-progress/reading-progress.module.ts create mode 100644 packages/api-nest/src/reading-progress/reading-progress.resolver.ts create mode 100644 packages/api-nest/src/reading-progress/reading-progress.service.ts create mode 100644 packages/api-nest/src/repositories/injection-tokens.ts create mode 100644 packages/api-nest/src/repositories/interfaces/reading-progress-repository.interface.ts create mode 100644 packages/api-nest/src/repositories/reading-progress.repository.ts create mode 100644 packages/api-nest/test/reading-progress.e2e-spec.ts create mode 100644 packages/api-nest/test/setup/test-logger-config.ts create mode 100644 packages/db/migrations/0193.do.add_highlight_selectors.sql create mode 100644 packages/db/migrations/0193.undo.add_highlight_selectors.sql create mode 100644 packages/db/migrations/0194.do.sentinel_reading_progress.sql create mode 100644 packages/db/migrations/0194.undo.sentinel_reading_progress.sql create mode 100644 packages/db/migrations/0195.do.uppercase_highlight_colors.sql create mode 100644 packages/db/migrations/0195.undo.uppercase_highlight_colors.sql create mode 100644 packages/db/migrations/0196.do.add_total_sentinels.sql create mode 100644 packages/db/migrations/0196.undo.add_total_sentinels.sql create mode 100644 packages/web-vite/src/components/HighlightSidebar.tsx create mode 100644 packages/web-vite/src/components/NotebookModal.tsx create mode 100644 packages/web-vite/src/lib/anchoredHighlights.ts create mode 100644 packages/web-vite/src/lib/contentHash.ts create mode 100644 packages/web-vite/src/lib/graphql-fragments.ts create mode 100644 packages/web-vite/src/lib/markdown.ts create mode 100644 packages/web-vite/src/styles/HighlightSidebar.css create mode 100644 packages/web-vite/src/styles/NotebookModal.css create mode 100644 packages/web-vite/src/styles/index.css delete mode 100755 scripts/deploy.sh delete mode 100644 scripts/hooks.json diff --git a/.eslintrc.base.json b/.eslintrc.base.json new file mode 100644 index 000000000..5036355b6 --- /dev/null +++ b/.eslintrc.base.json @@ -0,0 +1,118 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "simple-import-sort", "prettier"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "prettier", + "plugin:prettier/recommended" + ], + "rules": { + "prettier/prettier": "error", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + "arrow-body-style": "off", + "prefer-arrow-callback": "off", + "prefer-const": "warn", + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-this": "error", + "curly": ["error", "all"], + "no-console": ["warn", { "allow": ["warn", "error", "info"] }], + "dot-notation": "warn", + "eqeqeq": "error", + "for-direction": "error", + "global-require": "warn", + "guard-for-in": "warn", + "handle-callback-err": "warn", + "id-blacklist": "error", + "init-declarations": "warn", + "lines-around-directive": "error", + "lines-between-class-members": ["warn", "always"], + "max-depth": ["error", 4], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 11], + "newline-before-return": "warn", + "no-array-constructor": "error", + "no-await-in-loop": "warn", + "no-caller": "error", + "no-catch-shadow": "error", + "no-duplicate-imports": "error", + "no-else-return": "warn", + "no-empty-function": "off", + "no-eq-null": "error", + "no-eval": "error", + "no-implicit-globals": "error", + "no-implied-eval": "error", + "no-labels": "error", + "no-lone-blocks": "warn", + "no-lonely-if": "warn", + "no-loop-func": "warn", + "no-mixed-operators": "error", + "no-multi-assign": "error", + "no-multi-str": "warn", + "no-native-reassign": "error", + "no-proto": "error", + "no-prototype-builtins": "off", + "no-restricted-globals": "error", + "no-restricted-imports": "error", + "no-restricted-modules": "error", + "no-restricted-properties": "error", + "no-restricted-syntax": "error", + "no-return-await": "warn", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-tabs": "error", + "no-template-curly-in-string": "error", + "no-undef-init": "warn", + "no-undefined": "warn", + "@typescript-eslint/prefer-nullish-coalescing": "warn", + "@typescript-eslint/prefer-optional-chain": "error", + "@typescript-eslint/no-unnecessary-condition": [ + "warn", + { "allowConstantLoopConditions": true } + ], + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": "error", + "no-use-before-define": [ + "warn", + { + "functions": false + } + ], + "no-useless-call": "error", + "no-useless-concat": "error", + "no-useless-constructor": "off", + "no-useless-escape": "warn", + "no-useless-rename": "error", + "no-useless-return": "error", + "no-void": "error", + "no-with": "error", + "prefer-promise-reject-errors": "warn", + "prefer-rest-params": "error", + "prefer-template": "warn", + "radix": "warn", + "symbol-description": "error", + "valid-jsdoc": "error", + "vars-on-top": "error", + "yoda": "error", + "class-methods-use-this": "off", + "@typescript-eslint/no-empty-interface": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "error" + }, + "overrides": [ + { + "files": ["*.spec.ts", "*.test.ts"], + "rules": { + "@typescript-eslint/no-unused-vars": "warn", + "no-console": "off" + } + } + ] +} diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 000000000..c160a7712 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit ${1} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..7d5c4c84d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged + diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 000000000..a1c258b19 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,15 @@ +{ + "*.ts": [ + "eslint --fix", + "prettier --write", + "eslint --rule 'simple-import-sort/imports: error' --rule 'simple-import-sort/exports: error' --fix" + ], + "*.tsx": [ + "eslint --fix", + "prettier --write", + "eslint --rule 'simple-import-sort/imports: error' --rule 'simple-import-sort/exports: error' --fix" + ], + "*.js, *.jsx": ["eslint --fix", "prettier --write"], + "!(package-lock)*.{json,yaml,yml}": ["prettier --write"], + "*.md": ["prettier --write"] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..aa9acf55f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs +dist/ +build/ +**/dist/ +**/build/ + +# Generated files +*.generated.* +*.min.js +*.min.css + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Package manager files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Schema files (auto-generated) +**/schema.graphql + diff --git a/.prettierrc b/.prettierrc index b2095be81..b852e4d34 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,6 @@ { "semi": false, - "singleQuote": true + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "auto" } diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 000000000..f97e8eda7 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,16 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + // Allow detailed commit messages (ignore body length) + 'body-max-line-length': [0], + 'footer-max-line-length': [0], + // Allow longer subject lines for detailed commits + 'subject-max-length': [2, 'always', 200], + // Still enforce conventional commit types + 'type-enum': [ + 2, + 'always', + ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'], + ], + }, +} diff --git a/package.json b/package.json index 36109c01f..97aa149d5 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,48 @@ { - "name": "omnivore-app", - "version": "1.0.0", - "description": "", - "private": true, - "workspaces": [ - "packages/*" - ], - "license": "AGPL-3.0-only", - "scripts": { - "test": "lerna run --stream test", - "lint": "lerna run --parallel lint", - "build": "lerna run build", - "test:scoped:example": "lerna run test --scope={@omnivore/pdf-handler,@omnivore/web}", - "gql-typegen": "graphql-codegen", - "deploy:web": "vercel --prod" - }, - "dependencies": {}, - "devDependencies": { - "@ardatan/aggregate-error": "^0.0.6", - "@graphql-codegen/cli": "^2.6.2", - "@graphql-codegen/introspection": "^2.1.1", - "@graphql-codegen/schema-ast": "^2.1.1", - "@graphql-codegen/typescript": "^2.1.1", - "@graphql-codegen/typescript-resolvers": "^2.1.1", - "@tsconfig/node14": "^1.0.1", - "@typescript-eslint/eslint-plugin": "^5.9.0", - "@typescript-eslint/parser": "^5.9.0", - "eslint": "^8.6.0", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-prettier": "^4.0.0", - "graphql": "^15.3.0", - "graphql-tag": "^2.11.0", - "lerna": "^7.4.1", - "prettier": "^2.5.1", - "typescript": "5.7.3" - }, - "volta": { - "node": "22.11.0", - "yarn": "1.22.19" - } + "name": "omnivore-app", + "version": "1.0.0", + "description": "", + "private": true, + "workspaces": [ + "packages/*" + ], + "license": "AGPL-3.0-only", + "scripts": { + "test": "lerna run --stream test", + "lint": "lerna run --parallel lint", + "build": "lerna run build", + "test:scoped:example": "lerna run test --scope={@omnivore/pdf-handler,@omnivore/web}", + "gql-typegen": "graphql-codegen", + "deploy:web": "vercel --prod", + "prepare": "husky install || true" + }, + "dependencies": {}, + "devDependencies": { + "@ardatan/aggregate-error": "^0.0.6", + "@graphql-codegen/cli": "^2.6.2", + "@graphql-codegen/introspection": "^2.1.1", + "@graphql-codegen/schema-ast": "^2.1.1", + "@graphql-codegen/typescript": "^2.1.1", + "@graphql-codegen/typescript-resolvers": "^2.1.1", + "@tsconfig/node14": "^1.0.1", + "@typescript-eslint/eslint-plugin": "^5.9.0", + "@typescript-eslint/parser": "^5.9.0", + "eslint": "^8.6.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-simple-import-sort": "^7.0.0", + "@commitlint/cli": "^18.4.3", + "@commitlint/config-conventional": "^18.4.3", + "graphql": "^15.3.0", + "graphql-tag": "^2.11.0", + "husky": "^9.0.0", + "lerna": "^7.4.1", + "lint-staged": "^15.0.0", + "prettier": "^2.5.1", + "typescript": "5.7.3" + }, + "volta": { + "node": "22.11.0", + "yarn": "1.22.19" + } } diff --git a/packages/api-nest/.eslintrc.json b/packages/api-nest/.eslintrc.json new file mode 100644 index 000000000..d2a902e45 --- /dev/null +++ b/packages/api-nest/.eslintrc.json @@ -0,0 +1,8 @@ +{ + "extends": ["../../.eslintrc.base.json"], + "rules": { + "semi": [2, "never"], + "@typescript-eslint/no-unnecessary-type-assertion": [0, "never"] + } +} + diff --git a/packages/api-nest/package.json b/packages/api-nest/package.json index 16efae45e..ac74c0e34 100644 --- a/packages/api-nest/package.json +++ b/packages/api-nest/package.json @@ -16,6 +16,7 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", @@ -47,8 +48,10 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cross-fetch": "^4.1.0", + "dataloader": "^2.2.3", "google-auth-library": "^9.0.0", "graphql": "^16.11.0", + "graphql-scalars": "^1.25.0", "ioredis": "^5.3.2", "joi": "^17.11.0", "jwk-to-pem": "^2.0.5", diff --git a/packages/api-nest/schema.graphql b/packages/api-nest/schema.graphql index 9ae13af63..47c7173e5 100644 --- a/packages/api-nest/schema.graphql +++ b/packages/api-nest/schema.graphql @@ -28,7 +28,10 @@ input CreateHighlightInput { annotation: String """Highlight color (yellow, red, green, blue)""" - color: String + color: HighlightColor = YELLOW + + """Optional content version/hash for tracking""" + contentVersion: String """Anchor index for position""" highlightPositionAnchorIndex: Int = 0 @@ -48,6 +51,9 @@ input CreateHighlightInput { """Quoted text from the document""" quote: String! + """Web Annotation selectors for robust text positioning (W3C standard)""" + selectors: JSON + """Text after the quote (for context)""" suffix: String } @@ -76,7 +82,10 @@ type DeleteResult { type Highlight { annotation: String - color: String + color: HighlightColor! + + """Optional content version/hash for tracking""" + contentVersion: String createdAt: DateTime! highlightPositionAnchorIndex: Int! highlightPositionPercent: Float! @@ -88,18 +97,33 @@ type Highlight { prefix: String quote: String representation: RepresentationType! + + """Web Annotation selectors for robust text positioning (W3C standard)""" + selectors: JSON! sharedAt: DateTime shortId: String! suffix: String updatedAt: DateTime! } +"""Highlight color options""" +enum HighlightColor { + BLUE + GREEN + RED + YELLOW +} + enum HighlightType { HIGHLIGHT - NOTE REDACTION } +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + type Label { color: String! createdAt: DateTime! @@ -134,8 +158,9 @@ type LibraryItem { pageType: String! publishedAt: DateTime readAt: DateTime - readingProgressBottomPercent: Float - readingProgressTopPercent: Float + + """Reading progress percentage (0-100) based on sentinel tracking""" + readingProgressPercent: Float savedAt: DateTime! """Site favicon/icon URL""" @@ -314,14 +339,11 @@ type Mutation { input: UpdateNotebookInput! ): LibraryItem! - """Update reading progress for a library item""" + """Update sentinel-based reading progress for a library item""" updateReadingProgress( - """Library item ID""" - id: String! - - """Reading progress data""" - progress: ReadingProgressInput! - ): LibraryItem! + """Reading progress data with sentinels""" + input: UpdateReadingProgressInput! + ): ReadingProgress! } type Query { @@ -346,24 +368,42 @@ type Query { libraryItems(after: String, first: Int = 20, search: LibrarySearchInput): LibraryItemsConnection! me: User! + """Get reading progress for a library item""" + readingProgress( + """Optional content version to filter by""" + contentVersion: String + + """Library item ID""" + libraryItemId: String! + ): ReadingProgress + """Legacy search query for backward compatibility""" search(after: String, first: Int = 20, includeContent: Boolean = false, query: String): SearchResult! session: AuthPayload viewer: User! } -input ReadingProgressInput { - """Anchor index for last read position""" - readingProgressAnchorIndex: Int = 0 +type ReadingProgress { + """Hash/version of the content this progress applies to""" + contentVersion: String - """Bottom reading progress percentage (0-100)""" - readingProgressBottomPercent: Float! + """When this progress record was first created""" + createdAt: DateTime! - """Highest read anchor index""" - readingProgressHighestAnchor: Int = 0 + """Highest sentinel ever reached (for completion tracking)""" + highestSeenSentinel: Int! - """Top reading progress percentage (0-100)""" - readingProgressTopPercent: Float! + """Unique identifier""" + id: ID! + + """Most recent sentinel scrolled past (for position restoration)""" + lastSeenSentinel: Int! + + """Library item this progress belongs to""" + libraryItemId: String! + + """When this progress was last updated""" + updatedAt: DateTime! } enum RegistrationType { @@ -430,7 +470,7 @@ input UpdateHighlightInput { annotation: String """Highlight color (yellow, red, green, blue)""" - color: String + color: HighlightColor } input UpdateLabelInput { @@ -446,6 +486,9 @@ input UpdateLibraryItemInput { """Updated description for the library item""" description: String + """Read timestamp (set to mark as read, null to mark as unread)""" + readAt: DateTime + """Updated title for the library item""" title: String } @@ -455,6 +498,25 @@ input UpdateNotebookInput { note: String! } +input UpdateReadingProgressInput { + """Hash/version of the content this progress applies to""" + contentVersion: String + + """Highest sentinel ever reached by this user""" + highestSeenSentinel: Int! + + """Most recent sentinel the user scrolled past""" + lastSeenSentinel: Int! + + """Library item ID""" + libraryItemId: String! + + """ + Total number of sentinels in the article (for progress percentage calculation) + """ + totalSentinels: Int +} + type User { createdAt: DateTime! email: String diff --git a/packages/api-nest/src/app/app.module.ts b/packages/api-nest/src/app/app.module.ts index ed426d548..8dd4f0833 100644 --- a/packages/api-nest/src/app/app.module.ts +++ b/packages/api-nest/src/app/app.module.ts @@ -9,6 +9,7 @@ import { GraphqlModule } from '../graphql/graphql.module' import { LibraryModule } from '../library/library.module' import { LabelModule } from '../label/label.module' import { HighlightModule } from '../highlight/highlight.module' +import { ReadingProgressModule } from '../reading-progress/reading-progress.module' import { QueueModule } from '../queue/queue.module' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -51,6 +52,9 @@ import { configValidationSchema } from '../config/config.schema' // Highlights HighlightModule, + // Reading Progress (Sentinel-based) + ReadingProgressModule, + // Queue and Background Processing QueueModule, diff --git a/packages/api-nest/src/config/test.config.ts b/packages/api-nest/src/config/test.config.ts index 903f27865..281946e47 100644 --- a/packages/api-nest/src/config/test.config.ts +++ b/packages/api-nest/src/config/test.config.ts @@ -11,6 +11,7 @@ import { LibraryItemEntity } from '../library/entities/library-item.entity' import { Label } from '../label/entities/label.entity' import { EntityLabel } from '../label/entities/entity-label.entity' import { HighlightEntity } from '../highlight/entities/highlight.entity' +import { ReadingProgressEntity } from '../reading-progress/entities/reading-progress.entity' export const testDatabaseConfig: TypeOrmModuleOptions = { type: 'postgres', @@ -31,6 +32,7 @@ export const testDatabaseConfig: TypeOrmModuleOptions = { Label, EntityLabel, HighlightEntity, + ReadingProgressEntity, ], synchronize: false, logging: false, diff --git a/packages/api-nest/src/database/database.module.ts b/packages/api-nest/src/database/database.module.ts index 74d9c781e..169a79953 100644 --- a/packages/api-nest/src/database/database.module.ts +++ b/packages/api-nest/src/database/database.module.ts @@ -2,6 +2,9 @@ import { Module } from '@nestjs/common' import { TypeOrmModule } from '@nestjs/typeorm' import { ConfigModule, ConfigService } from '@nestjs/config' import { EnvVariables } from '../config/env-variables' +import { QueryPerformanceLogger } from './query-logger' +import { StructuredLogger } from '../logging/structured-logger.service' +import { LoggingModule } from '../logging/logging.module' import { User, UserProfile, UserPersonalization } from '../user/entities' import { Filter } from '../filter/entities/filter.entity' import { Group } from '../group/entities/group.entity' @@ -11,70 +14,76 @@ import { LibraryItemEntity } from '../library/entities/library-item.entity' import { Label } from '../label/entities/label.entity' import { EntityLabel } from '../label/entities/entity-label.entity' import { HighlightEntity } from '../highlight/entities/highlight.entity' +import { ReadingProgressEntity } from '../reading-progress/entities/reading-progress.entity' @Module({ imports: [ + LoggingModule, // Import to get access to StructuredLogger TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - type: 'postgres', - host: configService.get( - EnvVariables.DATABASE_HOST, - 'localhost', - ), - port: configService.get(EnvVariables.DATABASE_PORT, 5432), - username: configService.get( - EnvVariables.DATABASE_USER, - 'app_user', - ), - password: configService.get(EnvVariables.DATABASE_PASSWORD), - database: configService.get( - EnvVariables.DATABASE_NAME, - 'omnivore', - ), - - // Entity configuration - entities: [ - User, - UserProfile, - UserPersonalization, - Filter, - Group, - Invite, - GroupMembership, - LibraryItemEntity, - Label, - EntityLabel, - HighlightEntity, - ], - - // Migration configuration - // migrations: ['dist/database/migrations/*.js'], - // migrationsTableName: 'typeorm_migrations', - migrationsRun: false, // Don't auto-run migrations - - // Development settings - synchronize: false, // Never use synchronize with existing database - logging: + imports: [ConfigModule, LoggingModule], + useFactory: async ( + configService: ConfigService, + structuredLogger: StructuredLogger, + ) => { + const isDevelopment = configService.get(EnvVariables.NODE_ENV) === 'development' - ? ['query', 'error'] - : ['error'], - // Connection pool settings for production - extra: { - max: 20, // Maximum number of connections - min: 5, // Minimum number of connections - idle_timeout: 30000, - connectionTimeoutMillis: 10000, - }, + return { + type: 'postgres', + host: configService.get( + EnvVariables.DATABASE_HOST, + 'localhost', + ), + port: configService.get(EnvVariables.DATABASE_PORT, 5432), + username: configService.get( + EnvVariables.DATABASE_USER, + 'app_user', + ), + password: configService.get(EnvVariables.DATABASE_PASSWORD), + database: configService.get( + EnvVariables.DATABASE_NAME, + 'omnivore', + ), - // Enable SSL in production - ssl: - configService.get(EnvVariables.NODE_ENV) === 'production' - ? { rejectUnauthorized: false } - : false, - }), - inject: [ConfigService], + // Entity configuration + entities: [ + User, + UserProfile, + UserPersonalization, + Filter, + Group, + Invite, + GroupMembership, + LibraryItemEntity, + Label, + EntityLabel, + HighlightEntity, + ReadingProgressEntity, + ], + migrationsRun: false, + synchronize: false, + logging: ['query', 'warn', 'error'], + // Use QueryPerformanceLogger to track slow queries + logger: new QueryPerformanceLogger(structuredLogger, isDevelopment), + // Log queries slower than 1 second (QueryPerformanceLogger handles >500ms as "slow") + maxQueryExecutionTime: 1000, + + // Connection pool settings for production + extra: { + max: 20, // Maximum number of connections + min: 5, // Minimum number of connections + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + }, + + // Enable SSL in production + ssl: + configService.get(EnvVariables.NODE_ENV) === 'production' + ? { rejectUnauthorized: false } + : false, + } + }, + inject: [ConfigService, StructuredLogger], }), ], exports: [TypeOrmModule], diff --git a/packages/api-nest/src/database/seeds/library-items.seed.ts b/packages/api-nest/src/database/seeds/library-items.seed.ts index 1879ea4d5..dcd997fae 100644 --- a/packages/api-nest/src/database/seeds/library-items.seed.ts +++ b/packages/api-nest/src/database/seeds/library-items.seed.ts @@ -34,8 +34,6 @@ export async function seedLibraryItems( itemType: 'ARTICLE', wordCount: 2500, siteName: 'NestJS Docs', - readingProgressTopPercent: 0, - readingProgressBottomPercent: 0, savedAt: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago }, { @@ -52,16 +50,13 @@ export async function seedLibraryItems( itemType: 'ARTICLE', wordCount: 3200, siteName: 'GraphQL.org', - readingProgressTopPercent: 45, - readingProgressBottomPercent: 40, savedAt: new Date(Date.now() - 1000 * 60 * 60 * 3), // 3 hours ago }, { userId, title: 'Understanding React Server Components', slug: 'understanding-react-server-components', - originalUrl: - 'https://react.dev/reference/react/use-server', + originalUrl: 'https://react.dev/reference/react/use-server', author: 'React Team', description: 'Deep dive into React Server Components and their impact on modern web applications', @@ -71,8 +66,6 @@ export async function seedLibraryItems( itemType: 'ARTICLE', wordCount: 4100, siteName: 'React.dev', - readingProgressTopPercent: 100, - readingProgressBottomPercent: 100, readAt: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago savedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2), // 2 days ago }, @@ -82,16 +75,13 @@ export async function seedLibraryItems( slug: 'typescript-5-8-release-notes', originalUrl: 'https://devblogs.microsoft.com/typescript/', author: 'TypeScript Team', - description: - 'New features and improvements in TypeScript 5.8 release', + description: 'New features and improvements in TypeScript 5.8 release', state: LibraryItemState.SUCCEEDED, contentReader: ContentReaderType.WEB, folder: FOLDERS.ARCHIVE, itemType: 'ARTICLE', wordCount: 1800, siteName: 'TypeScript Blog', - readingProgressTopPercent: 100, - readingProgressBottomPercent: 100, readAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3), // 3 days ago savedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), // 5 days ago }, @@ -99,7 +89,8 @@ export async function seedLibraryItems( userId, title: 'PostgreSQL Performance Tuning Guide', slug: 'postgresql-performance-tuning-guide', - originalUrl: 'https://www.postgresql.org/docs/current/performance-tips.html', + originalUrl: + 'https://www.postgresql.org/docs/current/performance-tips.html', author: 'PostgreSQL Community', description: 'Comprehensive guide to optimizing PostgreSQL database performance', @@ -109,8 +100,6 @@ export async function seedLibraryItems( itemType: 'ARTICLE', wordCount: 5400, siteName: 'PostgreSQL Docs', - readingProgressTopPercent: 15, - readingProgressBottomPercent: 10, savedAt: new Date(Date.now() - 1000 * 60 * 60 * 12), // 12 hours ago }, ] @@ -125,8 +114,6 @@ export async function seedLibraryItems( id: randomUUID(), // Manually generate UUID like E2E tests do ...itemData, user: { id: userId } as any, // TypeORM relation requirement - readingProgressLastReadAnchor: itemData.readingProgressTopPercent > 0 ? 1 : 0, - readingProgressHighestReadAnchor: itemData.readingProgressTopPercent > 0 ? 1 : 0, }) console.log(`[Seed] Created entity object, now saving...`) @@ -138,12 +125,15 @@ export async function seedLibraryItems( console.error(`[Seed] Error name: ${error?.name}`) console.error(`[Seed] Error message: ${error?.message}`) if (error?.detail) console.error(`[Seed] Error detail: ${error.detail}`) - if (error?.constraint) console.error(`[Seed] Constraint: ${error.constraint}`) + if (error?.constraint) + console.error(`[Seed] Constraint: ${error.constraint}`) console.error(`[Seed] Full error:`, error) } } - console.log(`✅ [Seed] Completed: ${createdItems.length}/${sampleItems.length} items created for user ${userId}`) + console.log( + `✅ [Seed] Completed: ${createdItems.length}/${sampleItems.length} items created for user ${userId}` + ) return createdItems } diff --git a/packages/api-nest/src/graphql/dataloader.service.ts b/packages/api-nest/src/graphql/dataloader.service.ts new file mode 100644 index 000000000..e85fe3fdb --- /dev/null +++ b/packages/api-nest/src/graphql/dataloader.service.ts @@ -0,0 +1,152 @@ +import { Inject, Injectable } from '@nestjs/common' +import DataLoader from 'dataloader' +import { IEntityLabelRepository } from '../repositories/interfaces/entity-label-repository.interface' +import { IHighlightRepository } from '../repositories/interfaces/highlight-repository.interface' +import { IReadingProgressRepository } from '../repositories/interfaces/reading-progress-repository.interface' +import { Label } from '../label/entities/label.entity' +import { HighlightEntity } from '../highlight/entities/highlight.entity' +import { ReadingProgressEntity } from '../reading-progress/entities/reading-progress.entity' +import { User } from '../user/entities/user.entity' +import { REPOSITORY_TOKENS } from '../repositories/injection-tokens' + +/** + * Authenticated request object with user populated by JwtAuthGuard + */ +export interface AuthenticatedRequest { + user?: User +} + +/** + * DataLoader service for batching GraphQL queries + * Prevents N+1 query problems by batching multiple individual requests + * into single database queries + * + * This service should be instantiated per GraphQL request in the context + */ +export class DataLoaderService { + public readonly labels: DataLoader + + public readonly highlights: DataLoader + + public readonly readingProgress: DataLoader< + string, + ReadingProgressEntity | null + > + + constructor( + entityLabelRepository: IEntityLabelRepository, + highlightRepository: IHighlightRepository, + readingProgressRepository: IReadingProgressRepository, + request: AuthenticatedRequest, + ) { + // Access user lazily from request (set by JwtAuthGuard after context is created) + const getUserId = (): string | undefined => request.user?.id + + // Initialize DataLoader for labels + // This batches multiple getLibraryItemLabels calls into a single query + this.labels = new DataLoader( + async (libraryItemIds: readonly string[]) => { + const ids = libraryItemIds as string[] + const userId = getUserId() + + if (!userId || ids.length === 0) { + return libraryItemIds.map(() => []) + } + + // Batch fetch all entity labels for the given library item IDs + const entityLabelsMap = + await entityLabelRepository.findByLibraryItemIds(ids) + + // Map to array of label arrays, filtering by user ID and sorting by position + return libraryItemIds.map((libraryItemId) => { + const entityLabels = entityLabelsMap.get(libraryItemId) || [] + const labels = entityLabels + .map((el) => el.label) + .filter((label) => label.userId === userId) + + // Sort by position + return labels.sort((a, b) => a.position - b.position) + }) + }, + { + cacheKeyFn: (key: string) => key, + }, + ) + + // Initialize DataLoader for highlights + // This batches multiple highlights queries into a single query + this.highlights = new DataLoader( + async (libraryItemIds: readonly string[]) => { + const ids = libraryItemIds as string[] + const userId = getUserId() + + if (!userId || ids.length === 0) { + return libraryItemIds.map(() => []) + } + + // Batch fetch all highlights for the given library item IDs + const highlightsMap = await highlightRepository.findByLibraryItemIds( + ids, + userId, + ) + + // Map to array of highlight arrays + return libraryItemIds.map( + (libraryItemId) => highlightsMap.get(libraryItemId) || [], + ) + }, + { + cacheKeyFn: (key: string) => key, + }, + ) + + // Initialize DataLoader for reading progress + // This batches multiple reading progress queries into a single query + this.readingProgress = new DataLoader( + async (libraryItemIds: readonly string[]) => { + const ids = libraryItemIds as string[] + const userId = getUserId() + + if (!userId || ids.length === 0) { + return libraryItemIds.map(() => null) + } + + // Batch fetch reading progress for the given library item IDs + const progressMap = + await readingProgressRepository.findByLibraryItemIds(ids, userId) + + // Map to array of progress entities (or null if not found) + return libraryItemIds.map( + (libraryItemId) => progressMap.get(libraryItemId) || null, + ) + }, + { + cacheKeyFn: (key: string) => key, + }, + ) + } +} + +/** + * Factory service to create DataLoader instances per request + */ +@Injectable() +export class DataLoaderFactory { + constructor( + @Inject(REPOSITORY_TOKENS.IEntityLabelRepository) + private readonly entityLabelRepository: IEntityLabelRepository, + @Inject(REPOSITORY_TOKENS.IHighlightRepository) + private readonly highlightRepository: IHighlightRepository, + @Inject(REPOSITORY_TOKENS.IReadingProgressRepository) + private readonly readingProgressRepository: IReadingProgressRepository, + ) {} + + create(request: AuthenticatedRequest): DataLoaderService { + return new DataLoaderService( + this.entityLabelRepository, + this.highlightRepository, + this.readingProgressRepository, + request, + ) + } +} diff --git a/packages/api-nest/src/graphql/graphql.module.ts b/packages/api-nest/src/graphql/graphql.module.ts index d222e1e3d..408ae9d1c 100644 --- a/packages/api-nest/src/graphql/graphql.module.ts +++ b/packages/api-nest/src/graphql/graphql.module.ts @@ -1,22 +1,37 @@ import { Module } from '@nestjs/common' +import { ModuleRef } from '@nestjs/core' import { ConfigModule, ConfigService } from '@nestjs/config' import { GraphQLModule } from '@nestjs/graphql' import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo' import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default' -import { join, resolve } from 'path' -import { AuthModule } from '../auth/auth.module' -import { AuthService } from '../auth/services/auth.service' +import { Request, Response } from 'express' +import { resolve } from 'path' +import { GraphQLJSON } from 'graphql-scalars' import { EnvVariables } from '../config/env-variables' +import { RepositoriesModule } from '../repositories/repositories.module' +import { DataLoaderFactory, AuthenticatedRequest } from './dataloader.service' +import { User } from '../user/entities/user.entity' + +/** + * GraphQL context interface + */ +interface GraphQLContext { + req: AuthenticatedRequest + res: Response + user?: User + dataLoaders: ReturnType +} @Module({ imports: [ + RepositoriesModule, // Required for DataLoaderFactory GraphQLModule.forRootAsync({ driver: ApolloDriver, - imports: [ConfigModule, AuthModule], - inject: [ConfigService, AuthService], + imports: [ConfigModule, RepositoriesModule], + inject: [ConfigService, ModuleRef], useFactory: async ( configService: ConfigService, - authService: AuthService, + moduleRef: ModuleRef, ) => { const isProduction = configService.get(EnvVariables.NODE_ENV, 'development') === @@ -30,29 +45,38 @@ import { EnvVariables } from '../config/env-variables' debug: !isProduction, playground: false, introspection: !isProduction, + resolvers: { JSON: GraphQLJSON }, plugins: isProduction ? [] : [ApolloServerPluginLandingPageLocalDefault({ footer: false })], - context: async ({ req, res }: { req: any; res: any }) => { - const request = req ?? { headers: {} } + context: async ({ + req, + res, + }: { + req: AuthenticatedRequest + res: Response + }): Promise => { + const request = req ?? ({ headers: {} } as AuthenticatedRequest) + // Get DataLoaderFactory from module context + const dataLoaderFactory = moduleRef.get(DataLoaderFactory, { + strict: false, + }) + // Create a new DataLoader instance per request (request-scoped) + // Pass the request object so DataLoader can access user lazily after auth + const dataLoaders = dataLoaderFactory.create(request) - const authHeader = - request.headers?.authorization ?? - request.headers?.Authorization ?? - request.cookies?.authToken - - if (authHeader && !request.user) { - const user = await authService.validateToken(String(authHeader)) - if (user) { - request.user = user - } + return { + req: request, + res, + user: request.user, + dataLoaders, } - - return { req: request, res, user: request.user } }, } }, }), ], + providers: [DataLoaderFactory], + exports: [DataLoaderFactory], }) export class GraphqlModule {} diff --git a/packages/api-nest/src/highlight/dto/highlight-inputs.type.ts b/packages/api-nest/src/highlight/dto/highlight-inputs.type.ts index ba05fef22..b24f2ebbc 100644 --- a/packages/api-nest/src/highlight/dto/highlight-inputs.type.ts +++ b/packages/api-nest/src/highlight/dto/highlight-inputs.type.ts @@ -1,13 +1,20 @@ -import { InputType, Field, Float, Int } from '@nestjs/graphql' +import { Field, Float, InputType, Int } from '@nestjs/graphql' import { - IsString, - IsOptional, - IsNumber, - Min, - Max, + IsEnum, IsInt, - IsIn, + IsNumber, + IsObject, + IsOptional, + IsString, + Max, + Min, } from 'class-validator' +import { GraphQLJSON } from 'graphql-scalars' + +import { HighlightColor } from '../entities/highlight.entity' +import { HighlightSelectors } from '../entities/highlight-selector.interface' + +// Note: HighlightColor enum is registered in highlight.type.ts to avoid duplicate registration /** * Input type for creating a new highlight @@ -67,14 +74,14 @@ export class CreateHighlightInput { @Min(0) highlightPositionAnchorIndex?: number - @Field(() => String, { + @Field(() => HighlightColor, { nullable: true, + defaultValue: HighlightColor.YELLOW, description: 'Highlight color (yellow, red, green, blue)', }) @IsOptional() - @IsString() - @IsIn(['yellow', 'red', 'green', 'blue']) - color?: string + @IsEnum(HighlightColor) + color?: HighlightColor @Field(() => String, { nullable: true, @@ -83,6 +90,23 @@ export class CreateHighlightInput { @IsOptional() @IsString() html?: string + + @Field(() => GraphQLJSON, { + nullable: true, + description: + 'Web Annotation selectors for robust text positioning (W3C standard)', + }) + @IsOptional() + @IsObject() + selectors?: HighlightSelectors + + @Field(() => String, { + nullable: true, + description: 'Optional content version/hash for tracking', + }) + @IsOptional() + @IsString() + contentVersion?: string } /** @@ -98,12 +122,11 @@ export class UpdateHighlightInput { @IsString() annotation?: string - @Field(() => String, { + @Field(() => HighlightColor, { nullable: true, description: 'Highlight color (yellow, red, green, blue)', }) @IsOptional() - @IsString() - @IsIn(['yellow', 'red', 'green', 'blue']) - color?: string + @IsEnum(HighlightColor) + color?: HighlightColor } diff --git a/packages/api-nest/src/highlight/dto/highlight.type.ts b/packages/api-nest/src/highlight/dto/highlight.type.ts index 7bc32f8c0..064f6edb3 100644 --- a/packages/api-nest/src/highlight/dto/highlight.type.ts +++ b/packages/api-nest/src/highlight/dto/highlight.type.ts @@ -1,5 +1,19 @@ -import { Field, Float, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql' -import { HighlightType, RepresentationType } from '../entities/highlight.entity' +import { + Field, + Float, + ID, + Int, + ObjectType, + registerEnumType, +} from '@nestjs/graphql' +import { GraphQLJSON } from 'graphql-scalars' + +import { + HighlightColor, + HighlightType, + RepresentationType, +} from '../entities/highlight.entity' +import { HighlightSelectors } from '../entities/highlight-selector.interface' registerEnumType(HighlightType, { name: 'HighlightType', @@ -9,6 +23,11 @@ registerEnumType(RepresentationType, { name: 'RepresentationType', }) +registerEnumType(HighlightColor, { + name: 'HighlightColor', + description: 'Highlight color options', +}) + @ObjectType() export class Highlight { @Field(() => ID) @@ -56,9 +75,21 @@ export class Highlight { @Field({ nullable: true }) html?: string | null - @Field({ nullable: true }) - color?: string | null + @Field(() => HighlightColor) + color!: HighlightColor @Field(() => RepresentationType) representation!: RepresentationType + + @Field(() => GraphQLJSON, { + description: + 'Web Annotation selectors for robust text positioning (W3C standard)', + }) + selectors!: HighlightSelectors + + @Field(() => String, { + nullable: true, + description: 'Optional content version/hash for tracking', + }) + contentVersion?: string | null } diff --git a/packages/api-nest/src/highlight/entities/highlight-selector.interface.ts b/packages/api-nest/src/highlight/entities/highlight-selector.interface.ts new file mode 100644 index 000000000..db6bfae50 --- /dev/null +++ b/packages/api-nest/src/highlight/entities/highlight-selector.interface.ts @@ -0,0 +1,90 @@ +/** + * Web Annotation Data Model selectors for robust text positioning + * Based on W3C Web Annotation specification: https://www.w3.org/TR/annotation-model/ + * + * These selectors provide multiple strategies for locating highlighted text, + * making highlights resilient to content changes. + */ + +/** + * TextQuoteSelector - identifies text by quoting it directly + * Most common selector type for web highlights + * + * @see https://www.w3.org/TR/annotation-model/#text-quote-selector + */ +export interface TextQuoteSelector { + /** The exact text being highlighted */ + exact: string + /** Text immediately before the selection (for disambiguation) */ + prefix?: string + /** Text immediately after the selection (for disambiguation) */ + suffix?: string +} + +/** + * TextPositionSelector - identifies text by character position + * Useful as a fallback when text quote matching fails + * + * @see https://www.w3.org/TR/annotation-model/#text-position-selector + */ +export interface TextPositionSelector { + /** Starting character position in the document */ + start: number + /** Ending character position in the document */ + end: number +} + +/** + * XPathSelector - identifies elements using XPath expressions + * + * @see https://www.w3.org/TR/annotation-model/#xpath-selector + */ +export interface XPathSelector { + /** XPath expression */ + value: string +} + +/** + * CSSSelector - identifies elements using CSS selectors + * + * @see https://www.w3.org/TR/annotation-model/#css-selector + */ +export interface CSSSelector { + /** CSS selector expression */ + value: string +} + +/** + * RangeSelector - identifies text using DOM range + * Most precise for HTML documents with stable structure + * + * @see https://www.w3.org/TR/annotation-model/#range-selector + */ +export interface RangeSelector { + /** Start point of the range (XPath or CSS selector) */ + startSelector: XPathSelector | CSSSelector + /** End point of the range (XPath or CSS selector) */ + endSelector: XPathSelector | CSSSelector + /** Optional character offset within start element */ + startOffset?: number + /** Optional character offset within end element */ + endOffset?: number +} + +/** + * HighlightSelectors - container for multiple selector strategies + * + * Following the Web Annotation pattern of using multiple selectors + * for robust text positioning. The textQuote selector is required, + * with optional fallback selectors. + */ +export interface HighlightSelectors { + /** Primary selector using quoted text (REQUIRED) */ + textQuote: TextQuoteSelector + /** Optional character position fallback */ + textPosition?: TextPositionSelector + /** Optional DOM range fallback */ + domRange?: RangeSelector + /** Allow additional custom selector strategies */ + [key: string]: unknown +} diff --git a/packages/api-nest/src/highlight/entities/highlight.entity.ts b/packages/api-nest/src/highlight/entities/highlight.entity.ts index 6fa0408be..12a614376 100644 --- a/packages/api-nest/src/highlight/entities/highlight.entity.ts +++ b/packages/api-nest/src/highlight/entities/highlight.entity.ts @@ -7,13 +7,14 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm' -import { User } from '../../user/entities/user.entity' + import { LibraryItemEntity } from '../../library/entities/library-item.entity' +import { User } from '../../user/entities/user.entity' +import { HighlightSelectors } from './highlight-selector.interface' export enum HighlightType { HIGHLIGHT = 'HIGHLIGHT', REDACTION = 'REDACTION', - NOTE = 'NOTE', // Legacy - being phased out in favor of library_item.note } export enum RepresentationType { @@ -21,6 +22,13 @@ export enum RepresentationType { FEED_CONTENT = 'FEED_CONTENT', } +export enum HighlightColor { + YELLOW = 'YELLOW', + RED = 'RED', + GREEN = 'GREEN', + BLUE = 'BLUE', +} + @Entity({ name: 'highlight', schema: 'omnivore' }) export class HighlightEntity { @PrimaryGeneratedColumn('uuid') @@ -70,7 +78,11 @@ export class HighlightEntity { @Column({ name: 'highlight_position_percent', type: 'real', default: 0 }) highlightPositionPercent!: number - @Column({ name: 'highlight_position_anchor_index', type: 'integer', default: 0 }) + @Column({ + name: 'highlight_position_anchor_index', + type: 'integer', + default: 0, + }) highlightPositionAnchorIndex!: number @Column({ @@ -84,8 +96,12 @@ export class HighlightEntity { @Column({ type: 'text', nullable: true }) html?: string | null - @Column({ type: 'text', nullable: true }) - color?: string | null + @Column({ + type: 'enum', + enum: HighlightColor, + default: HighlightColor.YELLOW, + }) + color!: HighlightColor @Column({ type: 'enum', @@ -93,4 +109,22 @@ export class HighlightEntity { default: RepresentationType.CONTENT, }) representation!: RepresentationType + + // Robust anchored selectors for multi-strategy text positioning + // Uses W3C Web Annotation Data Model selector format + // @see https://www.w3.org/TR/annotation-model/#selectors + @Column({ + type: 'jsonb', + default: {}, + }) + selectors!: HighlightSelectors + + // Optional content version/hash for tracking + @Column({ + name: 'content_version', + type: 'varchar', + length: 64, + nullable: true, + }) + contentVersion?: string | null } diff --git a/packages/api-nest/src/highlight/highlight.resolver.ts b/packages/api-nest/src/highlight/highlight.resolver.ts index 44bca5782..a41355768 100644 --- a/packages/api-nest/src/highlight/highlight.resolver.ts +++ b/packages/api-nest/src/highlight/highlight.resolver.ts @@ -10,6 +10,7 @@ import { UpdateHighlightInput, } from './dto/highlight-inputs.type' import { DeleteResult } from '../library/dto/library-inputs.type' +import { HighlightColor } from './entities/highlight.entity' @Resolver(() => Highlight) export class HighlightResolver { @@ -120,7 +121,9 @@ function mapEntityToGraph(entity: any): Highlight { highlightPositionAnchorIndex: entity.highlightPositionAnchorIndex ?? 0, highlightType: entity.highlightType, html: entity.html ?? null, - color: entity.color ?? 'yellow', + color: entity.color ?? HighlightColor.YELLOW, representation: entity.representation, + selectors: entity.selectors ?? {}, + contentVersion: entity.contentVersion ?? null, } } diff --git a/packages/api-nest/src/highlight/highlight.service.ts b/packages/api-nest/src/highlight/highlight.service.ts index aa7fed1fc..e728326fa 100644 --- a/packages/api-nest/src/highlight/highlight.service.ts +++ b/packages/api-nest/src/highlight/highlight.service.ts @@ -5,19 +5,29 @@ import { Logger, Inject, } from '@nestjs/common' -import { HighlightEntity, HighlightType } from './entities/highlight.entity' -import { CreateHighlightInput, UpdateHighlightInput } from './dto/highlight-inputs.type' +import { + HighlightEntity, + HighlightType, + HighlightColor, + RepresentationType, +} from './entities/highlight.entity' +import { HighlightSelectors } from './entities/highlight-selector.interface' +import { + CreateHighlightInput, + UpdateHighlightInput, +} from './dto/highlight-inputs.type' import { ILibraryItemRepository } from '../repositories/interfaces/library-item-repository.interface' import { IHighlightRepository } from '../repositories/interfaces/highlight-repository.interface' +import { REPOSITORY_TOKENS } from '../repositories/injection-tokens' @Injectable() export class HighlightService { private readonly logger = new Logger(HighlightService.name) constructor( - @Inject('IHighlightRepository') + @Inject(REPOSITORY_TOKENS.IHighlightRepository) private readonly highlightRepository: IHighlightRepository, - @Inject('ILibraryItemRepository') + @Inject(REPOSITORY_TOKENS.ILibraryItemRepository) private readonly libraryItemRepository: ILibraryItemRepository, ) {} @@ -73,6 +83,24 @@ export class HighlightService { // Generate a short ID (8 characters) const shortId = this.generateShortId() + // Build selectors from input - prefer explicit selectors, fallback to quote/prefix/suffix + let selectors: HighlightSelectors + if (input.selectors) { + // Use selectors directly (GraphQLJSON scalar provides object) + selectors = input.selectors as HighlightSelectors + } else { + // Build TextQuote selector from quote/prefix/suffix fields + // Following W3C Web Annotation Data Model specification + // Database constraint enforces: selectors ? 'textQuote' AND selectors->'textQuote' ? 'exact' + selectors = { + textQuote: { + exact: input.quote || '', + prefix: input.prefix, + suffix: input.suffix, + }, + } + } + const highlight = this.highlightRepository.create({ userId, libraryItemId: input.libraryItemId, @@ -83,10 +111,12 @@ export class HighlightService { annotation: input.annotation, highlightPositionPercent: input.highlightPositionPercent ?? 0, highlightPositionAnchorIndex: input.highlightPositionAnchorIndex ?? 0, - color: input.color ?? 'yellow', + color: input.color ?? HighlightColor.YELLOW, html: input.html, highlightType: HighlightType.HIGHLIGHT, - representation: 'CONTENT' as any, + representation: RepresentationType.CONTENT, + selectors, + contentVersion: input.contentVersion, }) return this.highlightRepository.save(highlight) diff --git a/packages/api-nest/src/label/label.service.ts b/packages/api-nest/src/label/label.service.ts index 3bf1735ca..ca719e5eb 100644 --- a/packages/api-nest/src/label/label.service.ts +++ b/packages/api-nest/src/label/label.service.ts @@ -10,15 +10,16 @@ import { CreateLabelInput, UpdateLabelInput } from './dto/label-inputs.type' import { ILibraryItemRepository } from '../repositories/interfaces/library-item-repository.interface' import { ILabelRepository } from '../repositories/interfaces/label-repository.interface' import { IEntityLabelRepository } from '../repositories/interfaces/entity-label-repository.interface' +import { REPOSITORY_TOKENS } from '../repositories/injection-tokens' @Injectable() export class LabelService { constructor( - @Inject('ILabelRepository') + @Inject(REPOSITORY_TOKENS.ILabelRepository) private readonly labelRepository: ILabelRepository, - @Inject('IEntityLabelRepository') + @Inject(REPOSITORY_TOKENS.IEntityLabelRepository) private readonly entityLabelRepository: IEntityLabelRepository, - @Inject('ILibraryItemRepository') + @Inject(REPOSITORY_TOKENS.ILibraryItemRepository) private readonly libraryItemRepository: ILibraryItemRepository, ) {} diff --git a/packages/api-nest/src/library/dto/library-inputs.type.ts b/packages/api-nest/src/library/dto/library-inputs.type.ts index aede3bcc1..635cbc98a 100644 --- a/packages/api-nest/src/library/dto/library-inputs.type.ts +++ b/packages/api-nest/src/library/dto/library-inputs.type.ts @@ -18,7 +18,7 @@ import { IsUrl, } from 'class-validator' import { LibraryItemState } from '../entities/library-item.entity' -import { FOLDERS, ALL_FOLDERS, VALID_FOLDERS } from '../../constants/folders.constants' +import { FOLDERS, ALL_FOLDERS } from '../../constants/folders.constants' /** * Sort field options for library items @@ -49,44 +49,6 @@ registerEnumType(SortOrder, { description: 'Sort order direction', }) -/** - * Input type for updating reading progress - */ -@InputType() -export class ReadingProgressInput { - @Field(() => Float, { description: 'Top reading progress percentage (0-100)' }) - @IsNumber() - @Min(0) - @Max(100) - readingProgressTopPercent: number - - @Field(() => Float, { - description: 'Bottom reading progress percentage (0-100)', - }) - @IsNumber() - @Min(0) - @Max(100) - readingProgressBottomPercent: number - - @Field(() => Int, { - description: 'Anchor index for last read position', - nullable: true, - defaultValue: 0, - }) - @IsInt() - @Min(0) - readingProgressAnchorIndex?: number - - @Field(() => Int, { - description: 'Highest read anchor index', - nullable: true, - defaultValue: 0, - }) - @IsInt() - @Min(0) - readingProgressHighestAnchor?: number -} - /** * Result type for delete operations */ @@ -245,4 +207,11 @@ export class UpdateLibraryItemInput { @IsOptional() @IsString() description?: string + + @Field(() => Date, { + nullable: true, + description: 'Read timestamp (set to mark as read, null to mark as unread)', + }) + @IsOptional() + readAt?: Date | null } diff --git a/packages/api-nest/src/library/dto/library-item.type.ts b/packages/api-nest/src/library/dto/library-item.type.ts index 7c1597f86..d8023189b 100644 --- a/packages/api-nest/src/library/dto/library-item.type.ts +++ b/packages/api-nest/src/library/dto/library-item.type.ts @@ -1,5 +1,16 @@ -import { Field, Float, ID, Int, ObjectType, registerEnumType, createUnionType } from '@nestjs/graphql' -import { LibraryItemState, ContentReaderType } from '../entities/library-item.entity' +import { + Field, + Float, + ID, + Int, + ObjectType, + registerEnumType, + createUnionType, +} from '@nestjs/graphql' +import { + LibraryItemState, + ContentReaderType, +} from '../entities/library-item.entity' import { Label } from '../../label/dto/label.type' registerEnumType(LibraryItemState, { @@ -45,12 +56,6 @@ export class LibraryItem { @Field(() => Date) updatedAt!: Date - @Field(() => Float, { nullable: true }) - readingProgressTopPercent?: number | null - - @Field(() => Float, { nullable: true }) - readingProgressBottomPercent?: number | null - @Field(() => LibraryItemState) state!: LibraryItemState @@ -72,28 +77,48 @@ export class LibraryItem { @Field(() => Date, { nullable: true }) noteUpdatedAt?: Date | null - @Field({ nullable: true, description: 'Thumbnail/cover image URL for the library item' }) + @Field({ + nullable: true, + description: 'Thumbnail/cover image URL for the library item', + }) thumbnail?: string | null - @Field(() => Float, { nullable: true, description: 'Estimated word count for reading time calculation' }) + @Field(() => Float, { + nullable: true, + description: 'Estimated word count for reading time calculation', + }) wordCount?: number | null - @Field({ nullable: true, description: 'Site name (e.g., "Medium", "New York Times")' }) + @Field({ + nullable: true, + description: 'Site name (e.g., "Medium", "New York Times")', + }) siteName?: string | null @Field({ nullable: true, description: 'Site favicon/icon URL' }) siteIcon?: string | null - @Field({ description: 'Item type (ARTICLE, FILE, VIDEO, etc.)', defaultValue: 'ARTICLE' }) + @Field({ + description: 'Item type (ARTICLE, FILE, VIDEO, etc.)', + defaultValue: 'ARTICLE', + }) itemType!: string // Legacy field aliases for backward compatibility with frontend - @Field({ nullable: true, name: 'image', description: 'Legacy alias for thumbnail' }) + @Field({ + nullable: true, + name: 'image', + description: 'Legacy alias for thumbnail', + }) get image(): string | null { return this.thumbnail } - @Field(() => Float, { nullable: true, name: 'wordsCount', description: 'Legacy alias for wordCount' }) + @Field(() => Float, { + nullable: true, + name: 'wordsCount', + description: 'Legacy alias for wordCount', + }) get wordsCount(): number | null { return this.wordCount } @@ -102,6 +127,12 @@ export class LibraryItem { get pageType(): string { return this.itemType } + + @Field(() => Float, { + nullable: true, + description: 'Reading progress percentage (0-100) based on sentinel tracking', + }) + readingProgressPercent?: number | null } @ObjectType() diff --git a/packages/api-nest/src/library/entities/library-item.entity.ts b/packages/api-nest/src/library/entities/library-item.entity.ts index be022e57c..5be65e403 100644 --- a/packages/api-nest/src/library/entities/library-item.entity.ts +++ b/packages/api-nest/src/library/entities/library-item.entity.ts @@ -8,8 +8,9 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm' -import { User } from '../../user/entities/user.entity' + import { EntityLabel } from '../../label/entities/entity-label.entity' +import { User } from '../../user/entities/user.entity' export enum LibraryItemState { FAILED = 'FAILED', @@ -84,17 +85,19 @@ export class LibraryItemEntity { @Column({ name: 'site_icon', type: 'text', nullable: true }) siteIcon?: string | null - @Column({ name: 'reading_progress_top_percent', type: 'real', default: 0 }) - readingProgressTopPercent!: number + /** + * SHA-256 hash of sanitized content for version tracking + * Used by sentinel-based reading progress to detect content changes + */ + @Column({ name: 'content_hash', type: 'varchar', length: 64, nullable: true }) + contentHash?: string | null - @Column({ name: 'reading_progress_bottom_percent', type: 'real', default: 0 }) - readingProgressBottomPercent!: number - - @Column({ name: 'reading_progress_last_read_anchor', type: 'integer', default: 0 }) - readingProgressLastReadAnchor!: number - - @Column({ name: 'reading_progress_highest_read_anchor', type: 'integer', default: 0 }) - readingProgressHighestReadAnchor!: number + /** + * Total number of sentinel markers in the article content + * Used to calculate reading progress percentage + */ + @Column({ name: 'total_sentinels', type: 'integer', default: 0 }) + totalSentinels!: number @Column({ type: 'text', nullable: true }) thumbnail?: string | null @@ -113,7 +116,13 @@ export class LibraryItemEntity { @Column({ type: 'text' }) folder!: string - @Column({ name: 'label_names', type: 'text', array: true, nullable: true, default: [] }) + @Column({ + name: 'label_names', + type: 'text', + array: true, + nullable: true, + default: [], + }) labelNames?: string[] | null @Column({ name: 'readable_content', type: 'text', default: '' }) diff --git a/packages/api-nest/src/library/library.resolver.ts b/packages/api-nest/src/library/library.resolver.ts index 12ce73a35..8ea6dafd8 100644 --- a/packages/api-nest/src/library/library.resolver.ts +++ b/packages/api-nest/src/library/library.resolver.ts @@ -1,9 +1,19 @@ -import { Args, Int, Query, Mutation, Resolver, ResolveField, Parent } from '@nestjs/graphql' +import { + Args, + Int, + Query, + Mutation, + Resolver, + ResolveField, + Parent, + Context, +} from '@nestjs/graphql' import { UseGuards } from '@nestjs/common' import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' import { CurrentUser } from '../user/decorators/current-user.decorator' import { User } from '../user/entities/user.entity' import { LibraryService } from './library.service' +import { DataLoaderService } from '../graphql/dataloader.service' import { LibraryItem, LibraryItemsConnection, @@ -14,7 +24,6 @@ import { SearchPageInfo, } from './dto/library-item.type' import { - ReadingProgressInput, DeleteResult, LibrarySearchInput, SaveUrlInput, @@ -23,6 +32,15 @@ import { } from './dto/library-inputs.type' import { LabelService } from '../label/label.service' import { Label } from '../label/dto/label.type' +import { LibraryItemEntity } from './entities/library-item.entity' + +/** + * Library item with entity fields for field resolvers + * Extends the GraphQL type to include database entity fields + */ +interface LibraryItemWithEntityFields extends LibraryItem { + totalSentinels?: number +} @Resolver(() => LibraryItem) export class LibraryResolver { @@ -38,8 +56,43 @@ export class LibraryResolver { async labels( @Parent() libraryItem: LibraryItem, @CurrentUser() user: User, + @Context('dataLoaders') dataLoaders: DataLoaderService, ): Promise { - return this.labelService.getLibraryItemLabels(user.id, libraryItem.id) + // Use DataLoader to batch label queries and prevent N+1 problems + const labels = await dataLoaders.labels.load(libraryItem.id) + return labels.length > 0 ? labels : null + } + + @ResolveField(() => Number, { nullable: true }) + @UseGuards(JwtAuthGuard) + async readingProgressPercent( + @Parent() libraryItem: LibraryItemWithEntityFields, + @CurrentUser() user: User, + @Context('dataLoaders') dataLoaders: DataLoaderService, + ): Promise { + // Get total sentinels from the library item + const totalSentinels = libraryItem.totalSentinels || 0 + + if (totalSentinels === 0) { + return null // No sentinels injected yet + } + + // Use DataLoader to batch reading progress queries + const progress = await dataLoaders.readingProgress.load(libraryItem.id) + + if (!progress || progress.highestSeenSentinel === 0) { + return null // No reading progress yet + } + + // Calculate percentage: (highest_seen_sentinel / total_sentinels) * 100 + let percent = Math.min(100, Math.round((progress.highestSeenSentinel / totalSentinels) * 100)) + + // Round up to 100% if >= 95% (accounts for sentinels not being at the very end) + if (percent >= 95 && percent < 100) { + percent = 100 + } + + return percent } // ==================== QUERIES ==================== @@ -92,7 +145,11 @@ export class LibraryResolver { first = 20, @Args('after', { type: () => String, nullable: true }) after?: string, @Args('query', { type: () => String, nullable: true }) query?: string, - @Args('includeContent', { type: () => Boolean, nullable: true, defaultValue: false }) + @Args('includeContent', { + type: () => Boolean, + nullable: true, + defaultValue: false, + }) includeContent = false, ): Promise { try { @@ -119,7 +176,7 @@ export class LibraryResolver { } return { - cursor: item.id, // Each edge cursor should be the item's ID + cursor: item.id, // Each edge cursor should be the item's ID node: graphItem, } }) @@ -128,7 +185,7 @@ export class LibraryResolver { hasNextPage: !!nextCursor, hasPreviousPage: !!after, startCursor: items.length > 0 ? items[0].id : null, - endCursor: items.length > 0 ? items[items.length - 1].id : null, // Last item's ID, not nextCursor + endCursor: items.length > 0 ? items[items.length - 1].id : null, // Last item's ID, not nextCursor totalCount: null, // Not currently tracked } @@ -176,28 +233,6 @@ export class LibraryResolver { return await this.libraryService.deleteItem(user.id, id) } - @Mutation(() => LibraryItem, { - description: 'Update reading progress for a library item', - }) - @UseGuards(JwtAuthGuard) - async updateReadingProgress( - @CurrentUser() user: User, - @Args('id', { type: () => String, description: 'Library item ID' }) - id: string, - @Args('progress', { - type: () => ReadingProgressInput, - description: 'Reading progress data', - }) - progress: ReadingProgressInput, - ): Promise { - const entity = await this.libraryService.updateReadingProgress( - user.id, - id, - progress, - ) - return mapEntityToGraph(entity) - } - @Mutation(() => LibraryItem, { description: 'Update notebook content for a library item', }) @@ -352,7 +387,7 @@ export class LibraryResolver { * Map LibraryItemEntity to GraphQL LibraryItem type * Handles field name differences and null coalescing */ -function mapEntityToGraph(entity: any): LibraryItem { +function mapEntityToGraph(entity: LibraryItemEntity): LibraryItem { const thumbnail = entity.thumbnail ?? null const wordCount = entity.wordCount ?? null const itemType = entity.itemType ?? 'ARTICLE' @@ -369,21 +404,19 @@ function mapEntityToGraph(entity: any): LibraryItem { publishedAt: entity.publishedAt ?? null, readAt: entity.readAt ?? null, updatedAt: entity.updatedAt, - readingProgressTopPercent: entity.readingProgressTopPercent ?? 0, - readingProgressBottomPercent: entity.readingProgressBottomPercent ?? 0, state: entity.state, contentReader: entity.contentReader, folder: entity.folder, content: entity.readableContent ?? null, note: entity.note ?? null, noteUpdatedAt: entity.noteUpdatedAt ?? null, - labels: null, // Labels will be resolved by the field resolver - // ARC-009: Add fields for frontend library feature parity + labels: null, thumbnail, wordCount, siteName: entity.siteName ?? null, siteIcon: entity.siteIcon ?? null, itemType, + totalSentinels: entity.totalSentinels ?? 0, // For reading progress calculation // Legacy field aliases (TypeScript doesn't know about getters, so we set them directly) image: thumbnail, wordsCount: wordCount, diff --git a/packages/api-nest/src/library/library.service.ts b/packages/api-nest/src/library/library.service.ts index d8550ee68..fcee19ed8 100644 --- a/packages/api-nest/src/library/library.service.ts +++ b/packages/api-nest/src/library/library.service.ts @@ -11,7 +11,6 @@ import { LibraryItemState, } from './entities/library-item.entity' import { - ReadingProgressInput, LibrarySearchInput, SaveUrlInput, UpdateLibraryItemInput, @@ -20,15 +19,19 @@ import { EventBusService } from '../queue/event-bus.service' import { EVENT_NAMES } from '../queue/events.constants' import { JOB_PRIORITY } from '../queue/queue.constants' import { ILibraryItemRepository } from '../repositories/interfaces/library-item-repository.interface' +import { IReadingProgressRepository } from '../repositories/interfaces/reading-progress-repository.interface' import { FOLDERS, VALID_FOLDERS } from '../constants/folders.constants' +import { REPOSITORY_TOKENS } from '../repositories/injection-tokens' @Injectable() export class LibraryService { private readonly logger = new Logger(LibraryService.name) constructor( - @Inject('ILibraryItemRepository') + @Inject(REPOSITORY_TOKENS.ILibraryItemRepository) private readonly libraryRepository: ILibraryItemRepository, + @Inject(REPOSITORY_TOKENS.IReadingProgressRepository) + private readonly readingProgressRepository: IReadingProgressRepository, private readonly eventBus: EventBusService, ) {} @@ -56,7 +59,10 @@ export class LibraryService { * @param id - Library item ID * @returns Library item or null if not found */ - async findById(userId: string, id: string): Promise { + async findById( + userId: string, + id: string, + ): Promise { return this.libraryRepository.findById(id, userId) } @@ -79,7 +85,9 @@ export class LibraryService { } // Update state and folder based on archive status - item.state = archived ? LibraryItemState.ARCHIVED : LibraryItemState.SUCCEEDED + item.state = archived + ? LibraryItemState.ARCHIVED + : LibraryItemState.SUCCEEDED item.folder = archived ? FOLDERS.ARCHIVE : FOLDERS.INBOX return await this.libraryRepository.save(item) @@ -124,65 +132,6 @@ export class LibraryService { } } - /** - * Update reading progress for a library item - * @param userId - User ID who owns the item - * @param itemId - Library item ID - * @param progress - Reading progress data - * @returns Updated library item - */ - async updateReadingProgress( - userId: string, - itemId: string, - progress: ReadingProgressInput, - ): Promise { - const item = await this.findById(userId, itemId) - - if (!item) { - throw new NotFoundException(`Library item with ID ${itemId} not found`) - } - - // Validate progress percentages - if ( - progress.readingProgressTopPercent < 0 || - progress.readingProgressTopPercent > 100 - ) { - throw new BadRequestException( - 'Reading progress top percent must be between 0 and 100', - ) - } - - if ( - progress.readingProgressBottomPercent < 0 || - progress.readingProgressBottomPercent > 100 - ) { - throw new BadRequestException( - 'Reading progress bottom percent must be between 0 and 100', - ) - } - - // Update reading progress fields - item.readingProgressTopPercent = progress.readingProgressTopPercent - item.readingProgressBottomPercent = progress.readingProgressBottomPercent - - if (progress.readingProgressAnchorIndex !== undefined) { - item.readingProgressLastReadAnchor = progress.readingProgressAnchorIndex - } - - if (progress.readingProgressHighestAnchor !== undefined) { - item.readingProgressHighestReadAnchor = - progress.readingProgressHighestAnchor - } - - // If progress is 100%, mark as read - if (progress.readingProgressTopPercent === 100) { - item.readAt = new Date() - } - - await this.libraryRepository.save(item) - return item - } - /** * Move a library item to a different folder * @param userId - User ID who owns the item @@ -365,8 +314,32 @@ export class LibraryService { ) } - // Delegate to repository - return this.libraryRepository.bulkMarkAsRead(userId, itemIds) + // First, mark items as read in library_item table + const result = await this.libraryRepository.bulkMarkAsRead(userId, itemIds) + + // Then, update reading progress to 100% for items that have sentinels + // Fetch all items in a single query using findByIds + const items = await this.libraryRepository.findByIds(itemIds, userId) + + // Update reading progress for each item that has sentinels + await Promise.all( + items.map(async (item) => { + if (!item.totalSentinels || item.totalSentinels === 0) { + return // Skip items without sentinels + } + + // Set reading progress to 100% (highestSeenSentinel = totalSentinels) + await this.readingProgressRepository.upsertProgress( + userId, + item.id, + null, // contentVersion - null means applies to any version + item.totalSentinels, // lastSeenSentinel + item.totalSentinels, // highestSeenSentinel (100% completion) + ) + }), + ) + + return result } /** @@ -493,6 +466,10 @@ export class LibraryService { item.description = input.description || null } + if (input.readAt !== undefined) { + item.readAt = input.readAt + } + // Update the updatedAt timestamp item.updatedAt = new Date() @@ -534,5 +511,4 @@ export class LibraryService { return `url-${Date.now()}` } } - } diff --git a/packages/api-nest/src/queue/processors/content-processor.service.ts b/packages/api-nest/src/queue/processors/content-processor.service.ts index f25b85f1e..61229b473 100644 --- a/packages/api-nest/src/queue/processors/content-processor.service.ts +++ b/packages/api-nest/src/queue/processors/content-processor.service.ts @@ -240,16 +240,15 @@ export class ContentProcessorService // Phase 1: Fetch HTML content this.logger.debug(`Fetching HTML from ${url}`) + const response = await fetch(url, { headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - Accept: - 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'gzip, deflate, br', 'Cache-Control': 'no-cache', - Pragma: 'no-cache', + 'Pragma': 'no-cache', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', diff --git a/packages/api-nest/src/reading-progress/dto/reading-progress-inputs.type.ts b/packages/api-nest/src/reading-progress/dto/reading-progress-inputs.type.ts new file mode 100644 index 000000000..a9f2b9939 --- /dev/null +++ b/packages/api-nest/src/reading-progress/dto/reading-progress-inputs.type.ts @@ -0,0 +1,46 @@ +import { InputType, Field, Int } from '@nestjs/graphql' +import { IsString, IsOptional, IsInt, Min } from 'class-validator' + +/** + * Input type for updating reading progress with sentinel positions + */ +@InputType() +export class UpdateReadingProgressInput { + @Field(() => String, { + description: 'Library item ID', + }) + @IsString() + libraryItemId!: string + + @Field(() => String, { + nullable: true, + description: 'Hash/version of the content this progress applies to', + }) + @IsOptional() + @IsString() + contentVersion?: string + + @Field(() => Int, { + description: 'Most recent sentinel the user scrolled past', + }) + @IsInt() + @Min(0) + lastSeenSentinel!: number + + @Field(() => Int, { + description: 'Highest sentinel ever reached by this user', + }) + @IsInt() + @Min(0) + highestSeenSentinel!: number + + @Field(() => Int, { + nullable: true, + description: + 'Total number of sentinels in the article (for progress percentage calculation)', + }) + @IsOptional() + @IsInt() + @Min(0) + totalSentinels?: number +} diff --git a/packages/api-nest/src/reading-progress/dto/reading-progress.type.ts b/packages/api-nest/src/reading-progress/dto/reading-progress.type.ts new file mode 100644 index 000000000..20ab04766 --- /dev/null +++ b/packages/api-nest/src/reading-progress/dto/reading-progress.type.ts @@ -0,0 +1,45 @@ +import { Field, ObjectType, ID, Int } from '@nestjs/graphql' + +/** + * GraphQL type for ReadingProgress + * Represents sentinel-based reading position for a user/item/version + */ +@ObjectType() +export class ReadingProgress { + @Field(() => ID, { + description: 'Unique identifier', + }) + id!: string + + @Field(() => String, { + description: 'Library item this progress belongs to', + }) + libraryItemId!: string + + @Field(() => String, { + nullable: true, + description: 'Hash/version of the content this progress applies to', + }) + contentVersion?: string | null + + @Field(() => Int, { + description: + 'Most recent sentinel scrolled past (for position restoration)', + }) + lastSeenSentinel!: number + + @Field(() => Int, { + description: 'Highest sentinel ever reached (for completion tracking)', + }) + highestSeenSentinel!: number + + @Field(() => Date, { + description: 'When this progress record was first created', + }) + createdAt!: Date + + @Field(() => Date, { + description: 'When this progress was last updated', + }) + updatedAt!: Date +} diff --git a/packages/api-nest/src/reading-progress/entities/reading-progress.entity.ts b/packages/api-nest/src/reading-progress/entities/reading-progress.entity.ts new file mode 100644 index 000000000..482f73bf7 --- /dev/null +++ b/packages/api-nest/src/reading-progress/entities/reading-progress.entity.ts @@ -0,0 +1,95 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + Index, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm' +import { User } from '../../user/entities/user.entity' +import { LibraryItemEntity } from '../../library/entities/library-item.entity' + +/** + * Reading progress tracking using I/O sentinels for stable, content-aware positioning + * + * Replaces scroll percentages with sentinel-based tracking that survives: + * - Content updates + * - Dynamic layout changes + * - Different screen sizes + * - Image lazy loading + */ +@Entity({ name: 'reading_progress', schema: 'omnivore' }) +@Index(['userId', 'libraryItemId', 'contentVersion'], { + unique: true, + where: "content_version IS NOT NULL OR content_version = ''", +}) +@Index(['userId', 'libraryItemId', 'contentVersion']) +@Index(['userId', 'libraryItemId', 'updatedAt']) +export class ReadingProgressEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + // ==================== Relations ==================== + + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user?: User + + @Column({ name: 'library_item_id', type: 'uuid' }) + libraryItemId!: string + + @ManyToOne(() => LibraryItemEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'library_item_id' }) + libraryItem?: LibraryItemEntity + + // ==================== Content Version ==================== + + /** + * Hash/version of the content this progress applies to + * Enables detection of content changes and re-anchoring + */ + @Column({ + name: 'content_version', + type: 'varchar', + length: 64, + nullable: true, + }) + contentVersion?: string | null + + // ==================== Sentinel Positions ==================== + + /** + * Most recent sentinel the user scrolled past (viewport bottom) + * Used to restore reading position + */ + @Column({ name: 'last_seen_sentinel', type: 'int', default: 0 }) + lastSeenSentinel!: number + + /** + * Highest sentinel ever reached by this user + * Used for completion tracking and "furthest read" indicator + */ + @Column({ name: 'highest_seen_sentinel', type: 'int', default: 0 }) + highestSeenSentinel!: number + + // ==================== Timestamps ==================== + + @CreateDateColumn({ + name: 'created_at', + type: 'timestamptz', + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt!: Date + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamptz', + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt!: Date +} diff --git a/packages/api-nest/src/reading-progress/reading-progress.module.ts b/packages/api-nest/src/reading-progress/reading-progress.module.ts new file mode 100644 index 000000000..ff90facc0 --- /dev/null +++ b/packages/api-nest/src/reading-progress/reading-progress.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { ReadingProgressService } from './reading-progress.service' +import { ReadingProgressResolver } from './reading-progress.resolver' +import { RepositoriesModule } from '../repositories/repositories.module' + +/** + * ReadingProgressModule + * + * Provides sentinel-based reading progress tracking functionality + * Manages reading position persistence per user/item/content version + */ +@Module({ + imports: [ + RepositoriesModule, // Access to IReadingProgressRepository and ILibraryItemRepository + ], + providers: [ReadingProgressService, ReadingProgressResolver], + exports: [ReadingProgressService], +}) +export class ReadingProgressModule {} diff --git a/packages/api-nest/src/reading-progress/reading-progress.resolver.ts b/packages/api-nest/src/reading-progress/reading-progress.resolver.ts new file mode 100644 index 000000000..9ee7fabc3 --- /dev/null +++ b/packages/api-nest/src/reading-progress/reading-progress.resolver.ts @@ -0,0 +1,81 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' +import { UseGuards } from '@nestjs/common' +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' +import { CurrentUser } from '../user/decorators/current-user.decorator' +import { User } from '../user/entities/user.entity' +import { ReadingProgressService } from './reading-progress.service' +import { ReadingProgress } from './dto/reading-progress.type' +import { UpdateReadingProgressInput } from './dto/reading-progress-inputs.type' +import { ReadingProgressEntity } from './entities/reading-progress.entity' + +/** + * Map ReadingProgressEntity to GraphQL ReadingProgress type + */ +function mapEntityToGraph(entity: ReadingProgressEntity): ReadingProgress { + return { + id: entity.id, + libraryItemId: entity.libraryItemId, + contentVersion: entity.contentVersion, + lastSeenSentinel: entity.lastSeenSentinel, + highestSeenSentinel: entity.highestSeenSentinel, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + } +} + +@Resolver(() => ReadingProgress) +export class ReadingProgressResolver { + constructor( + private readonly readingProgressService: ReadingProgressService, + ) {} + + // ==================== QUERIES ==================== + + @Query(() => ReadingProgress, { + nullable: true, + description: 'Get reading progress for a library item', + }) + @UseGuards(JwtAuthGuard) + async readingProgress( + @CurrentUser() user: User, + @Args('libraryItemId', { + type: () => String, + description: 'Library item ID', + }) + libraryItemId: string, + @Args('contentVersion', { + type: () => String, + nullable: true, + description: 'Optional content version to filter by', + }) + contentVersion?: string, + ): Promise { + const entity = await this.readingProgressService.getProgress( + user.id, + libraryItemId, + contentVersion, + ) + return entity ? mapEntityToGraph(entity) : null + } + + // ==================== MUTATIONS ==================== + + @Mutation(() => ReadingProgress, { + description: 'Update sentinel-based reading progress for a library item', + }) + @UseGuards(JwtAuthGuard) + async updateReadingProgress( + @CurrentUser() user: User, + @Args('input', { + type: () => UpdateReadingProgressInput, + description: 'Reading progress data with sentinels', + }) + input: UpdateReadingProgressInput, + ): Promise { + const entity = await this.readingProgressService.updateProgress( + user.id, + input, + ) + return mapEntityToGraph(entity) + } +} diff --git a/packages/api-nest/src/reading-progress/reading-progress.service.ts b/packages/api-nest/src/reading-progress/reading-progress.service.ts new file mode 100644 index 000000000..b4b274a8a --- /dev/null +++ b/packages/api-nest/src/reading-progress/reading-progress.service.ts @@ -0,0 +1,170 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, + Inject, +} from '@nestjs/common' +import { ReadingProgressEntity } from './entities/reading-progress.entity' +import { UpdateReadingProgressInput } from './dto/reading-progress-inputs.type' +import { ILibraryItemRepository } from '../repositories/interfaces/library-item-repository.interface' +import { IReadingProgressRepository } from '../repositories/interfaces/reading-progress-repository.interface' +import { REPOSITORY_TOKENS } from '../repositories/injection-tokens' + +/** + * Service for managing sentinel-based reading progress + * Handles business logic for tracking user reading positions + */ +@Injectable() +export class ReadingProgressService { + private readonly logger = new Logger(ReadingProgressService.name) + + constructor( + @Inject(REPOSITORY_TOKENS.IReadingProgressRepository) + private readonly progressRepository: IReadingProgressRepository, + @Inject(REPOSITORY_TOKENS.ILibraryItemRepository) + private readonly libraryItemRepository: ILibraryItemRepository, + ) {} + + /** + * Get reading progress for a library item + * @param userId - User ID + * @param libraryItemId - Library item ID + * @param contentVersion - Optional content version to filter by + * @returns Reading progress or null if not found + */ + async getProgress( + userId: string, + libraryItemId: string, + contentVersion?: string, + ): Promise { + // Verify the library item exists and belongs to the user + const libraryItem = await this.libraryItemRepository.findById( + libraryItemId, + userId, + ) + + if (!libraryItem) { + throw new NotFoundException( + `Library item with ID ${libraryItemId} not found`, + ) + } + + // If content version is provided, get progress for that specific version + if (contentVersion) { + return this.progressRepository.findProgress( + libraryItemId, + userId, + contentVersion, + ) + } + + // Otherwise, get the most recent progress (any version) + return this.progressRepository.findLatestProgress(libraryItemId, userId) + } + + /** + * Update or create reading progress + * @param userId - User ID + * @param input - Progress update data + * @returns Updated reading progress + */ + async updateProgress( + userId: string, + input: UpdateReadingProgressInput, + ): Promise { + // Verify the library item exists and belongs to the user + const libraryItem = await this.libraryItemRepository.findById( + input.libraryItemId, + userId, + ) + + if (!libraryItem) { + throw new NotFoundException( + `Library item with ID ${input.libraryItemId} not found`, + ) + } + + // Validate sentinel values + if (input.lastSeenSentinel < 0 || input.highestSeenSentinel < 0) { + throw new BadRequestException('Sentinel values must be non-negative') + } + + // Normalize highestSeenSentinel: it should never be lower than lastSeenSentinel + // This ensures data consistency - highest can't be less than current position + let normalizedHighest = input.highestSeenSentinel + if (input.lastSeenSentinel > input.highestSeenSentinel) { + normalizedHighest = input.lastSeenSentinel + this.logger.debug( + `Normalizing highestSeenSentinel: ${input.highestSeenSentinel} -> ${normalizedHighest} ` + + `(lastSeen: ${input.lastSeenSentinel}) for user ${userId} on item ${input.libraryItemId}`, + ) + } + + // Update library item's total_sentinels if provided + if (input.totalSentinels !== undefined && input.totalSentinels > 0) { + await this.libraryItemRepository.update(input.libraryItemId, userId, { + totalSentinels: input.totalSentinels, + }) + this.logger.debug( + `Updated total_sentinels to ${input.totalSentinels} for item ${input.libraryItemId}`, + ) + } + + // Upsert progress (create or update) with normalized values + const progress = await this.progressRepository.upsertProgress( + userId, + input.libraryItemId, + input.contentVersion || null, + input.lastSeenSentinel, + normalizedHighest, + ) + + this.logger.log( + `Updated reading progress for user ${userId} on item ${input.libraryItemId}: sentinel ${input.lastSeenSentinel}`, + ) + + return progress + } + + /** + * Get completion percentage based on sentinel progress + * @param progress - Reading progress entity + * @param totalSentinels - Total number of sentinels in the content + * @returns Completion percentage (0-100) + */ + calculateCompletion( + progress: ReadingProgressEntity, + totalSentinels: number, + ): number { + if (totalSentinels <= 0) return 0 + return Math.min( + 100, + Math.round((progress.highestSeenSentinel / totalSentinels) * 100), + ) + } + + /** + * Detect if content has changed since last read + * @param userId - User ID + * @param libraryItemId - Library item ID + * @param currentContentVersion - Current content hash + * @returns True if content has changed, false otherwise + */ + async hasContentChanged( + userId: string, + libraryItemId: string, + currentContentVersion: string, + ): Promise { + const latestProgress = await this.progressRepository.findLatestProgress( + libraryItemId, + userId, + ) + + if (!latestProgress || !latestProgress.contentVersion) { + return false + } + + return latestProgress.contentVersion !== currentContentVersion + } +} diff --git a/packages/api-nest/src/repositories/entity-label.repository.ts b/packages/api-nest/src/repositories/entity-label.repository.ts index 6fc88203e..e7243dc1a 100644 --- a/packages/api-nest/src/repositories/entity-label.repository.ts +++ b/packages/api-nest/src/repositories/entity-label.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' -import { Repository } from 'typeorm' +import { Repository, In } from 'typeorm' import { EntityLabel } from '../label/entities/entity-label.entity' import { IEntityLabelRepository } from './interfaces/entity-label-repository.interface' @@ -12,7 +12,7 @@ import { IEntityLabelRepository } from './interfaces/entity-label-repository.int export class EntityLabelRepository implements IEntityLabelRepository { constructor( @InjectRepository(EntityLabel) - private readonly repository: Repository, + private readonly repository: Repository ) {} /** @@ -45,4 +45,30 @@ export class EntityLabelRepository implements IEntityLabelRepository { async save(entityLabels: EntityLabel[]): Promise { return this.repository.save(entityLabels) } + + /** + * Batch find entity labels for multiple library items with label relations loaded + * Used by DataLoader to prevent N+1 queries + */ + async findByLibraryItemIds(libraryItemIds: string[]): Promise> { + if (libraryItemIds.length === 0) { + return new Map() + } + + const entityLabels = await this.repository.find({ + where: { libraryItemId: In(libraryItemIds) }, + relations: ['label'], + }) + + // Group by library item ID + const result = new Map() + for (const libraryItemId of libraryItemIds) { + result.set( + libraryItemId, + entityLabels.filter(el => el.libraryItemId === libraryItemId) + ) + } + + return result + } } diff --git a/packages/api-nest/src/repositories/highlight.repository.ts b/packages/api-nest/src/repositories/highlight.repository.ts index 1ff4b2bda..41c5454cf 100644 --- a/packages/api-nest/src/repositories/highlight.repository.ts +++ b/packages/api-nest/src/repositories/highlight.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' -import { Repository } from 'typeorm' +import { Repository, In } from 'typeorm' import { HighlightEntity } from '../highlight/entities/highlight.entity' import { IHighlightRepository } from './interfaces/highlight-repository.interface' @@ -18,10 +18,7 @@ export class HighlightRepository implements IHighlightRepository { /** * Find a highlight by ID and user ID */ - async findById( - id: string, - userId: string, - ): Promise { + async findById(id: string, userId: string): Promise { return this.repository.findOne({ where: { id, @@ -69,4 +66,47 @@ export class HighlightRepository implements IHighlightRepository { async remove(highlight: HighlightEntity): Promise { await this.repository.remove(highlight) } + + /** + * Batch find highlights for multiple library items + * Used by DataLoader to prevent N+1 queries + */ + async findByLibraryItemIds( + libraryItemIds: string[], + userId: string, + ): Promise> { + if (libraryItemIds.length === 0) { + return new Map() + } + + const highlights = await this.repository.find({ + where: { + libraryItemId: In(libraryItemIds), + userId, + }, + order: { + highlightPositionPercent: 'ASC', + }, + }) + + // Group by library item ID + const result = new Map() + + for (const highlight of highlights) { + const bucket = result.get(highlight.libraryItemId) + if (bucket) { + bucket.push(highlight) + } else { + result.set(highlight.libraryItemId, [highlight]) + } + } + + for (const libraryItemId of libraryItemIds) { + if (!result.has(libraryItemId)) { + result.set(libraryItemId, []) + } + } + + return result + } } diff --git a/packages/api-nest/src/repositories/injection-tokens.ts b/packages/api-nest/src/repositories/injection-tokens.ts new file mode 100644 index 000000000..f6595861b --- /dev/null +++ b/packages/api-nest/src/repositories/injection-tokens.ts @@ -0,0 +1,16 @@ +/** + * Injection tokens for dependency injection + * Using Symbols instead of strings provides: + * - Type safety (prevents typos) + * - Better IDE autocomplete + * - Prevents accidental token collisions + * - Enables tree-shaking of unused tokens + */ +export const REPOSITORY_TOKENS = { + ILibraryItemRepository: Symbol('ILibraryItemRepository'), + IHighlightRepository: Symbol('IHighlightRepository'), + ILabelRepository: Symbol('ILabelRepository'), + IEntityLabelRepository: Symbol('IEntityLabelRepository'), + IReadingProgressRepository: Symbol('IReadingProgressRepository'), +} as const + diff --git a/packages/api-nest/src/repositories/interfaces/entity-label-repository.interface.ts b/packages/api-nest/src/repositories/interfaces/entity-label-repository.interface.ts index eb680785f..e990055be 100644 --- a/packages/api-nest/src/repositories/interfaces/entity-label-repository.interface.ts +++ b/packages/api-nest/src/repositories/interfaces/entity-label-repository.interface.ts @@ -5,6 +5,7 @@ import { EntityLabel } from '../../label/entities/entity-label.entity' * Manages the many-to-many relationship between library items and labels */ export interface IEntityLabelRepository { + /** * Find entity labels for a library item with label relations loaded * @param libraryItemId - Library item ID @@ -32,4 +33,11 @@ export interface IEntityLabelRepository { * @returns Saved entity labels */ save(entityLabels: EntityLabel[]): Promise + + /** + * Batch find entity labels for multiple library items with label relations loaded + * @param libraryItemIds - Array of library item IDs + * @returns Map of library item ID to array of entity labels with label relations + */ + findByLibraryItemIds(libraryItemIds: string[]): Promise> } diff --git a/packages/api-nest/src/repositories/interfaces/highlight-repository.interface.ts b/packages/api-nest/src/repositories/interfaces/highlight-repository.interface.ts index c7d8bf023..2cfdcb1e8 100644 --- a/packages/api-nest/src/repositories/interfaces/highlight-repository.interface.ts +++ b/packages/api-nest/src/repositories/interfaces/highlight-repository.interface.ts @@ -5,6 +5,7 @@ import { HighlightEntity } from '../../highlight/entities/highlight.entity' * Separates data access layer from business logic */ export interface IHighlightRepository { + /** * Find a highlight by ID and user ID * @param id - Highlight ID @@ -44,4 +45,15 @@ export interface IHighlightRepository { * @returns void */ remove(highlight: HighlightEntity): Promise + + /** + * Batch find highlights for multiple library items + * @param libraryItemIds - Array of library item IDs + * @param userId - User ID who owns the highlights + * @returns Map of library item ID to array of highlights + */ + findByLibraryItemIds( + libraryItemIds: string[], + userId: string, + ): Promise> } diff --git a/packages/api-nest/src/repositories/interfaces/library-item-repository.interface.ts b/packages/api-nest/src/repositories/interfaces/library-item-repository.interface.ts index 1d2143a59..e5e6e4897 100644 --- a/packages/api-nest/src/repositories/interfaces/library-item-repository.interface.ts +++ b/packages/api-nest/src/repositories/interfaces/library-item-repository.interface.ts @@ -1,8 +1,5 @@ import { LibraryItemEntity } from '../../library/entities/library-item.entity' -import { - LibrarySearchInput, - ReadingProgressInput, -} from '../../library/dto/library-inputs.type' +import { LibrarySearchInput } from '../../library/dto/library-inputs.type' /** * Options for finding library items @@ -38,6 +35,8 @@ export interface BulkOperationResult { * Separates data access layer from business logic */ export interface ILibraryItemRepository { + // Find operations + /** * Find a library item by ID and user ID * @param id - Library item ID @@ -46,6 +45,14 @@ export interface ILibraryItemRepository { */ findById(id: string, userId: string): Promise + /** + * Find multiple library items by IDs and user ID + * @param ids - Array of library item IDs + * @param userId - User ID who owns the items + * @returns Array of library items (items not found are omitted) + */ + findByIds(ids: string[], userId: string): Promise + /** * Find a library item by URL and user ID (for duplicate detection) * @param url - Original URL @@ -83,6 +90,19 @@ export interface ILibraryItemRepository { */ create(data: Partial): LibraryItemEntity + /** + * Update specific fields of a library item + * @param id - Library item ID + * @param userId - User ID who owns the item + * @param data - Partial data to update + * @returns Updated library item + */ + update( + id: string, + userId: string, + data: Partial, + ): Promise + /** * Bulk archive or unarchive library items * @param userId - User ID who owns the items diff --git a/packages/api-nest/src/repositories/interfaces/reading-progress-repository.interface.ts b/packages/api-nest/src/repositories/interfaces/reading-progress-repository.interface.ts new file mode 100644 index 000000000..f9a828269 --- /dev/null +++ b/packages/api-nest/src/repositories/interfaces/reading-progress-repository.interface.ts @@ -0,0 +1,76 @@ +import { ReadingProgressEntity } from '../../reading-progress/entities/reading-progress.entity' + +/** + * Repository interface for ReadingProgress entity + * Manages sentinel-based reading position tracking per user/item/content version + */ +export interface IReadingProgressRepository { + /** + * Find reading progress for a library item (optionally filtered by content version) + * @param libraryItemId - Library item ID + * @param userId - User ID who owns the progress + * @param contentVersion - Optional content hash/version to filter by + * @returns Reading progress or null if not found + */ + findProgress( + libraryItemId: string, + userId: string, + contentVersion?: string | null, + ): Promise + + /** + * Find the most recent reading progress for a library item (any version) + * @param libraryItemId - Library item ID + * @param userId - User ID who owns the progress + * @returns Most recent reading progress or null + */ + findLatestProgress( + libraryItemId: string, + userId: string, + ): Promise + + /** + * Create a new reading progress instance (without saving to database) + * @param data - Partial reading progress data + * @returns Reading progress instance + */ + create(data: Partial): ReadingProgressEntity + + /** + * Save (create or update) reading progress + * Uses upsert to handle conflicts on unique constraint + * @param progress - Reading progress to save + * @returns Saved reading progress + */ + save(progress: ReadingProgressEntity): Promise + + /** + * Update or create reading progress for a library item + * Handles upserting based on unique constraint (user, item, version) + * @param userId - User ID + * @param libraryItemId - Library item ID + * @param contentVersion - Content hash/version (nullable) + * @param lastSeenSentinel - Last sentinel scrolled past + * @param highestSeenSentinel - Highest sentinel ever reached + * @returns Updated/created reading progress + */ + upsertProgress( + userId: string, + libraryItemId: string, + contentVersion: string | null, + lastSeenSentinel: number, + highestSeenSentinel: number, + ): Promise + + /** + * Batch find latest reading progress for multiple library items + * Used by DataLoader to prevent N+1 queries + * @param libraryItemIds - Array of library item IDs + * @param userId - User ID who owns the progress + * @returns Map of library item ID to reading progress + */ + findByLibraryItemIds( + libraryItemIds: string[], + userId: string, + ): Promise> +} diff --git a/packages/api-nest/src/repositories/library-item.repository.ts b/packages/api-nest/src/repositories/library-item.repository.ts index 3a816e119..aa1a192e5 100644 --- a/packages/api-nest/src/repositories/library-item.repository.ts +++ b/packages/api-nest/src/repositories/library-item.repository.ts @@ -44,6 +44,25 @@ export class LibraryItemRepository implements ILibraryItemRepository { }) } + /** + * Find multiple library items by IDs and user ID + * Uses a single query with IN clause for efficiency + */ + async findByIds( + ids: string[], + userId: string, + ): Promise { + if (ids.length === 0) { + return [] + } + + return this.repository + .createQueryBuilder('item') + .where('item.id IN (:...ids)', { ids }) + .andWhere('item.userId = :userId', { userId }) + .getMany() + } + /** * Find a library item by URL and user ID (for duplicate detection) */ @@ -155,6 +174,32 @@ export class LibraryItemRepository implements ILibraryItemRepository { return this.repository.create(data) } + /** + * Update specific fields of a library item + */ + async update( + id: string, + userId: string, + data: Partial, + ): Promise { + // First find the item to ensure it exists and belongs to the user + const item = await this.findById(id, userId) + if (!item) { + throw new Error(`Library item with ID ${id} not found`) + } + + // Update the item + await this.repository.update({ id, userId }, data) + + // Fetch and return the updated item + const updated = await this.findById(id, userId) + if (!updated) { + throw new Error(`Failed to fetch updated library item ${id}`) + } + + return updated + } + /** * Bulk archive or unarchive library items */ @@ -376,8 +421,6 @@ export class LibraryItemRepository implements ILibraryItemRepository { .update(LibraryItemEntity) .set({ readAt: new Date(), - readingProgressTopPercent: 100, - readingProgressBottomPercent: 100, }) .where('id IN (:...ids)', { ids: batch }) .andWhere('userId = :userId', { userId }) diff --git a/packages/api-nest/src/repositories/reading-progress.repository.ts b/packages/api-nest/src/repositories/reading-progress.repository.ts new file mode 100644 index 000000000..9b89d6356 --- /dev/null +++ b/packages/api-nest/src/repositories/reading-progress.repository.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { Repository } from 'typeorm' +import { ReadingProgressEntity } from '../reading-progress/entities/reading-progress.entity' +import { IReadingProgressRepository } from './interfaces/reading-progress-repository.interface' + +/** + * TypeORM implementation of the IReadingProgressRepository interface + * Handles all data access operations for sentinel-based reading progress + */ +@Injectable() +export class ReadingProgressRepository implements IReadingProgressRepository { + constructor( + @InjectRepository(ReadingProgressEntity) + private readonly repository: Repository, + ) {} + + /** + * Find reading progress for a specific user + item + content version + */ + async findProgress( + libraryItemId: string, + userId: string, + contentVersion?: string | null, + ): Promise { + return this.repository.findOne({ + where: { + libraryItemId, + userId, + // Handle null contentVersion correctly + contentVersion: contentVersion ?? null, + }, + }) + } + + /** + * Find the most recent reading progress for a library item (any version) + * Useful when content version is unknown or has changed + */ + async findLatestProgress( + libraryItemId: string, + userId: string, + ): Promise { + return this.repository.findOne({ + where: { + libraryItemId, + userId, + }, + order: { + updatedAt: 'DESC', + }, + }) + } + + /** + * Create a new reading progress instance (without saving to database) + */ + create(data: Partial): ReadingProgressEntity { + return this.repository.create(data) + } + + /** + * Save (create or update) reading progress + */ + async save(progress: ReadingProgressEntity): Promise { + return this.repository.save(progress) + } + + /** + * Upsert reading progress using INSERT ... ON CONFLICT UPDATE + * More efficient than separate find + save operations + */ + async upsertProgress( + userId: string, + libraryItemId: string, + contentVersion: string | null, + lastSeenSentinel: number, + highestSeenSentinel: number, + ): Promise { + // First, try to find existing progress + const existing = await this.findProgress( + libraryItemId, + userId, + contentVersion, + ) + + if (existing) { + // Update existing record + // Always update lastSeenSentinel, but only increase highestSeenSentinel + existing.lastSeenSentinel = lastSeenSentinel + existing.highestSeenSentinel = Math.max( + existing.highestSeenSentinel, + highestSeenSentinel, + ) + return this.save(existing) + } + + // Create new record + const progress = this.create({ + userId, + libraryItemId, + contentVersion, + lastSeenSentinel, + highestSeenSentinel, + }) + + return this.save(progress) + } + + /** + * Batch find latest reading progress for multiple library items + * Optimized for DataLoader to prevent N+1 queries + */ + async findByLibraryItemIds( + libraryItemIds: string[], + userId: string, + ): Promise> { + if (libraryItemIds.length === 0) { + return new Map() + } + + // Fetch all progress records for these items in one query + const progressRecords = await this.repository + .createQueryBuilder('rp') + .where('rp.library_item_id IN (:...itemIds)', { itemIds: libraryItemIds }) + .andWhere('rp.user_id = :userId', { userId }) + .orderBy('rp.updated_at', 'DESC') + .getMany() + + // Group by library_item_id, keeping only the most recent for each + const progressMap = new Map() + for (const progress of progressRecords) { + if (!progressMap.has(progress.libraryItemId)) { + progressMap.set(progress.libraryItemId, progress) + } + } + + return progressMap + } +} diff --git a/packages/api-nest/src/repositories/repositories.module.ts b/packages/api-nest/src/repositories/repositories.module.ts index 4f50f1921..bf98eb2fb 100644 --- a/packages/api-nest/src/repositories/repositories.module.ts +++ b/packages/api-nest/src/repositories/repositories.module.ts @@ -8,6 +8,9 @@ import { Label } from '../label/entities/label.entity' import { EntityLabel } from '../label/entities/entity-label.entity' import { LabelRepository } from './label.repository' import { EntityLabelRepository } from './entity-label.repository' +import { ReadingProgressEntity } from '../reading-progress/entities/reading-progress.entity' +import { ReadingProgressRepository } from './reading-progress.repository' +import { REPOSITORY_TOKENS } from './injection-tokens' /** * RepositoriesModule @@ -24,31 +27,37 @@ import { EntityLabelRepository } from './entity-label.repository' HighlightEntity, Label, EntityLabel, + ReadingProgressEntity, ]), ], providers: [ { - provide: 'ILibraryItemRepository', + provide: REPOSITORY_TOKENS.ILibraryItemRepository, useClass: LibraryItemRepository, }, { - provide: 'IHighlightRepository', + provide: REPOSITORY_TOKENS.IHighlightRepository, useClass: HighlightRepository, }, { - provide: 'ILabelRepository', + provide: REPOSITORY_TOKENS.ILabelRepository, useClass: LabelRepository, }, { - provide: 'IEntityLabelRepository', + provide: REPOSITORY_TOKENS.IEntityLabelRepository, useClass: EntityLabelRepository, }, + { + provide: REPOSITORY_TOKENS.IReadingProgressRepository, + useClass: ReadingProgressRepository, + }, ], exports: [ - 'ILibraryItemRepository', - 'IHighlightRepository', - 'ILabelRepository', - 'IEntityLabelRepository', + REPOSITORY_TOKENS.ILibraryItemRepository, + REPOSITORY_TOKENS.IHighlightRepository, + REPOSITORY_TOKENS.ILabelRepository, + REPOSITORY_TOKENS.IEntityLabelRepository, + REPOSITORY_TOKENS.IReadingProgressRepository, TypeOrmModule, // Export TypeOrmModule to make raw repositories available in tests ], }) diff --git a/packages/api-nest/test/factories-example.e2e-spec.ts b/packages/api-nest/test/factories-example.e2e-spec.ts index a013d8297..914715b5f 100644 --- a/packages/api-nest/test/factories-example.e2e-spec.ts +++ b/packages/api-nest/test/factories-example.e2e-spec.ts @@ -7,10 +7,16 @@ * Run with: yarn test:e2e --testPathPattern=factories-example */ -import { UserFactory, LibraryItemFactory, HighlightFactory, LabelFactory } from './factories' +import { + UserFactory, + LibraryItemFactory, + HighlightFactory, + LabelFactory, +} from './factories' import { getTestDataSource } from './setup/test-datasource' import { UserRole } from '../src/user/enums/user-role.enum' import { StatusType } from '../src/user/entities/user.entity' +import { HighlightColor } from '../src/highlight/entities/highlight.entity' describe('Factory Pattern Example (e2e)', () => { it('should create test data using factories', async () => { @@ -40,11 +46,19 @@ describe('Factory Pattern Example (e2e)', () => { expect(item.slug).toBeDefined() // Slug is auto-generated by factory // Create highlights for the article - const highlight1 = await HighlightFactory.withColor(item.id, user.id, 'yellow') - const highlight2 = await HighlightFactory.withColor(item.id, user.id, 'red') + const highlight1 = await HighlightFactory.withColor( + item.id, + user.id, + HighlightColor.YELLOW, + ) + const highlight2 = await HighlightFactory.withColor( + item.id, + user.id, + HighlightColor.RED, + ) - expect(highlight1.color).toBe('yellow') - expect(highlight2.color).toBe('red') + expect(highlight1.color).toBe(HighlightColor.YELLOW) + expect(highlight2.color).toBe(HighlightColor.RED) expect(highlight1.quote).toBeDefined() // Faker-generated // Create labels @@ -91,8 +105,8 @@ describe('Factory Pattern Example (e2e)', () => { const archivedItem = await LibraryItemFactory.archived(user.id) expect(archivedItem.folder).toBe('archive') - const itemWithProgress = await LibraryItemFactory.withProgress(user.id, 75) - expect(itemWithProgress.readingProgressTopPercent).toBe(75) + const itemWithProgress = await LibraryItemFactory.withProgress(user.id, 100) + expect(itemWithProgress.readAt).toBeDefined() // 100% progress marks as read console.log('✅ Factory helper methods work perfectly!') }) diff --git a/packages/api-nest/test/factories/highlight.factory.ts b/packages/api-nest/test/factories/highlight.factory.ts index dacbf40c2..172b8ac5e 100644 --- a/packages/api-nest/test/factories/highlight.factory.ts +++ b/packages/api-nest/test/factories/highlight.factory.ts @@ -3,6 +3,7 @@ import { Repository } from 'typeorm' import { HighlightEntity, HighlightType, + HighlightColor, } from '../../src/highlight/entities/highlight.entity' import { BaseFactory, getTestRepository } from './base.factory' @@ -22,19 +23,29 @@ import { BaseFactory, getTestRepository } from './base.factory' * ``` */ class HighlightFactoryClass extends BaseFactory { - protected generateDefaults() { + protected generateDefaults(): Partial { const shortTimestamp = Date.now().toString().slice(-8) + const quote = faker.lorem.sentence() return { id: faker.string.uuid(), shortId: `h${shortTimestamp}${faker.string.alphanumeric(2)}`, - quote: faker.lorem.sentence(), + quote, prefix: faker.lorem.words(3), suffix: faker.lorem.words(3), highlightPositionPercent: faker.number.int({ min: 10, max: 90 }), highlightPositionAnchorIndex: faker.number.int({ min: 0, max: 100 }), - color: 'yellow', + color: HighlightColor.YELLOW, highlightType: HighlightType.HIGHLIGHT, + // Selectors format: JSON object with textQuote.exact required by database constraint + // Match the format from highlight.service.ts + selectors: { + textQuote: { + exact: quote, + prefix: faker.lorem.words(3), + suffix: faker.lorem.words(3), + }, + }, createdAt: new Date(), updatedAt: new Date(), // These will be set by the caller @@ -55,7 +66,7 @@ class HighlightFactoryClass extends BaseFactory { async withColor( libraryItemId: string, userId: string, - color: string, + color: HighlightColor, overrides: Partial = {}, ): Promise { return this.create({ @@ -128,7 +139,7 @@ class HighlightFactoryClass extends BaseFactory { buildWithColor( libraryItemId: string, userId: string, - color: string, + color: HighlightColor, overrides: Partial = {}, ): HighlightEntity { return this.build({ diff --git a/packages/api-nest/test/factories/library-item.factory.ts b/packages/api-nest/test/factories/library-item.factory.ts index 93571fea0..47f2616ce 100644 --- a/packages/api-nest/test/factories/library-item.factory.ts +++ b/packages/api-nest/test/factories/library-item.factory.ts @@ -45,13 +45,11 @@ class LibraryItemFactoryClass extends BaseFactory { // ARC-009: Add metadata fields for frontend library feature parity author: faker.person.fullName(), description: faker.lorem.paragraph(), - thumbnail: faker.image.url({ width: 640, height: 480, category: 'tech' }), + thumbnail: faker.image.url({ width: 640, height: 480 }), wordCount: faker.number.int({ min: 300, max: 5000 }), siteName: faker.company.name(), siteIcon: `https://${domain}/favicon.ico`, publishedAt: faker.date.past({ years: 1 }), - readingProgressTopPercent: 0, - readingProgressBottomPercent: 0, readingProgressLastReadAnchor: 0, readingProgressHighestReadAnchor: 0, readableContent: faker.lorem.paragraphs(5), @@ -111,8 +109,6 @@ class LibraryItemFactoryClass extends BaseFactory { return this.create({ userId, - readingProgressTopPercent: percentComplete, - readingProgressBottomPercent: Math.min(percentComplete + 5, 100), readAt, ...overrides, }) @@ -189,8 +185,6 @@ class LibraryItemFactoryClass extends BaseFactory { ): LibraryItemEntity { return this.build({ userId, - readingProgressTopPercent: percentComplete, - readingProgressBottomPercent: Math.min(percentComplete + 5, 100), readAt: percentComplete === 100 ? new Date() : null, ...overrides, }) diff --git a/packages/api-nest/test/highlight.e2e-spec.ts b/packages/api-nest/test/highlight.e2e-spec.ts index 370a83a82..1efb13b1b 100644 --- a/packages/api-nest/test/highlight.e2e-spec.ts +++ b/packages/api-nest/test/highlight.e2e-spec.ts @@ -11,7 +11,12 @@ import { LibraryItemEntity, LibraryItemState, } from '../src/library/entities/library-item.entity' -import { HighlightEntity } from '../src/highlight/entities/highlight.entity' +import { + HighlightEntity, + HighlightColor, + RepresentationType, + HighlightType, +} from '../src/highlight/entities/highlight.entity' import { FOLDERS } from '../src/constants/folders.constants' const HIGHLIGHTS_QUERY = ` @@ -23,6 +28,8 @@ const HIGHLIGHTS_QUERY = ` annotation color highlightPositionPercent + selectors + contentVersion createdAt updatedAt } @@ -49,6 +56,8 @@ const CREATE_HIGHLIGHT_MUTATION = ` annotation color highlightPositionPercent + selectors + contentVersion createdAt } } @@ -141,7 +150,8 @@ describe('Highlight GraphQL (e2e)', () => { contentReader: ContentReaderType.WEB, folder: FOLDERS.INBOX, itemType: 'ARTICLE', - readableContent: 'This is the content of the article that can be highlighted.', + readableContent: + 'This is the content of the article that can be highlighted.', }) const saved = await libraryRepository.save(testItem) @@ -152,17 +162,18 @@ describe('Highlight GraphQL (e2e)', () => { await app.close() }, 30000) - const executeQuery = (query: string, variables: Record = {}) => + const executeQuery = ( + query: string, + variables: Record = {}, + ) => request(app.getHttpServer()) .post('/api/graphql') .set('Authorization', `Bearer ${authToken}`) .send({ query, variables }) - .expect(200) + .expect(200) as Promise describe('Query highlights', () => { beforeAll(async () => { - // Create test highlights with different colors - // Use shorter timestamp (last 8 digits) to fit in varchar(14) constraint const shortTimestamp = Date.now().toString().slice(-8) const highlights = [ { @@ -174,11 +185,12 @@ describe('Highlight GraphQL (e2e)', () => { shortId: `t${shortTimestamp}1`, quote: 'First important quote', annotation: 'This is significant', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 10, highlightPositionAnchorIndex: 0, - highlightType: 'HIGHLIGHT' as any, - representation: 'CONTENT' as any, + highlightType: HighlightType.HIGHLIGHT, + representation: RepresentationType.CONTENT, + selectors: { textQuote: { exact: 'First important quote' } }, }, { id: randomUUID(), @@ -189,11 +201,12 @@ describe('Highlight GraphQL (e2e)', () => { shortId: `t${shortTimestamp}2`, quote: 'Second important quote', annotation: 'Very interesting', - color: 'green', + color: HighlightColor.GREEN, highlightPositionPercent: 25, highlightPositionAnchorIndex: 0, highlightType: 'HIGHLIGHT' as any, representation: 'CONTENT' as any, + selectors: { textQuote: { exact: 'Second important quote' } }, }, { id: randomUUID(), @@ -203,11 +216,12 @@ describe('Highlight GraphQL (e2e)', () => { libraryItem: { id: testLibraryItemId } as any, shortId: `t${shortTimestamp}3`, quote: 'Third important quote', - color: 'red', + color: HighlightColor.RED, highlightPositionPercent: 50, highlightPositionAnchorIndex: 0, highlightType: 'HIGHLIGHT' as any, representation: 'CONTENT' as any, + selectors: { textQuote: { exact: 'Third important quote' } }, }, { id: randomUUID(), @@ -218,11 +232,12 @@ describe('Highlight GraphQL (e2e)', () => { shortId: `t${shortTimestamp}4`, quote: 'Fourth important quote', annotation: 'Key insight', - color: 'blue', + color: HighlightColor.BLUE, highlightPositionPercent: 75, highlightPositionAnchorIndex: 0, highlightType: 'HIGHLIGHT' as any, representation: 'CONTENT' as any, + selectors: { textQuote: { exact: 'Fourth important quote' } }, }, ] @@ -260,10 +275,10 @@ describe('Highlight GraphQL (e2e)', () => { expect(response.body.errors).toBeUndefined() const colors = response.body.data.highlights.map((h: any) => h.color) - expect(colors).toContain('yellow') - expect(colors).toContain('green') - expect(colors).toContain('red') - expect(colors).toContain('blue') + expect(colors).toContain(HighlightColor.YELLOW) + expect(colors).toContain(HighlightColor.GREEN) + expect(colors).toContain(HighlightColor.RED) + expect(colors).toContain(HighlightColor.BLUE) }) it('retrieves a single highlight by id', async () => { @@ -284,7 +299,7 @@ describe('Highlight GraphQL (e2e)', () => { id: existing!.id, quote: 'First important quote', annotation: 'This is significant', - color: 'yellow', + color: HighlightColor.YELLOW, }) }) @@ -329,7 +344,7 @@ describe('Highlight GraphQL (e2e)', () => { expect(response.body.data.createHighlight).toMatchObject({ quote: 'New highlight quote', annotation: 'My thoughts', - color: 'yellow', + color: HighlightColor.YELLOW, }) expect(response.body.data.createHighlight.id).toBeTruthy() expect(response.body.data.createHighlight.shortId).toBeTruthy() @@ -338,7 +353,7 @@ describe('Highlight GraphQL (e2e)', () => { const highlight = await highlightRepository.findOneBy({ id: response.body.data.createHighlight.id, }) - expect(highlight?.color).toBe('yellow') + expect(highlight?.color).toBe(HighlightColor.YELLOW) }) it('creates a highlight with red color', async () => { @@ -346,7 +361,7 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: testLibraryItemId, quote: 'Important red highlight', - color: 'red', + color: HighlightColor.RED, highlightPositionPercent: 42, }, }) @@ -354,14 +369,14 @@ describe('Highlight GraphQL (e2e)', () => { expect(response.body.errors).toBeUndefined() expect(response.body.data.createHighlight).toMatchObject({ quote: 'Important red highlight', - color: 'red', + color: HighlightColor.RED, }) // Verify in database const highlight = await highlightRepository.findOneBy({ id: response.body.data.createHighlight.id, }) - expect(highlight?.color).toBe('red') + expect(highlight?.color).toBe(HighlightColor.RED) }) it('creates a highlight with green color', async () => { @@ -369,13 +384,15 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: testLibraryItemId, quote: 'Positive green highlight', - color: 'green', + color: HighlightColor.GREEN, highlightPositionPercent: 55, }, }) expect(response.body.errors).toBeUndefined() - expect(response.body.data.createHighlight.color).toBe('green') + expect(response.body.data.createHighlight.color).toBe( + HighlightColor.GREEN, + ) }) it('creates a highlight with blue color', async () => { @@ -383,13 +400,13 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: testLibraryItemId, quote: 'Information blue highlight', - color: 'blue', + color: HighlightColor.BLUE, highlightPositionPercent: 68, }, }) expect(response.body.errors).toBeUndefined() - expect(response.body.data.createHighlight.color).toBe('blue') + expect(response.body.data.createHighlight.color).toBe(HighlightColor.BLUE) }) it('creates a highlight without annotation', async () => { @@ -397,7 +414,7 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: testLibraryItemId, quote: 'Quote without annotation', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 20, }, }) @@ -416,7 +433,7 @@ describe('Highlight GraphQL (e2e)', () => { quote: 'highlighted text', prefix: 'This is the ', suffix: ' with context', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 30, }, }) @@ -452,7 +469,7 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: randomUUID(), quote: 'Test quote', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 40, }, }) @@ -466,7 +483,7 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: testLibraryItemId, quote: 'First quote', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 11, }, }) @@ -475,7 +492,7 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: testLibraryItemId, quote: 'Second quote', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 12, }, }) @@ -499,11 +516,12 @@ describe('Highlight GraphQL (e2e)', () => { shortId: `u${Date.now().toString().slice(-8)}`, quote: 'Original quote', annotation: 'Original annotation', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 50, highlightPositionAnchorIndex: 0, highlightType: 'HIGHLIGHT' as any, representation: 'CONTENT' as any, + selectors: { textQuote: { exact: 'Original quote' } }, }) const saved = await highlightRepository.save(highlight) @@ -520,31 +538,35 @@ describe('Highlight GraphQL (e2e)', () => { expect(response.body.data.updateHighlight).toMatchObject({ id: testHighlightId, annotation: 'Updated annotation', - color: 'yellow', // Unchanged + color: HighlightColor.YELLOW, // Unchanged }) // Verify in database - const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + const highlight = await highlightRepository.findOneBy({ + id: testHighlightId, + }) expect(highlight?.annotation).toBe('Updated annotation') - expect(highlight?.color).toBe('yellow') + expect(highlight?.color).toBe(HighlightColor.YELLOW) }) it('updates highlight color from yellow to red', async () => { const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { id: testHighlightId, - input: { color: 'red' }, + input: { color: HighlightColor.RED }, }) expect(response.body.errors).toBeUndefined() expect(response.body.data.updateHighlight).toMatchObject({ id: testHighlightId, - color: 'red', + color: HighlightColor.RED, annotation: 'Original annotation', // Unchanged }) // Verify in database - const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) - expect(highlight?.color).toBe('red') + const highlight = await highlightRepository.findOneBy({ + id: testHighlightId, + }) + expect(highlight?.color).toBe(HighlightColor.RED) expect(highlight?.annotation).toBe('Original annotation') }) @@ -553,7 +575,7 @@ describe('Highlight GraphQL (e2e)', () => { id: testHighlightId, input: { annotation: 'New annotation', - color: 'blue', + color: HighlightColor.BLUE, }, }) @@ -561,13 +583,15 @@ describe('Highlight GraphQL (e2e)', () => { expect(response.body.data.updateHighlight).toMatchObject({ id: testHighlightId, annotation: 'New annotation', - color: 'blue', + color: HighlightColor.BLUE, }) // Verify in database - const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + const highlight = await highlightRepository.findOneBy({ + id: testHighlightId, + }) expect(highlight?.annotation).toBe('New annotation') - expect(highlight?.color).toBe('blue') + expect(highlight?.color).toBe(HighlightColor.BLUE) }) it('clears annotation with empty string', async () => { @@ -580,12 +604,19 @@ describe('Highlight GraphQL (e2e)', () => { expect(response.body.data.updateHighlight.annotation).toBe('') // Verify in database - const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) + const highlight = await highlightRepository.findOneBy({ + id: testHighlightId, + }) expect(highlight?.annotation).toBe('') }) it('cycles through all color options', async () => { - const colors = ['yellow', 'red', 'green', 'blue'] + const colors = [ + HighlightColor.YELLOW, + HighlightColor.RED, + HighlightColor.GREEN, + HighlightColor.BLUE, + ] for (const color of colors) { const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { @@ -598,14 +629,16 @@ describe('Highlight GraphQL (e2e)', () => { } // Verify final state in database - const highlight = await highlightRepository.findOneBy({ id: testHighlightId }) - expect(highlight?.color).toBe('blue') + const highlight = await highlightRepository.findOneBy({ + id: testHighlightId, + }) + expect(highlight?.color).toBe(HighlightColor.BLUE) }) it('returns error for invalid color', async () => { const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { id: testHighlightId, - input: { color: 'orange' }, // Invalid color + input: { color: 'orange' as any as HighlightColor }, // Invalid color }) expect(response.body.errors).toBeDefined() @@ -622,7 +655,9 @@ describe('Highlight GraphQL (e2e)', () => { }) it('updates updatedAt timestamp', async () => { - const before = await highlightRepository.findOneBy({ id: testHighlightId }) + const before = await highlightRepository.findOneBy({ + id: testHighlightId, + }) const originalUpdatedAt = before!.updatedAt // Wait a bit to ensure timestamp difference @@ -650,11 +685,12 @@ describe('Highlight GraphQL (e2e)', () => { libraryItem: { id: testLibraryItemId } as any, shortId: `d${Date.now().toString().slice(-8)}`, quote: 'To be deleted', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 50, highlightPositionAnchorIndex: 0, highlightType: 'HIGHLIGHT' as any, representation: 'CONTENT' as any, + selectors: { textQuote: { exact: 'To be deleted' } }, }) const saved = await highlightRepository.save(highlight) @@ -693,7 +729,7 @@ describe('Highlight GraphQL (e2e)', () => { libraryItemId: testLibraryItemId, quote: 'Critical information', annotation: 'Must remember', - color: 'red', + color: HighlightColor.RED, highlightPositionPercent: 15, }, }) @@ -703,7 +739,7 @@ describe('Highlight GraphQL (e2e)', () => { libraryItemId: testLibraryItemId, quote: 'To do item', annotation: 'Action required', - color: 'green', + color: HighlightColor.GREEN, highlightPositionPercent: 35, }, }) @@ -713,14 +749,20 @@ describe('Highlight GraphQL (e2e)', () => { libraryItemId: testLibraryItemId, quote: 'Reference material', annotation: 'For later', - color: 'blue', + color: HighlightColor.BLUE, highlightPositionPercent: 65, }, }) - expect(importantQuote.body.data.createHighlight.color).toBe('red') - expect(actionItem.body.data.createHighlight.color).toBe('green') - expect(reference.body.data.createHighlight.color).toBe('blue') + expect(importantQuote.body.data.createHighlight.color).toBe( + HighlightColor.RED, + ) + expect(actionItem.body.data.createHighlight.color).toBe( + HighlightColor.GREEN, + ) + expect(reference.body.data.createHighlight.color).toBe( + HighlightColor.BLUE, + ) // Verify all highlights are retrievable const allHighlights = await executeQuery(HIGHLIGHTS_QUERY, { @@ -728,9 +770,9 @@ describe('Highlight GraphQL (e2e)', () => { }) const colors = allHighlights.body.data.highlights.map((h: any) => h.color) - expect(colors).toContain('red') - expect(colors).toContain('green') - expect(colors).toContain('blue') + expect(colors).toContain(HighlightColor.RED) + expect(colors).toContain(HighlightColor.GREEN) + expect(colors).toContain(HighlightColor.BLUE) }) it('supports changing highlight color based on re-evaluation', async () => { @@ -739,7 +781,7 @@ describe('Highlight GraphQL (e2e)', () => { input: { libraryItemId: testLibraryItemId, quote: 'Initially interesting', - color: 'yellow', + color: HighlightColor.YELLOW, highlightPositionPercent: 45, }, }) @@ -750,15 +792,136 @@ describe('Highlight GraphQL (e2e)', () => { const updateResponse = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, { id: highlightId, input: { - color: 'red', + color: HighlightColor.RED, annotation: 'Actually very important!', }, }) expect(updateResponse.body.data.updateHighlight).toMatchObject({ - color: 'red', + color: HighlightColor.RED, annotation: 'Actually very important!', }) }) }) + + describe('Robust anchored selectors', () => { + it('creates highlight with explicit selectors JSON', async () => { + const selectors = { + textQuote: { + exact: 'highlighted text', + prefix: 'This is the ', + suffix: ' with context', + }, + domRange: { + startPath: '0/1/2', + startOffset: 5, + endPath: '0/1/2', + endOffset: 20, + }, + textPosition: { + start: 150, + end: 165, + }, + } + + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'highlighted text', + color: HighlightColor.YELLOW, + highlightPositionPercent: 30, + selectors: selectors, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight.selectors).toBeTruthy() + + expect(response.body.data.createHighlight.selectors).toMatchObject( + selectors, + ) + + // Verify in database + const highlight = await highlightRepository.findOneBy({ + id: response.body.data.createHighlight.id, + }) + expect(highlight?.selectors).toMatchObject(selectors) + }) + + it('creates highlight with contentVersion tracking', async () => { + const contentVersion = 'test-version-hash-12345' + + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'versioned highlight', + color: HighlightColor.GREEN, + highlightPositionPercent: 50, + contentVersion, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight.contentVersion).toBe( + contentVersion, + ) + + // Verify in database + const highlight = await highlightRepository.findOneBy({ + id: response.body.data.createHighlight.id, + }) + expect(highlight?.contentVersion).toBe(contentVersion) + }) + + it('creates highlight without selectors (legacy format)', async () => { + // Test backward compatibility - no selectors provided + const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'simple highlight', + prefix: 'before ', + suffix: ' after', + color: HighlightColor.BLUE, + highlightPositionPercent: 75, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.createHighlight.selectors).toBeTruthy() + + // Verify fallback to textQuote selector from quote/prefix/suffix + // GraphQL returns selectors as object, not string + expect(response.body.data.createHighlight.selectors.textQuote).toMatchObject({ + exact: 'simple highlight', + prefix: 'before ', + suffix: ' after', + }) + }) + + it('retrieves highlights with selectors correctly', async () => { + // Create a highlight with selectors + await executeQuery(CREATE_HIGHLIGHT_MUTATION, { + input: { + libraryItemId: testLibraryItemId, + quote: 'test quote', + color: HighlightColor.YELLOW, + highlightPositionPercent: 45, + selectors: JSON.stringify({ + textQuote: { exact: 'test quote' }, + }), + }, + }) + + // Query all highlights + const response = await executeQuery(HIGHLIGHTS_QUERY, { + libraryItemId: testLibraryItemId, + }) + + expect(response.body.errors).toBeUndefined() + const highlightsWithSelectors = response.body.data.highlights.filter( + (h: any) => h.selectors, + ) + expect(highlightsWithSelectors.length).toBeGreaterThan(0) + }) + }) }) diff --git a/packages/api-nest/test/jest-e2e.json b/packages/api-nest/test/jest-e2e.json index e6a2142ee..cbfb68d09 100644 --- a/packages/api-nest/test/jest-e2e.json +++ b/packages/api-nest/test/jest-e2e.json @@ -21,5 +21,7 @@ "globalSetup": "/setup/global-setup.ts", "globalTeardown": "/setup/global-teardown.ts", "setupFilesAfterEnv": ["/setup/jest-environment-setup.ts"], - "testTimeout": 60000 + "testTimeout": 60000, + "silent": false, + "verbose": true } diff --git a/packages/api-nest/test/library-arc009.e2e-spec.ts b/packages/api-nest/test/library-arc009.e2e-spec.ts index 82df6e155..019b9da79 100644 --- a/packages/api-nest/test/library-arc009.e2e-spec.ts +++ b/packages/api-nest/test/library-arc009.e2e-spec.ts @@ -104,18 +104,18 @@ describe('ARC-009: Frontend Library Feature Parity (e2e)', () => { // Create item with both progress and metadata const item = await LibraryItemFactory.withFullMetadata(user.id, { - readingProgressTopPercent: 65, + readAt: new Date(), // Mark as read title: 'Partially Read Article', }) expect(item.title).toBe('Partially Read Article') - expect(item.readingProgressTopPercent).toBe(65) + expect(item.readAt).toBeDefined() expect(item.thumbnail).toBeDefined() expect(item.wordCount).toBeDefined() expect(item.siteName).toBeDefined() console.log('✅ Progress + metadata work together') - console.log(' - Progress:', item.readingProgressTopPercent, '%') + console.log(' - Read:', item.readAt ? 'Yes' : 'No') console.log(' - Word count:', item.wordCount) }) diff --git a/packages/api-nest/test/library.e2e-spec.ts b/packages/api-nest/test/library.e2e-spec.ts index 5eb0ffb01..50dc8081b 100644 --- a/packages/api-nest/test/library.e2e-spec.ts +++ b/packages/api-nest/test/library.e2e-spec.ts @@ -61,12 +61,12 @@ const DELETE_LIBRARY_ITEM_MUTATION = ` } ` +// Legacy UPDATE_READING_PROGRESS_MUTATION removed +// See reading-progress.e2e-spec.ts for sentinel-based progress tests const UPDATE_READING_PROGRESS_MUTATION = ` mutation UpdateReadingProgress($id: String!, $progress: ReadingProgressInput!) { updateReadingProgress(id: $id, progress: $progress) { id - readingProgressTopPercent - readingProgressBottomPercent readAt } } @@ -184,7 +184,10 @@ describe('Library GraphQL (e2e)', () => { await app.close() }, 30000) // 30 second timeout for graceful BullMQ worker shutdown - const executeQuery = (query: string, variables: Record = {}) => + const executeQuery = ( + query: string, + variables: Record = {}, + ) => request(app.getHttpServer()) .post('/api/graphql') .set('Authorization', `Bearer ${authToken}`) @@ -263,7 +266,10 @@ describe('Library GraphQL (e2e)', () => { }) it('retrieves a single library item by id', async () => { - const existing = await libraryRepository.findOneByOrFail({ slug: 'second-article', userId }) + const existing = await libraryRepository.findOneByOrFail({ + slug: 'second-article', + userId, + }) const response = await executeQuery(LIBRARY_ITEM_QUERY, { id: existing.id }) @@ -293,8 +299,6 @@ describe('Library GraphQL (e2e)', () => { contentReader: ContentReaderType.WEB, folder: FOLDERS.INBOX, itemType: 'ARTICLE', - readingProgressTopPercent: 0, - readingProgressBottomPercent: 0, }) const saved = await libraryRepository.save(testItem) @@ -410,76 +414,6 @@ describe('Library GraphQL (e2e)', () => { }) }) - describe('updateReadingProgress', () => { - it('updates reading progress', async () => { - const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { - id: testItemId, - progress: { - readingProgressTopPercent: 50, - readingProgressBottomPercent: 45, - readingProgressAnchorIndex: 100, - readingProgressHighestAnchor: 150, - }, - }) - - expect(response.body.errors).toBeUndefined() - expect(response.body.data.updateReadingProgress).toMatchObject({ - id: testItemId, - readingProgressTopPercent: 50, - readingProgressBottomPercent: 45, - }) - - // Verify in database - const item = await libraryRepository.findOneBy({ id: testItemId }) - expect(item?.readingProgressTopPercent).toBe(50) - expect(item?.readingProgressBottomPercent).toBe(45) - expect(item?.readingProgressLastReadAnchor).toBe(100) - expect(item?.readingProgressHighestReadAnchor).toBe(150) - }) - - it('marks item as read when progress reaches 100%', async () => { - const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { - id: testItemId, - progress: { - readingProgressTopPercent: 100, - readingProgressBottomPercent: 100, - }, - }) - - expect(response.body.errors).toBeUndefined() - expect(response.body.data.updateReadingProgress.readAt).toBeTruthy() - - // Verify in database - const item = await libraryRepository.findOneBy({ id: testItemId }) - expect(item?.readAt).toBeTruthy() - }) - - it('returns error for invalid progress percentage', async () => { - const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { - id: testItemId, - progress: { - readingProgressTopPercent: 150, // Invalid: > 100 - readingProgressBottomPercent: 45, - }, - }) - - expect(response.body.errors).toBeDefined() - }) - - it('returns error for non-existent item', async () => { - const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { - id: randomUUID(), - progress: { - readingProgressTopPercent: 50, - readingProgressBottomPercent: 45, - }, - }) - - expect(response.body.errors).toBeDefined() - expect(response.body.errors[0].message).toContain('not found') - }) - }) - describe('moveLibraryItemToFolder', () => { it('moves item to archive', async () => { const response = await executeQuery( @@ -669,9 +603,9 @@ describe('Library GraphQL (e2e)', () => { }) expect(response.body.errors).toBeUndefined() - expect(response.body.data.libraryItems.items.length).toBeGreaterThanOrEqual( - 2, - ) + expect( + response.body.data.libraryItems.items.length, + ).toBeGreaterThanOrEqual(2) expect( response.body.data.libraryItems.items.every( (item: any) => item.author === 'John Doe', @@ -728,7 +662,9 @@ describe('Library GraphQL (e2e)', () => { expect(response.body.errors).toBeUndefined() const items = response.body.data.libraryItems.items - expect(items.every((item: any) => item.folder === FOLDERS.INBOX)).toBe(true) + expect(items.every((item: any) => item.folder === FOLDERS.INBOX)).toBe( + true, + ) expect(items.every((item: any) => item.author === 'John Doe')).toBe(true) }) @@ -773,7 +709,9 @@ describe('Library GraphQL (e2e)', () => { expect(response.body.errors).toBeUndefined() expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0) - expect(response.body.data.libraryItems.items[0].title).toContain('GraphQL') + expect(response.body.data.libraryItems.items[0].title).toContain( + 'GraphQL', + ) }) it('supports pagination with search filters', async () => { @@ -843,7 +781,9 @@ describe('Library GraphQL (e2e)', () => { successCount: 3, failureCount: 0, }) - expect(response.body.data.bulkArchiveItems.message).toContain('archived') + expect(response.body.data.bulkArchiveItems.message).toContain( + 'archived', + ) // Verify items are archived const archivedItems = await libraryRepository.find({ @@ -887,7 +827,9 @@ describe('Library GraphQL (e2e)', () => { }) expect(response.body.errors).toBeDefined() - expect(response.body.errors[0].message).toContain('No item IDs provided') + expect(response.body.errors[0].message).toContain( + 'No item IDs provided', + ) }) it('handles partial success gracefully', async () => { @@ -899,7 +841,9 @@ describe('Library GraphQL (e2e)', () => { }) expect(response.body.errors).toBeUndefined() - expect(response.body.data.bulkArchiveItems.successCount).toBeGreaterThan(0) + expect( + response.body.data.bulkArchiveItems.successCount, + ).toBeGreaterThan(0) }) }) @@ -932,7 +876,9 @@ describe('Library GraphQL (e2e)', () => { }) expect(response.body.errors).toBeDefined() - expect(response.body.errors[0].message).toContain('No item IDs provided') + expect(response.body.errors[0].message).toContain( + 'No item IDs provided', + ) }) }) @@ -1000,7 +946,9 @@ describe('Library GraphQL (e2e)', () => { }) expect(response.body.errors).toBeDefined() - expect(response.body.errors[0].message).toContain('No item IDs provided') + expect(response.body.errors[0].message).toContain( + 'No item IDs provided', + ) }) }) @@ -1025,8 +973,6 @@ describe('Library GraphQL (e2e)', () => { }) expect(markedItem?.readAt).toBeDefined() expect(markedItem?.readAt).toBeInstanceOf(Date) - expect(markedItem?.readingProgressTopPercent).toBe(100) - expect(markedItem?.readingProgressBottomPercent).toBe(100) }) it('returns error for empty itemIds array', async () => { @@ -1035,7 +981,9 @@ describe('Library GraphQL (e2e)', () => { }) expect(response.body.errors).toBeDefined() - expect(response.body.errors[0].message).toContain('No item IDs provided') + expect(response.body.errors[0].message).toContain( + 'No item IDs provided', + ) }) }) diff --git a/packages/api-nest/test/notebook.e2e-spec.ts b/packages/api-nest/test/notebook.e2e-spec.ts index 873436b40..963fe1dde 100644 --- a/packages/api-nest/test/notebook.e2e-spec.ts +++ b/packages/api-nest/test/notebook.e2e-spec.ts @@ -275,15 +275,15 @@ describe('Notebook GraphQL (e2e)', () => { const noteContent = 'My preserved notes' await libraryRepository.update(testItemId, { note: noteContent }) - // Update reading progress (different field) + // Update another field to test note preservation await libraryRepository.update(testItemId, { - readingProgressTopPercent: 50, + title: 'Updated Title', }) // Verify notebook is preserved const item = await libraryRepository.findOneBy({ id: testItemId }) expect(item?.note).toBe(noteContent) - expect(item?.readingProgressTopPercent).toBe(50) + expect(item?.title).toBe('Updated Title') }) }) }) diff --git a/packages/api-nest/test/reading-progress.e2e-spec.ts b/packages/api-nest/test/reading-progress.e2e-spec.ts new file mode 100644 index 000000000..e01262d1c --- /dev/null +++ b/packages/api-nest/test/reading-progress.e2e-spec.ts @@ -0,0 +1,465 @@ +import { randomUUID } from 'crypto' +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication, ValidationPipe } from '@nestjs/common' +import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import request from 'supertest' +import { Repository } from 'typeorm' +import { AppModule } from '../src/app/app.module' +import { testDatabaseConfig } from '../src/config/test.config' +import { + ContentReaderType, + LibraryItemEntity, + LibraryItemState, +} from '../src/library/entities/library-item.entity' +import { ReadingProgressEntity } from '../src/reading-progress/entities/reading-progress.entity' +import { FOLDERS } from '../src/constants/folders.constants' + +const GET_READING_PROGRESS_QUERY = ` + query GetReadingProgress($libraryItemId: String!, $contentVersion: String) { + readingProgress(libraryItemId: $libraryItemId, contentVersion: $contentVersion) { + id + libraryItemId + contentVersion + lastSeenSentinel + highestSeenSentinel + createdAt + updatedAt + } + } +` + +const UPDATE_READING_PROGRESS_MUTATION = ` + mutation UpdateReadingProgress($input: UpdateReadingProgressInput!) { + updateReadingProgress(input: $input) { + id + libraryItemId + contentVersion + lastSeenSentinel + highestSeenSentinel + createdAt + updatedAt + } + } +` + +describe('ReadingProgress GraphQL (e2e)', () => { + let app: INestApplication + let authToken: string + let userId: string + let libraryRepository: Repository + let progressRepository: Repository + let testItemId: string + let testContentVersion: string + + beforeAll(async () => { + // Set required environment variables for tests + process.env.GOOGLE_CLIENT_ID = 'test-client-id' + process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' + process.env.JWT_SECRET = 'test-jwt-secret' + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideModule(TypeOrmModule) + .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) + .compile() + + app = moduleFixture.createNestApplication() + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + await app.init() + + libraryRepository = moduleFixture.get>( + getRepositoryToken(LibraryItemEntity), + ) + progressRepository = moduleFixture.get>( + getRepositoryToken(ReadingProgressEntity), + ) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(async () => { + // Note: Don't clear tables here due to foreign key constraints + // Each test creates a unique user, so data is isolated + + // Create a test user and get auth token via REST endpoint + const registerResponse = await request(app.getHttpServer()) + .post('/api/v2/auth/register') + .send({ + email: `reading-progress-test-${Date.now()}@omnivore.app`, + name: 'Reading Progress Test User', + password: 'testPassword123', + }) + .expect(201) + + authToken = registerResponse.body.accessToken + userId = registerResponse.body.user.id + }) + + const executeQuery = async (query: string, variables?: any) => { + return request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ query, variables }) + } + + describe('Sentinel-based Reading Progress', () => { + beforeEach(async () => { + // Create a test library item + testContentVersion = 'test-hash-' + randomUUID() + const testItem = libraryRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'Test Article for Reading Progress', + slug: 'test-article-reading-progress', + originalUrl: 'https://example.com/test-article', + author: 'Test Author', + description: 'Test description', + savedAt: new Date(), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: FOLDERS.INBOX, + itemType: 'ARTICLE', + contentHash: testContentVersion, + }) + + const saved = await libraryRepository.save(testItem) + testItemId = saved.id + }) + + describe('updateReadingProgress', () => { + it('creates new reading progress record', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 25, + highestSeenSentinel: 30, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateReadingProgress).toMatchObject({ + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 25, + highestSeenSentinel: 30, + }) + expect(response.body.data.updateReadingProgress.id).toBeTruthy() + + // Verify in database + const progress = await progressRepository.findOne({ + where: { + libraryItemId: testItemId, + userId, + contentVersion: testContentVersion, + }, + }) + expect(progress).toBeTruthy() + expect(progress?.lastSeenSentinel).toBe(25) + expect(progress?.highestSeenSentinel).toBe(30) + }) + + it('updates existing reading progress record', async () => { + // Create initial progress + await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 10, + highestSeenSentinel: 15, + }, + }) + + // Update progress (user scrolled further) + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 25, + highestSeenSentinel: 30, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateReadingProgress).toMatchObject({ + libraryItemId: testItemId, + lastSeenSentinel: 25, + highestSeenSentinel: 30, + }) + + // Verify only one record exists in database + const progressRecords = await progressRepository.find({ + where: { + libraryItemId: testItemId, + userId, + }, + }) + expect(progressRecords).toHaveLength(1) + }) + + it('tracks highest sentinel correctly when user scrolls backwards', async () => { + // User scrolls to sentinel 50 + await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 50, + highestSeenSentinel: 50, + }, + }) + + // User scrolls back to sentinel 20 (but highest should remain 50) + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 20, + highestSeenSentinel: 50, // Frontend should send the max + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateReadingProgress).toMatchObject({ + lastSeenSentinel: 20, // Current position + highestSeenSentinel: 50, // Max ever reached + }) + }) + + it('handles multiple content versions for the same item', async () => { + const contentV1 = 'version-1-hash' + const contentV2 = 'version-2-hash' + + // Save progress for version 1 + await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: contentV1, + lastSeenSentinel: 25, + highestSeenSentinel: 30, + }, + }) + + // Save progress for version 2 (content changed) + await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: contentV2, + lastSeenSentinel: 10, + highestSeenSentinel: 15, + }, + }) + + // Verify both records exist + const v1Progress = await progressRepository.findOne({ + where: { + libraryItemId: testItemId, + userId, + contentVersion: contentV1, + }, + }) + const v2Progress = await progressRepository.findOne({ + where: { + libraryItemId: testItemId, + userId, + contentVersion: contentV2, + }, + }) + + expect(v1Progress).toBeTruthy() + expect(v1Progress?.lastSeenSentinel).toBe(25) + expect(v2Progress).toBeTruthy() + expect(v2Progress?.lastSeenSentinel).toBe(10) + }) + + it('accepts progress without content version (null)', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + lastSeenSentinel: 15, + highestSeenSentinel: 20, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect( + response.body.data.updateReadingProgress.contentVersion, + ).toBeNull() + }) + + it('returns error for negative sentinel values', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: -5, + highestSeenSentinel: 10, + }, + }) + + expect(response.body.errors).toBeDefined() + }) + + it('returns error for non-existent library item', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: randomUUID(), + contentVersion: testContentVersion, + lastSeenSentinel: 10, + highestSeenSentinel: 15, + }, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + }) + + describe('readingProgress query', () => { + beforeEach(async () => { + // Create some test progress data + await progressRepository.save( + progressRepository.create({ + id: randomUUID(), + userId, + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 42, + highestSeenSentinel: 55, + }), + ) + }) + + it('retrieves reading progress by library item ID and content version', async () => { + const response = await executeQuery(GET_READING_PROGRESS_QUERY, { + libraryItemId: testItemId, + contentVersion: testContentVersion, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.readingProgress).toMatchObject({ + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 42, + highestSeenSentinel: 55, + }) + }) + + it('retrieves latest progress when content version not provided', async () => { + // Create progress for multiple versions + await progressRepository.save( + progressRepository.create({ + id: randomUUID(), + userId, + libraryItemId: testItemId, + contentVersion: 'older-version', + lastSeenSentinel: 10, + highestSeenSentinel: 20, + updatedAt: new Date(Date.now() - 10000), // 10 seconds ago + }), + ) + + const response = await executeQuery(GET_READING_PROGRESS_QUERY, { + libraryItemId: testItemId, + }) + + expect(response.body.errors).toBeUndefined() + // Should return the more recent one (testContentVersion) + expect(response.body.data.readingProgress.contentVersion).toBe( + testContentVersion, + ) + }) + + it('returns null for non-existent progress', async () => { + const response = await executeQuery(GET_READING_PROGRESS_QUERY, { + libraryItemId: randomUUID(), + contentVersion: 'non-existent', + }) + + expect(response.body.errors).toBeDefined() + // Should get error because library item doesn't exist + }) + + it('returns null when no progress exists for given version', async () => { + const response = await executeQuery(GET_READING_PROGRESS_QUERY, { + libraryItemId: testItemId, + contentVersion: 'different-version', + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.readingProgress).toBeNull() + }) + }) + + describe('End-to-end reading flow', () => { + it('simulates complete reading session with position restoration', async () => { + // Step 1: User starts reading (sentinel 0) + await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 0, + highestSeenSentinel: 0, + }, + }) + + // Step 2: User scrolls to middle of article (sentinel 25) + await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 25, + highestSeenSentinel: 25, + }, + }) + + // Step 3: User leaves and comes back - fetch progress + const getProgressResponse = await executeQuery( + GET_READING_PROGRESS_QUERY, + { + libraryItemId: testItemId, + contentVersion: testContentVersion, + }, + ) + + expect( + getProgressResponse.body.data.readingProgress.lastSeenSentinel, + ).toBe(25) + + // Step 4: User continues reading to end (sentinel 50) + await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + input: { + libraryItemId: testItemId, + contentVersion: testContentVersion, + lastSeenSentinel: 50, + highestSeenSentinel: 50, + }, + }) + + // Step 5: Verify final progress + const finalProgressResponse = await executeQuery( + GET_READING_PROGRESS_QUERY, + { + libraryItemId: testItemId, + contentVersion: testContentVersion, + }, + ) + + expect( + finalProgressResponse.body.data.readingProgress.highestSeenSentinel, + ).toBe(50) + }) + }) + }) +}) diff --git a/packages/api-nest/test/setup/jest-environment-setup.ts b/packages/api-nest/test/setup/jest-environment-setup.ts index 72ca5727d..87460dcf0 100644 --- a/packages/api-nest/test/setup/jest-environment-setup.ts +++ b/packages/api-nest/test/setup/jest-environment-setup.ts @@ -3,6 +3,7 @@ * Runs in each test worker to initialize the test DataSource */ +import './test-logger-config' // Import logger configuration to suppress noise import { DataSource } from 'typeorm' import { User } from '../../src/user/entities/user.entity' import { UserProfile } from '../../src/user/entities/profile.entity' diff --git a/packages/api-nest/test/setup/test-logger-config.ts b/packages/api-nest/test/setup/test-logger-config.ts new file mode 100644 index 000000000..669930f58 --- /dev/null +++ b/packages/api-nest/test/setup/test-logger-config.ts @@ -0,0 +1,71 @@ +/** + * Test Logger Configuration + * Suppresses non-critical logs during test execution to reduce noise + */ + +// Store original console methods +const originalConsoleLog = console.log +const originalConsoleError = console.error +const originalConsoleWarn = console.warn + +// Patterns to suppress +const SUPPRESS_PATTERNS = [ + /Incoming HTTP request/, + /HTTP request completed/, + /User (login|registration)/, + /User login (attempt|successful)/, + /Failed to fetch content/, + /Job .* failed/, + /Content fetch failed/, + /\[Nest\]/, + /executeQuery/, +] + +/** + * Check if a message should be suppressed + */ +function shouldSuppress(message: string): boolean { + return SUPPRESS_PATTERNS.some((pattern) => pattern.test(message)) +} + +/** + * Filter console.log to suppress noise + */ +console.log = (...args: any[]) => { + const message = args.join(' ') + if (!shouldSuppress(message)) { + originalConsoleLog.apply(console, args) + } +} + +/** + * Filter console.error to only show test-relevant errors + */ +console.error = (...args: any[]) => { + const message = args.join(' ') + // Only suppress specific error patterns, keep test assertion errors + if ( + !message.includes('ContentProcessorService') && + !message.includes('EventBusService') && + !message.includes('Query failed: INSERT') + ) { + originalConsoleError.apply(console, args) + } +} + +/** + * Filter console.warn + */ +console.warn = (...args: any[]) => { + const message = args.join(' ') + if (!shouldSuppress(message)) { + originalConsoleWarn.apply(console, args) + } +} + +// Export for potential restoration in specific tests +export const restoreConsole = () => { + console.log = originalConsoleLog + console.error = originalConsoleError + console.warn = originalConsoleWarn +} diff --git a/packages/api-nest/test/setup/testcontainers.ts b/packages/api-nest/test/setup/testcontainers.ts index f2245fb17..ebe93a2dc 100644 --- a/packages/api-nest/test/setup/testcontainers.ts +++ b/packages/api-nest/test/setup/testcontainers.ts @@ -11,6 +11,7 @@ import { LibraryItemEntity } from '../../src/library/entities/library-item.entit import { Label } from '../../src/label/entities/label.entity' import { EntityLabel } from '../../src/label/entities/entity-label.entity' import { HighlightEntity } from '../../src/highlight/entities/highlight.entity' +import { ReadingProgressEntity } from '../../src/reading-progress/entities/reading-progress.entity' let container: StartedPostgreSqlContainer | null = null let dataSource: DataSource | null = null @@ -55,6 +56,7 @@ export async function setupTestContainer(): Promise<{ Label, EntityLabel, HighlightEntity, + ReadingProgressEntity, ], synchronize: false, // We'll call synchronize() manually after creating schema logging: false, // Disable logging for cleaner test output @@ -139,6 +141,7 @@ export async function cleanDatabase(): Promise { // Truncate all tables const tables = [ 'omnivore.entity_label', + 'omnivore.reading_progress', 'omnivore.highlight', 'omnivore.label', 'omnivore.library_item', diff --git a/packages/db/migrations/0193.do.add_highlight_selectors.sql b/packages/db/migrations/0193.do.add_highlight_selectors.sql new file mode 100644 index 000000000..0c94595b6 --- /dev/null +++ b/packages/db/migrations/0193.do.add_highlight_selectors.sql @@ -0,0 +1,86 @@ +-- Type: DO +-- Name: add_highlight_selectors +-- Description: Add robust anchored selectors (JSONB) to highlight table for multi-strategy text positioning + +BEGIN; + +-- 1) Create highlight_color enum if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'highlight_color') THEN + CREATE TYPE omnivore.highlight_color AS ENUM ('yellow', 'red', 'green', 'blue'); + END IF; +END$$; + +-- 2) Add new columns for robust highlighting +ALTER TABLE omnivore.highlight + ADD COLUMN IF NOT EXISTS selectors JSONB, + ADD COLUMN IF NOT EXISTS content_version VARCHAR(64); + +-- 3) Migrate existing highlights to new selectors format +-- Convert quote/prefix/suffix to TextQuote selector +UPDATE omnivore.highlight +SET selectors = jsonb_build_object( + 'textQuote', jsonb_build_object( + 'exact', COALESCE(quote, ''), + 'prefix', CASE WHEN prefix IS NOT NULL AND prefix != '' THEN prefix ELSE NULL END, + 'suffix', CASE WHEN suffix IS NOT NULL AND suffix != '' THEN suffix ELSE NULL END + ) +) +WHERE selectors IS NULL; + +-- 4) Make selectors NOT NULL now that all rows have data +ALTER TABLE omnivore.highlight + ALTER COLUMN selectors SET NOT NULL; + +-- 5) Add validation constraint: selectors must contain textQuote +ALTER TABLE omnivore.highlight + ADD CONSTRAINT highlight_selectors_textquote_check + CHECK (selectors ? 'textQuote' AND selectors->'textQuote' ? 'exact'); + +-- 6) Change color column from text to enum (if currently text) +-- First, set any NULL colors to default 'yellow' +UPDATE omnivore.highlight +SET color = 'yellow' +WHERE color IS NULL OR color = ''; + +-- Drop existing column and recreate with enum type +-- (Safe because we just migrated all data) +ALTER TABLE omnivore.highlight + ALTER COLUMN color TYPE omnivore.highlight_color + USING CASE + WHEN lower(color) = 'red' THEN 'red'::omnivore.highlight_color + WHEN lower(color) = 'green' THEN 'green'::omnivore.highlight_color + WHEN lower(color) = 'blue' THEN 'blue'::omnivore.highlight_color + ELSE 'yellow'::omnivore.highlight_color + END; + +-- Make color NOT NULL with default +ALTER TABLE omnivore.highlight + ALTER COLUMN color SET NOT NULL, + ALTER COLUMN color SET DEFAULT 'yellow'::omnivore.highlight_color; + +-- 7) Create indexes for common query patterns + +-- Fast lookup: user's highlights for a specific item (most common query) +CREATE INDEX IF NOT EXISTS highlight_user_item_idx + ON omnivore.highlight (user_id, library_item_id); + +-- List highlights ordered by creation time for an item +CREATE INDEX IF NOT EXISTS highlight_item_created_idx + ON omnivore.highlight (library_item_id, created_at DESC); + +-- GIN index on selectors JSONB for potential server-side text searches +-- (Optional but useful for future features like searching across highlights) +CREATE INDEX IF NOT EXISTS highlight_selectors_gin_idx + ON omnivore.highlight + USING GIN (selectors); + +-- 8) Add helpful comment +COMMENT ON COLUMN omnivore.highlight.selectors IS +'JSONB containing anchored selectors: {textQuote: {exact, prefix?, suffix?}, domRange?: {...}, textPosition?: {...}}'; + +COMMENT ON COLUMN omnivore.highlight.content_version IS +'Optional hash/version of content this highlight was created against for version tracking'; + +COMMIT; \ No newline at end of file diff --git a/packages/db/migrations/0193.undo.add_highlight_selectors.sql b/packages/db/migrations/0193.undo.add_highlight_selectors.sql new file mode 100644 index 000000000..fd0d04b2d --- /dev/null +++ b/packages/db/migrations/0193.undo.add_highlight_selectors.sql @@ -0,0 +1,35 @@ +-- Type: UNDO +-- Name: add_highlight_selectors +-- Description: Rollback addition of robust anchored selectors to highlight table + +BEGIN; + +-- 1) Drop indexes +DROP INDEX IF EXISTS omnivore.highlight_selectors_gin_idx; + +DROP INDEX IF EXISTS omnivore.highlight_item_created_idx; + +DROP INDEX IF EXISTS omnivore.highlight_user_item_idx; + +-- 2) Drop constraint +ALTER TABLE omnivore.highlight +DROP CONSTRAINT IF EXISTS highlight_selectors_textquote_check; + +-- 3) Revert color to text type (preserve data) +ALTER TABLE omnivore.highlight +ALTER COLUMN color +DROP DEFAULT, +ALTER COLUMN color +DROP NOT NULL, +ALTER COLUMN color TYPE TEXT USING color::TEXT; + +-- 4) Drop new columns +ALTER TABLE omnivore.highlight +DROP COLUMN IF EXISTS content_version, +DROP COLUMN IF EXISTS selectors; + +-- 5) Drop enum type if it was created by this migration +-- (Only drop if no other tables use it) +DROP TYPE IF EXISTS omnivore.highlight_color; + +COMMIT; \ No newline at end of file diff --git a/packages/db/migrations/0194.do.sentinel_reading_progress.sql b/packages/db/migrations/0194.do.sentinel_reading_progress.sql new file mode 100644 index 000000000..e2ecee394 --- /dev/null +++ b/packages/db/migrations/0194.do.sentinel_reading_progress.sql @@ -0,0 +1,180 @@ +-- Type: DO +-- Name: sentinel_reading_progress +-- Description: Implement sentinel-based reading progress tracking with content versioning + +BEGIN; + +-- ============================================================ +-- PART 1: Add content_hash to library_item +-- ============================================================ + +ALTER TABLE omnivore.library_item +ADD COLUMN IF NOT EXISTS content_hash VARCHAR(64); + +-- Index for fast lookups by user + item + version +CREATE INDEX IF NOT EXISTS library_item_user_content_hash_idx ON omnivore.library_item (user_id, id, content_hash); + +COMMENT ON COLUMN omnivore.library_item.content_hash IS 'SHA-256 hash of sanitized content for version tracking and cache invalidation'; + +-- ============================================================ +-- PART 2: Create reading_progress table (sentinel-based) +-- ============================================================ + + +CREATE TABLE IF NOT EXISTS omnivore.reading_progress ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v1mc(), + + user_id UUID NOT NULL + REFERENCES omnivore."user"(id) ON DELETE CASCADE, + + library_item_id UUID NOT NULL + REFERENCES omnivore.library_item(id) ON DELETE CASCADE, + +-- Content version this progress is for +content_version VARCHAR(64), + +-- Sentinel-based progress tracking +last_seen_sentinel INTEGER NOT NULL DEFAULT 0, +highest_seen_sentinel INTEGER NOT NULL DEFAULT 0, + +-- Timestamps +created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- One progress record per (user, item, content_version) +-- Use COALESCE to handle NULL versions as empty string for uniqueness +CREATE UNIQUE INDEX IF NOT EXISTS reading_progress_unique_key ON omnivore.reading_progress ( + user_id, + library_item_id, + COALESCE(content_version, '') +); + +-- Hot path: fetch progress for specific user + item + version +CREATE INDEX IF NOT EXISTS reading_progress_lookup_idx ON omnivore.reading_progress ( + user_id, + library_item_id, + content_version +); + +-- Query latest progress regardless of version +CREATE INDEX IF NOT EXISTS reading_progress_user_item_updated_idx ON omnivore.reading_progress ( + user_id, + library_item_id, + updated_at DESC +); + +-- ============================================================ +-- PART 3: Triggers for updated_at +-- ============================================================ + +-- Create or reuse the touch_updated_at function +CREATE OR REPLACE FUNCTION omnivore.touch_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := CURRENT_TIMESTAMP; + RETURN NEW; +END$$; + +-- Apply trigger to reading_progress +DROP TRIGGER IF EXISTS trig_touch_reading_progress_updated_at + ON omnivore.reading_progress; + +CREATE TRIGGER trig_touch_reading_progress_updated_at + BEFORE UPDATE ON omnivore.reading_progress + FOR EACH ROW + EXECUTE FUNCTION omnivore.touch_updated_at(); + +-- ============================================================ +-- PART 4: Migrate existing scroll-based progress (optional) +-- ============================================================ + +-- For users with existing progress, create a sentinel record +-- Estimate sentinel position from scroll percentage +-- Formula: sentinel = FLOOR(highest_anchor * (scroll_percent / 100)) +-- This is approximate but provides continuity for existing users + +INSERT INTO omnivore.reading_progress ( + user_id, + library_item_id, + content_version, + last_seen_sentinel, + highest_seen_sentinel, + created_at, + updated_at +) +SELECT + li.user_id, + li.id, + li.content_hash, + -- Estimate last seen sentinel from top percent + CASE + WHEN li.reading_progress_highest_read_anchor > 0 + THEN FLOOR(li.reading_progress_highest_read_anchor * (li.reading_progress_top_percent / 100.0))::INTEGER + ELSE 0 + END, + -- Use existing highest anchor + COALESCE(li.reading_progress_highest_read_anchor, 0), + li.updated_at, + li.updated_at +FROM omnivore.library_item li +WHERE + -- Only migrate if there's meaningful progress + li.reading_progress_top_percent > 0 + OR li.reading_progress_highest_read_anchor > 0 +ON CONFLICT (user_id, library_item_id, COALESCE(content_version, '')) +DO NOTHING; + +-- ============================================================ +-- PART 5: Drop old scroll-percent columns from library_item +-- ============================================================ + +-- Safe to drop now that data is migrated +ALTER TABLE omnivore.library_item + DROP COLUMN IF EXISTS reading_progress_top_percent, + DROP COLUMN IF EXISTS reading_progress_bottom_percent, + DROP COLUMN IF EXISTS reading_progress_last_read_anchor, + DROP COLUMN IF EXISTS reading_progress_highest_read_anchor; + +-- ============================================================ +-- PART 6: Row Level Security (RLS) +-- ============================================================ + +ALTER TABLE omnivore.reading_progress ENABLE ROW LEVEL SECURITY; + +CREATE POLICY read_reading_progress ON omnivore.reading_progress + FOR SELECT TO omnivore_user + USING (user_id = omnivore.get_current_user_id()); + +CREATE POLICY create_reading_progress ON omnivore.reading_progress + FOR INSERT TO omnivore_user + WITH CHECK (user_id = omnivore.get_current_user_id()); + +CREATE POLICY update_reading_progress ON omnivore.reading_progress + FOR UPDATE TO omnivore_user + USING (user_id = omnivore.get_current_user_id()); + +CREATE POLICY delete_reading_progress ON omnivore.reading_progress + FOR DELETE TO omnivore_user + USING (user_id = omnivore.get_current_user_id()); + +-- Grant permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.reading_progress TO omnivore_user; + +-- ============================================================ +-- PART 7: Helpful comments +-- ============================================================ + +COMMENT ON TABLE omnivore.reading_progress IS + 'Sentinel-based reading progress tracking per user per item per content version'; + +COMMENT ON COLUMN omnivore.reading_progress.last_seen_sentinel IS + 'Last I/O sentinel the user scrolled past (viewport bottom)'; + +COMMENT ON COLUMN omnivore.reading_progress.highest_seen_sentinel IS + 'Highest sentinel ever reached by this user (for "furthest read" tracking)'; + +COMMENT ON COLUMN omnivore.reading_progress.content_version IS + 'Content hash/version this progress applies to - enables re-anchoring on content updates'; + +COMMIT; \ No newline at end of file diff --git a/packages/db/migrations/0194.undo.sentinel_reading_progress.sql b/packages/db/migrations/0194.undo.sentinel_reading_progress.sql new file mode 100644 index 000000000..fc08a130d --- /dev/null +++ b/packages/db/migrations/0194.undo.sentinel_reading_progress.sql @@ -0,0 +1,66 @@ +-- Type: UNDO +-- Name: sentinel_reading_progress +-- Description: Rollback sentinel-based reading progress and restore scroll percentages + +BEGIN; + +-- ============================================================ +-- PART 1: Restore old scroll-percent columns to library_item +-- ============================================================ + +ALTER TABLE omnivore.library_item +ADD COLUMN IF NOT EXISTS reading_progress_top_percent NUMERIC, +ADD COLUMN IF NOT EXISTS reading_progress_bottom_percent NUMERIC, +ADD COLUMN IF NOT EXISTS reading_progress_last_read_anchor INTEGER, +ADD COLUMN IF NOT EXISTS reading_progress_highest_read_anchor INTEGER; + +-- ============================================================ +-- PART 2: Migrate sentinel progress back to scroll percentages +-- ============================================================ + +-- Estimate scroll percentage from sentinel position +-- Formula: scroll_percent = (highest_seen_sentinel / total_sentinels) * 100 +-- This requires guessing total sentinels - use a reasonable default (e.g., 1000) + +UPDATE omnivore.library_item li +SET + reading_progress_top_percent = LEAST( + 100, + rp.last_seen_sentinel * 0.1 + ), + reading_progress_bottom_percent = LEAST( + 100, + rp.last_seen_sentinel * 0.1 + ), + reading_progress_highest_read_anchor = rp.highest_seen_sentinel, + reading_progress_last_read_anchor = rp.last_seen_sentinel +FROM omnivore.reading_progress rp +WHERE + li.id = rp.library_item_id + AND li.user_id = rp.user_id + -- Only use the most recent progress record if multiple versions exist + AND rp.updated_at = ( + SELECT MAX(updated_at) + FROM omnivore.reading_progress rp2 + WHERE + rp2.user_id = rp.user_id + AND rp2.library_item_id = rp.library_item_id + ); + +-- ============================================================ +-- PART 3: Drop reading_progress table +-- ============================================================ + +DROP TRIGGER IF EXISTS trig_touch_reading_progress_updated_at ON omnivore.reading_progress; + +DROP TABLE IF EXISTS omnivore.reading_progress CASCADE; + +-- ============================================================ +-- PART 4: Drop content_hash from library_item +-- ============================================================ + +DROP INDEX IF EXISTS omnivore.library_item_user_content_hash_idx; + +ALTER TABLE omnivore.library_item DROP COLUMN IF EXISTS content_hash; + +COMMIT; \ No newline at end of file diff --git a/packages/db/migrations/0195.do.uppercase_highlight_colors.sql b/packages/db/migrations/0195.do.uppercase_highlight_colors.sql new file mode 100644 index 000000000..a89f7e837 --- /dev/null +++ b/packages/db/migrations/0195.do.uppercase_highlight_colors.sql @@ -0,0 +1,34 @@ +-- Type: DO +-- Name: uppercase_highlight_colors +-- Description: Convert highlight_color enum from lowercase to uppercase for consistency with TypeScript/GraphQL + +BEGIN; + +-- 1) Create new enum type with uppercase values +CREATE TYPE omnivore.highlight_color_new AS ENUM ('YELLOW', 'RED', 'GREEN', 'BLUE'); + +-- 2) Drop the default value temporarily (required for type conversion) +ALTER TABLE omnivore.highlight ALTER COLUMN color DROP DEFAULT; + +-- 3) Convert existing data to new enum type with uppercase values +ALTER TABLE omnivore.highlight +ALTER COLUMN color TYPE omnivore.highlight_color_new USING CASE + WHEN color::text = 'yellow' THEN 'YELLOW'::omnivore.highlight_color_new + WHEN color::text = 'red' THEN 'RED'::omnivore.highlight_color_new + WHEN color::text = 'green' THEN 'GREEN'::omnivore.highlight_color_new + WHEN color::text = 'blue' THEN 'BLUE'::omnivore.highlight_color_new + ELSE 'YELLOW'::omnivore.highlight_color_new +END; + +-- 4) Set new default value to uppercase +ALTER TABLE omnivore.highlight +ALTER COLUMN color +SET DEFAULT 'YELLOW'::omnivore.highlight_color_new; + +-- 5) Drop old enum type +DROP TYPE omnivore.highlight_color; + +-- 6) Rename new enum type to original name +ALTER TYPE omnivore.highlight_color_new RENAME TO highlight_color; + +COMMIT; \ No newline at end of file diff --git a/packages/db/migrations/0195.undo.uppercase_highlight_colors.sql b/packages/db/migrations/0195.undo.uppercase_highlight_colors.sql new file mode 100644 index 000000000..514687d0c --- /dev/null +++ b/packages/db/migrations/0195.undo.uppercase_highlight_colors.sql @@ -0,0 +1,34 @@ +-- Type: UNDO +-- Name: uppercase_highlight_colors +-- Description: Revert highlight_color enum from uppercase back to lowercase + +BEGIN; + +-- 1) Create enum type with lowercase values (original format) +CREATE TYPE omnivore.highlight_color_old AS ENUM ('yellow', 'red', 'green', 'blue'); + +-- 2) Drop the default value temporarily (required for type conversion) +ALTER TABLE omnivore.highlight ALTER COLUMN color DROP DEFAULT; + +-- 3) Convert existing data back to lowercase enum values +ALTER TABLE omnivore.highlight +ALTER COLUMN color TYPE omnivore.highlight_color_old USING CASE + WHEN color::text = 'YELLOW' THEN 'yellow'::omnivore.highlight_color_old + WHEN color::text = 'RED' THEN 'red'::omnivore.highlight_color_old + WHEN color::text = 'GREEN' THEN 'green'::omnivore.highlight_color_old + WHEN color::text = 'BLUE' THEN 'blue'::omnivore.highlight_color_old + ELSE 'yellow'::omnivore.highlight_color_old +END; + +-- 4) Set default value back to lowercase +ALTER TABLE omnivore.highlight +ALTER COLUMN color +SET DEFAULT 'yellow'::omnivore.highlight_color_old; + +-- 5) Drop uppercase enum type +DROP TYPE omnivore.highlight_color; + +-- 6) Rename old enum type back to original name +ALTER TYPE omnivore.highlight_color_old RENAME TO highlight_color; + +COMMIT; \ No newline at end of file diff --git a/packages/db/migrations/0196.do.add_total_sentinels.sql b/packages/db/migrations/0196.do.add_total_sentinels.sql new file mode 100644 index 000000000..333dad9bb --- /dev/null +++ b/packages/db/migrations/0196.do.add_total_sentinels.sql @@ -0,0 +1,22 @@ +-- Type: DO +-- Name: add_total_sentinels +-- Description: Add total_sentinels column to library_item for reading progress percentage calculation + +BEGIN; + +-- ============================================================ +-- Add total_sentinels to library_item +-- ============================================================ + +ALTER TABLE omnivore.library_item +ADD COLUMN IF NOT EXISTS total_sentinels INTEGER DEFAULT 0; + +-- Index for efficient progress queries (joining with reading_progress) +CREATE INDEX IF NOT EXISTS library_item_total_sentinels_idx + ON omnivore.library_item (id, total_sentinels) + WHERE total_sentinels > 0; + +COMMENT ON COLUMN omnivore.library_item.total_sentinels IS + 'Total number of sentinel markers in the article content. Used to calculate reading progress percentage: (highest_seen_sentinel / total_sentinels) * 100'; + +COMMIT; diff --git a/packages/db/migrations/0196.undo.add_total_sentinels.sql b/packages/db/migrations/0196.undo.add_total_sentinels.sql new file mode 100644 index 000000000..cd2784924 --- /dev/null +++ b/packages/db/migrations/0196.undo.add_total_sentinels.sql @@ -0,0 +1,12 @@ +-- Type: UNDO +-- Name: add_total_sentinels +-- Description: Remove total_sentinels column from library_item + +BEGIN; + +DROP INDEX IF EXISTS omnivore.library_item_total_sentinels_idx; + +ALTER TABLE omnivore.library_item +DROP COLUMN IF EXISTS total_sentinels; + +COMMIT; diff --git a/packages/db/package.json b/packages/db/package.json index 0af47351b..f544d4e2c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -10,12 +10,15 @@ "author": "", "license": "ISC", "dependencies": { + "chalk": "^4.1.2", "dotenv": "^8.2.0", "pg": "^8.3.0", "postgrator": "^4.1.1", "ts-node": "^10.2.1" }, "devDependencies": { - "plop": "^2.7.1" + "@types/minimatch": "^6.0.0", + "@types/node": "^24.9.2", + "plop": "^4.0.4" } } diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index b08285106..b54093c0f 100755 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,3 +1,17 @@ { - "extends": "@tsconfig/node14/tsconfig.json" -} + "extends": "@tsconfig/node14/tsconfig.json", + "compilerOptions": { + "typeRoots": [ + "../../node_modules/@types", + "./node_modules/@types" + ], + "types": ["node"], + "skipLibCheck": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/packages/web-vite/eslint.config.js b/packages/web-vite/eslint.config.js index b19330b10..669f2e048 100644 --- a/packages/web-vite/eslint.config.js +++ b/packages/web-vite/eslint.config.js @@ -3,21 +3,115 @@ import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' -export default defineConfig([ - globalIgnores(['dist']), +export default [ + { ignores: ['dist', '**/dist/**', 'node_modules/**'] }, + js.configs.recommended, + ...tseslint.configs.recommended, { files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], - reactRefresh.configs.vite, - ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + // Standard rules from base config + 'prefer-const': 'warn', + quotes: ['warn', 'single'], + 'array-bracket-spacing': ['warn', 'never'], + 'array-callback-return': 'error', + 'arrow-spacing': 'error', + 'block-scoped-var': 'error', + 'block-spacing': 'warn', + 'comma-spacing': ['error', { after: true, before: false }], + 'comma-style': ['error', 'last'], + 'computed-property-spacing': 'warn', + curly: ['error', 'multi-line'], + 'no-console': ['warn', { allow: ['warn', 'error', 'info'] }], + 'dot-notation': 'warn', + eqeqeq: 'error', + 'for-direction': 'error', + 'func-call-spacing': 'warn', + 'guard-for-in': 'warn', + indent: [ + 'error', + 2, + { + SwitchCase: 1, + ignoredNodes: ['TemplateLiteral'], + }, + ], + 'key-spacing': [ + 'warn', + { + beforeColon: false, + afterColon: true, + }, + ], + 'keyword-spacing': [ + 'warn', + { + before: true, + after: true, + }, + ], + 'lines-between-class-members': ['warn', 'always'], + 'max-depth': ['error', 4], + 'max-len': [ + 'error', + { + code: 120, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + }, + ], + 'max-nested-callbacks': ['error', 4], + 'max-params': ['error', 5], + 'newline-before-return': 'warn', + 'no-array-constructor': 'error', + 'no-await-in-loop': 'warn', + 'no-duplicate-imports': 'error', + 'no-else-return': 'warn', + 'no-empty-function': 'off', + 'no-eq-null': 'error', + 'no-eval': 'error', + 'no-lonely-if': 'warn', + 'no-loop-func': 'warn', + 'no-mixed-operators': 'error', + 'no-multi-assign': 'error', + 'no-multi-spaces': 'warn', + 'no-multiple-empty-lines': ['error', { max: 2, maxEOF: 1 }], + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', + 'no-tabs': 'error', + 'no-trailing-spaces': ['warn', { skipBlankLines: true }], + 'no-unneeded-ternary': 'error', + 'no-use-before-define': [ + 'warn', + { + functions: false, + }, + ], + 'no-useless-concat': 'error', + 'no-useless-return': 'error', + 'object-curly-spacing': ['warn', 'always'], + 'prefer-template': 'warn', + 'space-before-blocks': 'error', + 'space-in-parens': ['error', 'never'], + 'space-infix-ops': 'error', + yoda: 'error', + '@typescript-eslint/no-empty-interface': 0, + '@typescript-eslint/no-explicit-any': 0, + }, }, -]) +] diff --git a/packages/web-vite/index.html b/packages/web-vite/index.html index 0c9df05a8..5fe5913a0 100644 --- a/packages/web-vite/index.html +++ b/packages/web-vite/index.html @@ -6,10 +6,26 @@ Omnivore - Read-it-later for serious readers - + + + + + + + + - + + + + + + + + + + diff --git a/packages/web-vite/package.json b/packages/web-vite/package.json index 255f85876..fbf39529c 100644 --- a/packages/web-vite/package.json +++ b/packages/web-vite/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "lint:fix": "eslint . --fix", "preview": "vite preview", "test": "vitest", "test:ui": "vitest --ui", diff --git a/packages/web-vite/src/App.tsx b/packages/web-vite/src/App.tsx index 00edca09a..614334996 100644 --- a/packages/web-vite/src/App.tsx +++ b/packages/web-vite/src/App.tsx @@ -1,10 +1,12 @@ // Main App component for Omnivore Vite migration // Uses AppRouter for proper React Router navigation -import React from 'react' -import AppRouter from './router/AppRouter' import './App.css' +import React from 'react' + +import AppRouter from './router/AppRouter' + const App: React.FC = () => { return (
diff --git a/packages/web-vite/src/__tests__/basic.test.ts b/packages/web-vite/src/__tests__/basic.test.ts index d2ed55710..5c2d17a4b 100644 --- a/packages/web-vite/src/__tests__/basic.test.ts +++ b/packages/web-vite/src/__tests__/basic.test.ts @@ -1,7 +1,7 @@ // Simple test to verify Vitest setup // Basic functionality test without complex dependencies -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' describe('Basic Setup', () => { it('should run tests', () => { diff --git a/packages/web-vite/src/components/AddLinkModal.tsx b/packages/web-vite/src/components/AddLinkModal.tsx index 6bc468f68..25acbded6 100644 --- a/packages/web-vite/src/components/AddLinkModal.tsx +++ b/packages/web-vite/src/components/AddLinkModal.tsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react' -import { useSaveUrl } from '../lib/graphql-client' import '../styles/AddLinkModal.css' +import React, { useState } from 'react' + +import { useSaveUrl } from '../lib/graphql-client' + interface AddLinkModalProps { isOpen: boolean onClose: () => void @@ -26,6 +28,7 @@ const AddLinkModal: React.FC = ({ // Basic URL validation if (!urlString.trim()) { setValidationError('URL is required') + return false } @@ -39,9 +42,11 @@ const AddLinkModal: React.FC = ({ new URL(testUrl) setValidationError(null) + return true } catch { setValidationError('Please enter a valid URL (e.g., https://example.com/article)') + return false } } diff --git a/packages/web-vite/src/components/AuthWrapper.tsx b/packages/web-vite/src/components/AuthWrapper.tsx index 711b26eb1..97fbd513b 100644 --- a/packages/web-vite/src/components/AuthWrapper.tsx +++ b/packages/web-vite/src/components/AuthWrapper.tsx @@ -2,9 +2,10 @@ // This component is no longer used as AppRouter handles all routing now // Keeping for backwards compatibility but should be removed in next cleanup import React from 'react' -import { useAuthStore } from '../stores' + import LibraryPage from '../pages/LibraryPage' import LoginPage from '../pages/LoginPage' +import { useAuthStore } from '../stores' import ErrorBoundary from './ErrorBoundary' const AuthWrapper: React.FC = () => { diff --git a/packages/web-vite/src/components/CardSkeleton.tsx b/packages/web-vite/src/components/CardSkeleton.tsx index c3a41c3e1..1543fd210 100644 --- a/packages/web-vite/src/components/CardSkeleton.tsx +++ b/packages/web-vite/src/components/CardSkeleton.tsx @@ -11,10 +11,12 @@ * - Respects density modes */ -import React from 'react' -import type { CardDensity } from './LibraryItemCard' import '../styles/CardSkeleton.css' +import React from 'react' + +import type { CardDensity } from './LibraryItemCard' + interface CardSkeletonProps { density?: CardDensity } diff --git a/packages/web-vite/src/components/EditInfoModal.tsx b/packages/web-vite/src/components/EditInfoModal.tsx index 204c6812a..06ec18580 100644 --- a/packages/web-vite/src/components/EditInfoModal.tsx +++ b/packages/web-vite/src/components/EditInfoModal.tsx @@ -1,7 +1,9 @@ -import { useState, useEffect } from 'react' -import { useUpdateLibraryItem, type UpdateLibraryItemInput } from '../lib/graphql-client' import '../styles/EditInfoModal.css' +import { useEffect, useState } from 'react' + +import { type UpdateLibraryItemInput, useUpdateLibraryItem } from '../lib/graphql-client' + interface EditInfoModalProps { itemId: string currentTitle: string @@ -45,6 +47,7 @@ export function EditInfoModal({ // If nothing changed, just close if (Object.keys(input).length === 0) { onClose() + return } diff --git a/packages/web-vite/src/components/ErrorBoundary.tsx b/packages/web-vite/src/components/ErrorBoundary.tsx index 7c4ab57bc..8779b8f75 100644 --- a/packages/web-vite/src/components/ErrorBoundary.tsx +++ b/packages/web-vite/src/components/ErrorBoundary.tsx @@ -1,7 +1,8 @@ // React Error Boundaries for Omnivore Vite migration // Comprehensive error handling with graceful fallbacks -import React, { Component, type ReactNode } from 'react' +import React, { type ReactNode, Component } from 'react' + import { type ApiError } from '../types/api' interface ErrorBoundaryState { diff --git a/packages/web-vite/src/components/FlairBadge.tsx b/packages/web-vite/src/components/FlairBadge.tsx index 802f0165e..2144946c5 100644 --- a/packages/web-vite/src/components/FlairBadge.tsx +++ b/packages/web-vite/src/components/FlairBadge.tsx @@ -12,10 +12,12 @@ * - Distinguished from user tags (which show text + color) */ -import React from 'react' -import type { Label } from '../types/api' import '../styles/FlairBadge.css' +import React from 'react' + +import type { Label } from '../types/api' + interface FlairBadgeProps { label: Label } diff --git a/packages/web-vite/src/components/HighlightSidebar.tsx b/packages/web-vite/src/components/HighlightSidebar.tsx new file mode 100644 index 000000000..b135fd653 --- /dev/null +++ b/packages/web-vite/src/components/HighlightSidebar.tsx @@ -0,0 +1,266 @@ +// Highlight Sidebar Component - Shows all highlights for an article +// Allows editing, deleting, and navigating to highlights + +import '../styles/HighlightSidebar.css' + +import React, { useState } from 'react' + +import type { Highlight } from '../lib/graphql-client' +import type { HighlightColor } from '../types/api' + +interface HighlightSidebarProps { + highlights: Highlight[] + onUpdateHighlight: ( + id: string, + annotation: string, + color: HighlightColor, + ) => Promise + onDeleteHighlight: (id: string) => Promise + onJumpToHighlight: (id: string) => void + onClose: () => void +} + +const HighlightSidebar: React.FC = ({ + highlights, + onUpdateHighlight, + onDeleteHighlight, + onJumpToHighlight, + onClose, +}) => { + const [editingId, setEditingId] = useState(null) + const [editAnnotation, setEditAnnotation] = useState('') + const [editColor, setEditColor] = useState('YELLOW') + const [deletingId, setDeletingId] = useState(null) + + const sortedHighlights = [...highlights].sort( + (a, b) => a.highlightPositionPercent - b.highlightPositionPercent, + ) + + const colorOptions: Array<{ + value: HighlightColor + label: string + bg: string + }> = [ + { value: 'YELLOW', label: 'General', bg: 'rgba(255, 212, 59, 0.5)' }, + { value: 'RED', label: 'Important', bg: 'rgba(255, 107, 107, 0.5)' }, + { value: 'GREEN', label: 'Action', bg: 'rgba(85, 239, 196, 0.5)' }, + { value: 'BLUE', label: 'Reference', bg: 'rgba(116, 185, 255, 0.5)' }, + ] + + const startEdit = (highlight: Highlight) => { + setEditingId(highlight.id) + setEditAnnotation(highlight.annotation || '') + setEditColor(highlight.color) + } + + const cancelEdit = () => { + setEditingId(null) + setEditAnnotation('') + } + + const saveEdit = async (id: string) => { + await onUpdateHighlight(id, editAnnotation, editColor) + setEditingId(null) + } + + const confirmDelete = (id: string) => { + setDeletingId(id) + } + + const handleDelete = async (id: string) => { + await onDeleteHighlight(id) + setDeletingId(null) + } + + if (highlights.length === 0) { + return ( +
+
+

Highlights

+ +
+
+

No highlights yet

+

Select text to create a highlight

+
+
+ ) + } + + return ( +
+
+

+ Highlights{' '} + ({highlights.length}) +

+ +
+ +
+ {sortedHighlights.map((highlight) => ( +
+ {/* Color indicator bar */} +
+ +
+ {/* Quote */} + + + {/* Annotation - editable or display */} + {editingId === highlight.id ? ( +
+