mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
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>
This commit is contained in:
parent
c4a4b7c4fb
commit
48fd77c46b
121 changed files with 8781 additions and 1642 deletions
118
.eslintrc.base.json
Normal file
118
.eslintrc.base.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.husky/commit-msg
Normal file
4
.husky/commit-msg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit ${1}
|
||||
5
.husky/pre-commit
Executable file
5
.husky/pre-commit
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
15
.lintstagedrc.json
Normal file
15
.lintstagedrc.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
42
.prettierignore
Normal file
42
.prettierignore
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
|
|
|||
16
commitlint.config.js
Normal file
16
commitlint.config.js
Normal file
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
}
|
||||
86
package.json
86
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
packages/api-nest/.eslintrc.json
Normal file
8
packages/api-nest/.eslintrc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": ["../../.eslintrc.base.json"],
|
||||
"rules": {
|
||||
"semi": [2, "never"],
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": [0, "never"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string>(
|
||||
EnvVariables.DATABASE_HOST,
|
||||
'localhost',
|
||||
),
|
||||
port: configService.get<number>(EnvVariables.DATABASE_PORT, 5432),
|
||||
username: configService.get<string>(
|
||||
EnvVariables.DATABASE_USER,
|
||||
'app_user',
|
||||
),
|
||||
password: configService.get<string>(EnvVariables.DATABASE_PASSWORD),
|
||||
database: configService.get<string>(
|
||||
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<string>(
|
||||
EnvVariables.DATABASE_HOST,
|
||||
'localhost',
|
||||
),
|
||||
port: configService.get<number>(EnvVariables.DATABASE_PORT, 5432),
|
||||
username: configService.get<string>(
|
||||
EnvVariables.DATABASE_USER,
|
||||
'app_user',
|
||||
),
|
||||
password: configService.get<string>(EnvVariables.DATABASE_PASSWORD),
|
||||
database: configService.get<string>(
|
||||
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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
152
packages/api-nest/src/graphql/dataloader.service.ts
Normal file
152
packages/api-nest/src/graphql/dataloader.service.ts
Normal file
|
|
@ -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<string, Label[]>
|
||||
|
||||
public readonly highlights: DataLoader<string, HighlightEntity[]>
|
||||
|
||||
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<string, Label[]>(
|
||||
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<string, HighlightEntity[]>(
|
||||
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<string, ReadingProgressEntity | null>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DataLoaderFactory['create']>
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
RepositoriesModule, // Required for DataLoaderFactory
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
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<string>(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<GraphQLContext> => {
|
||||
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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: '' })
|
||||
|
|
|
|||
|
|
@ -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<Label[] | null> {
|
||||
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<number | null> {
|
||||
// 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<typeof SearchResult> {
|
||||
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<LibraryItem> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<LibraryItemEntity | null> {
|
||||
async findById(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<LibraryItemEntity | null> {
|
||||
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<LibraryItemEntity> {
|
||||
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()}`
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<ReadingProgress | null> {
|
||||
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<ReadingProgress> {
|
||||
const entity = await this.readingProgressService.updateProgress(
|
||||
user.id,
|
||||
input,
|
||||
)
|
||||
return mapEntityToGraph(entity)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ReadingProgressEntity | null> {
|
||||
// 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<ReadingProgressEntity> {
|
||||
// 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<boolean> {
|
||||
const latestProgress = await this.progressRepository.findLatestProgress(
|
||||
libraryItemId,
|
||||
userId,
|
||||
)
|
||||
|
||||
if (!latestProgress || !latestProgress.contentVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
return latestProgress.contentVersion !== currentContentVersion
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EntityLabel>,
|
||||
private readonly repository: Repository<EntityLabel>
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -45,4 +45,30 @@ export class EntityLabelRepository implements IEntityLabelRepository {
|
|||
async save(entityLabels: EntityLabel[]): Promise<EntityLabel[]> {
|
||||
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<Map<string, EntityLabel[]>> {
|
||||
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<string, EntityLabel[]>()
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
result.set(
|
||||
libraryItemId,
|
||||
entityLabels.filter(el => el.libraryItemId === libraryItemId)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HighlightEntity | null> {
|
||||
async findById(id: string, userId: string): Promise<HighlightEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
|
|
@ -69,4 +66,47 @@ export class HighlightRepository implements IHighlightRepository {
|
|||
async remove(highlight: HighlightEntity): Promise<void> {
|
||||
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<Map<string, HighlightEntity[]>> {
|
||||
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<string, HighlightEntity[]>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
packages/api-nest/src/repositories/injection-tokens.ts
Normal file
16
packages/api-nest/src/repositories/injection-tokens.ts
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -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<EntityLabel[]>
|
||||
|
||||
/**
|
||||
* 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<Map<string, EntityLabel[]>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
|
||||
/**
|
||||
* 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<Map<string, HighlightEntity[]>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LibraryItemEntity | null>
|
||||
|
||||
/**
|
||||
* 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<LibraryItemEntity[]>
|
||||
|
||||
/**
|
||||
* 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>): 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<LibraryItemEntity>,
|
||||
): Promise<LibraryItemEntity>
|
||||
|
||||
/**
|
||||
* Bulk archive or unarchive library items
|
||||
* @param userId - User ID who owns the items
|
||||
|
|
|
|||
|
|
@ -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<ReadingProgressEntity | null>
|
||||
|
||||
/**
|
||||
* 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<ReadingProgressEntity | null>
|
||||
|
||||
/**
|
||||
* Create a new reading progress instance (without saving to database)
|
||||
* @param data - Partial reading progress data
|
||||
* @returns Reading progress instance
|
||||
*/
|
||||
create(data: Partial<ReadingProgressEntity>): 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<ReadingProgressEntity>
|
||||
|
||||
/**
|
||||
* 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<ReadingProgressEntity>
|
||||
|
||||
/**
|
||||
* 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<Map<string, ReadingProgressEntity>>
|
||||
}
|
||||
|
|
@ -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<LibraryItemEntity[]> {
|
||||
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<LibraryItemEntity>,
|
||||
): Promise<LibraryItemEntity> {
|
||||
// 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 })
|
||||
|
|
|
|||
|
|
@ -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<ReadingProgressEntity>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find reading progress for a specific user + item + content version
|
||||
*/
|
||||
async findProgress(
|
||||
libraryItemId: string,
|
||||
userId: string,
|
||||
contentVersion?: string | null,
|
||||
): Promise<ReadingProgressEntity | null> {
|
||||
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<ReadingProgressEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
libraryItemId,
|
||||
userId,
|
||||
},
|
||||
order: {
|
||||
updatedAt: 'DESC',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new reading progress instance (without saving to database)
|
||||
*/
|
||||
create(data: Partial<ReadingProgressEntity>): ReadingProgressEntity {
|
||||
return this.repository.create(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (create or update) reading progress
|
||||
*/
|
||||
async save(progress: ReadingProgressEntity): Promise<ReadingProgressEntity> {
|
||||
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<ReadingProgressEntity> {
|
||||
// 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<Map<string, ReadingProgressEntity>> {
|
||||
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<string, ReadingProgressEntity>()
|
||||
for (const progress of progressRecords) {
|
||||
if (!progressMap.has(progress.libraryItemId)) {
|
||||
progressMap.set(progress.libraryItemId, progress)
|
||||
}
|
||||
}
|
||||
|
||||
return progressMap
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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!')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HighlightEntity> {
|
||||
protected generateDefaults() {
|
||||
protected generateDefaults(): Partial<HighlightEntity> {
|
||||
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<HighlightEntity> {
|
|||
async withColor(
|
||||
libraryItemId: string,
|
||||
userId: string,
|
||||
color: string,
|
||||
color: HighlightColor,
|
||||
overrides: Partial<HighlightEntity> = {},
|
||||
): Promise<HighlightEntity> {
|
||||
return this.create({
|
||||
|
|
@ -128,7 +139,7 @@ class HighlightFactoryClass extends BaseFactory<HighlightEntity> {
|
|||
buildWithColor(
|
||||
libraryItemId: string,
|
||||
userId: string,
|
||||
color: string,
|
||||
color: HighlightColor,
|
||||
overrides: Partial<HighlightEntity> = {},
|
||||
): HighlightEntity {
|
||||
return this.build({
|
||||
|
|
|
|||
|
|
@ -45,13 +45,11 @@ class LibraryItemFactoryClass extends BaseFactory<LibraryItemEntity> {
|
|||
// 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<LibraryItemEntity> {
|
|||
|
||||
return this.create({
|
||||
userId,
|
||||
readingProgressTopPercent: percentComplete,
|
||||
readingProgressBottomPercent: Math.min(percentComplete + 5, 100),
|
||||
readAt,
|
||||
...overrides,
|
||||
})
|
||||
|
|
@ -189,8 +185,6 @@ class LibraryItemFactoryClass extends BaseFactory<LibraryItemEntity> {
|
|||
): LibraryItemEntity {
|
||||
return this.build({
|
||||
userId,
|
||||
readingProgressTopPercent: percentComplete,
|
||||
readingProgressBottomPercent: Math.min(percentComplete + 5, 100),
|
||||
readAt: percentComplete === 100 ? new Date() : null,
|
||||
...overrides,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) =>
|
||||
const executeQuery = (
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
) =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/graphql')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ query, variables })
|
||||
.expect(200)
|
||||
.expect(200) as Promise<request.Response>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,5 +21,7 @@
|
|||
"globalSetup": "<rootDir>/setup/global-setup.ts",
|
||||
"globalTeardown": "<rootDir>/setup/global-teardown.ts",
|
||||
"setupFilesAfterEnv": ["<rootDir>/setup/jest-environment-setup.ts"],
|
||||
"testTimeout": 60000
|
||||
"testTimeout": 60000,
|
||||
"silent": false,
|
||||
"verbose": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) =>
|
||||
const executeQuery = (
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
) =>
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
465
packages/api-nest/test/reading-progress.e2e-spec.ts
Normal file
465
packages/api-nest/test/reading-progress.e2e-spec.ts
Normal file
|
|
@ -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<LibraryItemEntity>
|
||||
let progressRepository: Repository<ReadingProgressEntity>
|
||||
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<Repository<LibraryItemEntity>>(
|
||||
getRepositoryToken(LibraryItemEntity),
|
||||
)
|
||||
progressRepository = moduleFixture.get<Repository<ReadingProgressEntity>>(
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
71
packages/api-nest/test/setup/test-logger-config.ts
Normal file
71
packages/api-nest/test/setup/test-logger-config.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<void> {
|
|||
// Truncate all tables
|
||||
const tables = [
|
||||
'omnivore.entity_label',
|
||||
'omnivore.reading_progress',
|
||||
'omnivore.highlight',
|
||||
'omnivore.label',
|
||||
'omnivore.library_item',
|
||||
|
|
|
|||
86
packages/db/migrations/0193.do.add_highlight_selectors.sql
Normal file
86
packages/db/migrations/0193.do.add_highlight_selectors.sql
Normal file
|
|
@ -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;
|
||||
35
packages/db/migrations/0193.undo.add_highlight_selectors.sql
Normal file
35
packages/db/migrations/0193.undo.add_highlight_selectors.sql
Normal file
|
|
@ -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;
|
||||
180
packages/db/migrations/0194.do.sentinel_reading_progress.sql
Normal file
180
packages/db/migrations/0194.do.sentinel_reading_progress.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
22
packages/db/migrations/0196.do.add_total_sentinels.sql
Normal file
22
packages/db/migrations/0196.do.add_total_sentinels.sql
Normal file
|
|
@ -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;
|
||||
12
packages/db/migrations/0196.undo.add_total_sentinels.sql
Normal file
12
packages/db/migrations/0196.undo.add_total_sentinels.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,10 +6,26 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Omnivore - Read-it-later for serious readers</title>
|
||||
|
||||
<!-- Inter font from Google Fonts (instant loading) -->
|
||||
<!-- DNS Prefetch for external resources -->
|
||||
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
|
||||
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
|
||||
<link rel="dns-prefetch" href="https://substackcdn.com">
|
||||
<link rel="dns-prefetch" href="https://substack-post-media.s3.amazonaws.com">
|
||||
<link rel="dns-prefetch" href="https://secure.gravatar.com">
|
||||
|
||||
<!-- Preconnect to critical origins -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Preconnect to common CDN origins for faster image loading -->
|
||||
<link rel="preconnect" href="https://substackcdn.com" crossorigin>
|
||||
<link rel="preconnect" href="https://substack-post-media.s3.amazonaws.com" crossorigin>
|
||||
|
||||
<!-- Inter font from Google Fonts with optimized loading -->
|
||||
<!-- Using font-display=swap for faster initial render, loading only essential weights -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Preload critical CSS (Vite will inject this automatically in production) -->
|
||||
|
||||
<!-- Google Sign-In loaded dynamically in LoginPage component -->
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="app">
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<AddLinkModalProps> = ({
|
|||
// Basic URL validation
|
||||
if (!urlString.trim()) {
|
||||
setValidationError('URL is required')
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -39,9 +42,11 @@ const AddLinkModal: React.FC<AddLinkModalProps> = ({
|
|||
new URL(testUrl)
|
||||
|
||||
setValidationError(null)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
setValidationError('Please enter a valid URL (e.g., https://example.com/article)')
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
266
packages/web-vite/src/components/HighlightSidebar.tsx
Normal file
266
packages/web-vite/src/components/HighlightSidebar.tsx
Normal file
|
|
@ -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<void>
|
||||
onDeleteHighlight: (id: string) => Promise<void>
|
||||
onJumpToHighlight: (id: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const HighlightSidebar: React.FC<HighlightSidebarProps> = ({
|
||||
highlights,
|
||||
onUpdateHighlight,
|
||||
onDeleteHighlight,
|
||||
onJumpToHighlight,
|
||||
onClose,
|
||||
}) => {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editAnnotation, setEditAnnotation] = useState('')
|
||||
const [editColor, setEditColor] = useState<HighlightColor>('YELLOW')
|
||||
const [deletingId, setDeletingId] = useState<string | null>(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 (
|
||||
<div className="highlight-sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="sidebar-title">Highlights</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-empty">
|
||||
<p>No highlights yet</p>
|
||||
<p className="empty-hint">Select text to create a highlight</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="highlight-sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="sidebar-title">
|
||||
Highlights{' '}
|
||||
<span className="highlight-count">({highlights.length})</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{sortedHighlights.map((highlight) => (
|
||||
<div
|
||||
key={highlight.id}
|
||||
className={`highlight-item highlight-item-${highlight.color}`}
|
||||
>
|
||||
{/* Color indicator bar */}
|
||||
<div
|
||||
className={`highlight-color-bar highlight-bar-${highlight.color}`}
|
||||
/>
|
||||
|
||||
<div className="highlight-body">
|
||||
{/* Quote */}
|
||||
<button
|
||||
type="button"
|
||||
className="highlight-quote"
|
||||
onClick={() => onJumpToHighlight(highlight.id)}
|
||||
>
|
||||
"{highlight.quote}"
|
||||
</button>
|
||||
|
||||
{/* Annotation - editable or display */}
|
||||
{editingId === highlight.id ? (
|
||||
<div className="edit-container">
|
||||
<textarea
|
||||
className="edit-annotation"
|
||||
value={editAnnotation}
|
||||
onChange={(e) => setEditAnnotation(e.target.value)}
|
||||
placeholder="Add your thoughts..."
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="edit-color-picker">
|
||||
<label className="edit-label">Color:</label>
|
||||
<div className="color-options">
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`color-option ${
|
||||
editColor === option.value ? 'active' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: option.bg }}
|
||||
onClick={() => setEditColor(option.value)}
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-save"
|
||||
onClick={() => saveEdit(highlight.id)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cancel"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{highlight.annotation && (
|
||||
<div className="highlight-annotation">
|
||||
{highlight.annotation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="highlight-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn"
|
||||
onClick={() => startEdit(highlight)}
|
||||
title="Edit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn action-btn-danger"
|
||||
onClick={() => confirmDelete(highlight.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deletingId === highlight.id && (
|
||||
<div className="delete-confirmation">
|
||||
<p>Delete this highlight?</p>
|
||||
<div className="delete-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-delete-confirm"
|
||||
onClick={() => handleDelete(highlight.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-delete-cancel"
|
||||
onClick={() => setDeletingId(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HighlightSidebar
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useLabels, useSetLibraryItemLabels, type Label } from '../lib/graphql-client'
|
||||
import '../styles/LabelPicker.css'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { type Label, useLabels, useSetLibraryItemLabels } from '../lib/graphql-client'
|
||||
|
||||
interface LabelPickerProps {
|
||||
itemId: string
|
||||
currentLabels: string[]
|
||||
|
|
@ -50,6 +52,7 @@ export function LabelPicker({ itemId, currentLabels, onUpdate }: LabelPickerProp
|
|||
} else {
|
||||
newSet.add(labelName)
|
||||
}
|
||||
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useLabels, useSetLibraryItemLabels, useCreateLabel, type Label } from '../lib/graphql-client'
|
||||
import '../styles/LabelPickerModal.css'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { type Label, useCreateLabel, useLabels, useSetLibraryItemLabels } from '../lib/graphql-client'
|
||||
|
||||
interface LabelPickerModalProps {
|
||||
itemId: string
|
||||
currentLabels: string[]
|
||||
|
|
@ -50,6 +52,7 @@ export function LabelPickerModal({ itemId, currentLabels, onUpdate, onClose }: L
|
|||
} else {
|
||||
newSet.add(labelName)
|
||||
}
|
||||
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
|
@ -99,6 +102,7 @@ export function LabelPickerModal({ itemId, currentLabels, onUpdate, onClose }: L
|
|||
// Add to selected labels immediately
|
||||
setSelectedLabels((prev) => {
|
||||
const updated = new Set([...prev, trimmedName])
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Left navigation panel component - matches legacy Omnivore UI
|
||||
// Features: Main nav (Home, Library, Highlights, etc.) + Shortcuts section
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import '../styles/LeftNavigation.css'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
interface NavItem {
|
||||
id: string
|
||||
label: string
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
// Enhanced library item card component for grid view
|
||||
// Features: thumbnail, reading time, progress bar, site attribution
|
||||
|
||||
import React from 'react'
|
||||
import type { LibraryItem, Label } from '../types/api'
|
||||
import {
|
||||
calculateReadingTime,
|
||||
formatTimestamp,
|
||||
getProgressColor,
|
||||
formatReadingProgress
|
||||
} from '../lib/reading-time'
|
||||
import LabelPicker from './LabelPicker'
|
||||
import FlairBadge from './FlairBadge'
|
||||
import CardSkeleton from './CardSkeleton'
|
||||
import '../styles/LibraryCard.css'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { calculateReadingTime, formatTimestamp } from '../lib/reading-time'
|
||||
import type { Label, LibraryItem } from '../types/api'
|
||||
import CardSkeleton from './CardSkeleton'
|
||||
|
||||
export type CardDensity = 'compact' | 'comfortable' | 'spacious'
|
||||
|
||||
export type CardAction =
|
||||
|
|
@ -46,19 +41,17 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
onAction,
|
||||
onToggleSelect,
|
||||
isProcessing = false,
|
||||
density = 'comfortable'
|
||||
density = 'comfortable',
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = React.useState(false)
|
||||
const [showAllLabels, setShowAllLabels] = React.useState(false)
|
||||
const menuRef = React.useRef<HTMLDivElement>(null)
|
||||
const readingTime = calculateReadingTime(item.wordCount)
|
||||
const timestamp = formatTimestamp(item.savedAt)
|
||||
const progressPercent = item.readingProgressTopPercent ?? 0
|
||||
const progressColor = getProgressColor(progressPercent)
|
||||
const progressLabel = formatReadingProgress(
|
||||
item.readingProgressTopPercent,
|
||||
item.readingProgressBottomPercent
|
||||
)
|
||||
const isRead = !!item.readAt
|
||||
|
||||
// Calculate reading progress percentage (0-100) for progress bar
|
||||
const readingProgressPercent = item.readingProgressPercent ?? 0
|
||||
|
||||
// Close menu when clicking outside
|
||||
React.useEffect(() => {
|
||||
|
|
@ -69,6 +62,7 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
}
|
||||
if (showMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showMenu])
|
||||
|
|
@ -88,16 +82,19 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
`density-${density}`,
|
||||
isSelected ? 'selected' : '',
|
||||
item.state === 'ARCHIVED' ? 'is-archived' : '',
|
||||
isProcessing ? 'is-processing' : ''
|
||||
].filter(Boolean).join(' ')
|
||||
isProcessing ? 'is-processing' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
// Density-specific behavior
|
||||
const showThumbnail = density !== 'compact'
|
||||
const showAuthor = density === 'spacious'
|
||||
|
||||
// Separate system labels (Flair) from user labels (Tags)
|
||||
const flairLabels = item.labels?.filter(label => label.internal === true) || []
|
||||
const userTags = item.labels?.filter(label => !label.internal) || []
|
||||
const flairLabels =
|
||||
item.labels?.filter((label) => label.internal === true) || []
|
||||
const userTags = item.labels?.filter((label) => !label.internal) || []
|
||||
|
||||
// Show skeleton loader when processing
|
||||
if (item.state === 'PROCESSING') {
|
||||
|
|
@ -138,6 +135,8 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
alt={item.title}
|
||||
className="thumbnail-image"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
// fetchPriority="low"
|
||||
onError={(e) => {
|
||||
// Fallback to placeholder on image load error
|
||||
e.currentTarget.style.display = 'none'
|
||||
|
|
@ -170,7 +169,16 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
aria-label="Card actions"
|
||||
aria-expanded={showMenu}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
|
|
@ -182,12 +190,29 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
|
||||
{/* Dropdown menu - positioned outside thumbnail to avoid overflow clipping */}
|
||||
{showMenu && !isMultiSelectMode && showThumbnail && (
|
||||
<div ref={menuRef} className="card-menu-dropdown" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="card-menu-dropdown"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction(item.state === 'ARCHIVED' ? 'unarchive' : 'archive')}
|
||||
onClick={() =>
|
||||
handleMenuAction(
|
||||
item.state === 'ARCHIVED' ? 'unarchive' : 'archive',
|
||||
)
|
||||
}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
||||
<rect x="1" y="3" width="22" height="5"></rect>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
|
|
@ -199,7 +224,16 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('set-labels')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
||||
</svg>
|
||||
|
|
@ -210,7 +244,16 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('open-notebook')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||
</svg>
|
||||
|
|
@ -221,7 +264,16 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('open-original')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
|
|
@ -233,7 +285,16 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('edit-info')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
|
|
@ -242,12 +303,23 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction(progressPercent > 0 ? 'mark-unread' : 'mark-read')}
|
||||
onClick={() =>
|
||||
handleMenuAction(isRead ? 'mark-unread' : 'mark-read')
|
||||
}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
{progressPercent > 0 ? 'Mark Unread' : 'Mark Read'}
|
||||
{isRead ? 'Mark Unread' : 'Mark Read'}
|
||||
</button>
|
||||
|
||||
<div className="card-menu-divider"></div>
|
||||
|
|
@ -256,7 +328,16 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
className="card-menu-item card-menu-item-danger"
|
||||
onClick={() => handleMenuAction('delete')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
|
|
@ -276,9 +357,7 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
<p className="card-description">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="card-description">{item.description}</p>
|
||||
)}
|
||||
|
||||
{/* Metadata bar - Site name, Author, Reading time, Saved date */}
|
||||
|
|
@ -286,7 +365,17 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
{/* Site name/source with globe icon */}
|
||||
{item.siteName && (
|
||||
<div className="metadata-item">
|
||||
<svg className="metadata-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
className="metadata-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
|
|
@ -298,7 +387,17 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
{/* Author name with user icon */}
|
||||
{item.author && (
|
||||
<div className="metadata-item">
|
||||
<svg className="metadata-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
className="metadata-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
|
|
@ -309,7 +408,17 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
{/* Reading time with book-open icon */}
|
||||
{readingTime && (
|
||||
<div className="metadata-item">
|
||||
<svg className="metadata-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
className="metadata-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||
</svg>
|
||||
|
|
@ -320,7 +429,17 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
{/* Saved date with bookmark icon */}
|
||||
{timestamp && (
|
||||
<div className="metadata-item">
|
||||
<svg className="metadata-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
className="metadata-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m19 21-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"></path>
|
||||
</svg>
|
||||
<span>{timestamp}</span>
|
||||
|
|
@ -337,7 +456,17 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
className="label-badge"
|
||||
title={label.description || label.name}
|
||||
>
|
||||
<svg className="label-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
className="label-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
||||
</svg>
|
||||
|
|
@ -359,7 +488,11 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
setShowAllLabels(!showAllLabels)
|
||||
}
|
||||
}}
|
||||
title={showAllLabels ? 'Show fewer labels' : `Show ${userTags.length - 3} more labels`}
|
||||
title={
|
||||
showAllLabels
|
||||
? 'Show fewer labels'
|
||||
: `Show ${userTags.length - 3} more labels`
|
||||
}
|
||||
>
|
||||
{showAllLabels ? '− Show less' : `+${userTags.length - 3}`}
|
||||
</span>
|
||||
|
|
@ -368,14 +501,16 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar at bottom of card */}
|
||||
{progressPercent > 0 && (
|
||||
{/* Reading progress bar - positioned absolutely at bottom */}
|
||||
{/* Show progress bar for any article with progress (1-100%) */}
|
||||
{/* Color indicates status: blue (0-33%), yellow (34-66%), orange (67-99%), green (100%) */}
|
||||
{readingProgressPercent > 0 && (
|
||||
<div className="card-progress-bar">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: progressColor
|
||||
width: `${readingProgressPercent}%`,
|
||||
backgroundColor: getProgressColor(readingProgressPercent),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -384,4 +519,12 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
// Helper function to determine progress bar color based on completion percentage
|
||||
function getProgressColor(percent: number): string {
|
||||
if (percent < 34) return '#4a9eff' // Blue: 0-33% (started)
|
||||
if (percent < 67) return '#ffd700' // Yellow: 34-66% (in progress)
|
||||
|
||||
return '#10b981' // Green: 67-100% (almost done)
|
||||
}
|
||||
|
||||
export default LibraryItemCard
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// Library item row component for list view
|
||||
// Horizontal layout matching legacy Omnivore UI
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import '../styles/LibraryList.css'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { calculateReadingTime, formatTimestamp } from '../lib/reading-time'
|
||||
import type { LibraryItem } from '../types/api'
|
||||
import type { CardAction } from './LibraryItemCard'
|
||||
import {
|
||||
calculateReadingTime,
|
||||
formatTimestamp,
|
||||
getProgressColor
|
||||
} from '../lib/reading-time'
|
||||
import '../styles/LibraryList.css'
|
||||
|
||||
interface LibraryItemRowProps {
|
||||
item: LibraryItem
|
||||
|
|
@ -28,15 +26,14 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
onRead,
|
||||
onAction,
|
||||
onToggleSelect,
|
||||
isProcessing = false
|
||||
isProcessing = false,
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [showAllLabels, setShowAllLabels] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const readingTime = calculateReadingTime(item.wordCount)
|
||||
const timestamp = formatTimestamp(item.savedAt)
|
||||
const progressPercent = item.readingProgressTopPercent ?? 0
|
||||
const progressColor = getProgressColor(progressPercent)
|
||||
const isRead = !!item.readAt
|
||||
|
||||
// Determine thumbnail/icon source
|
||||
const thumbnailSrc = item.thumbnail || item.siteIcon
|
||||
|
|
@ -52,6 +49,7 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
|
||||
if (showMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showMenu])
|
||||
|
|
@ -62,11 +60,12 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
}
|
||||
|
||||
const isArchived = item.folder === 'archive' || item.state === 'ARCHIVED'
|
||||
const isRead = item.readAt !== null && item.readAt !== undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`library-item-row ${isSelected ? 'selected' : ''} ${showMenu ? 'menu-open' : ''}`}
|
||||
className={`library-item-row ${isSelected ? 'selected' : ''} ${
|
||||
showMenu ? 'menu-open' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
{isMultiSelectMode && onToggleSelect && (
|
||||
|
|
@ -89,11 +88,14 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
alt=""
|
||||
className={showSiteIcon ? 'site-icon-img' : 'thumbnail-img'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
// fetchPriority="low"
|
||||
onError={(e) => {
|
||||
// Fallback to placeholder on error
|
||||
const parent = e.currentTarget.parentElement
|
||||
if (parent) {
|
||||
parent.innerHTML = '<div class="thumbnail-placeholder-small">📄</div>'
|
||||
parent.innerHTML =
|
||||
'<div class="thumbnail-placeholder-small">📄</div>'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -124,9 +126,7 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="metadata-item timestamp-text">
|
||||
{timestamp}
|
||||
</span>
|
||||
<span className="metadata-item timestamp-text">{timestamp}</span>
|
||||
|
||||
{/* Reading time */}
|
||||
{readingTime && (
|
||||
|
|
@ -139,18 +139,12 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{progressPercent > 0 && (
|
||||
{/* Read badge */}
|
||||
{isRead && (
|
||||
<div className="row-progress">
|
||||
<div className="progress-bar-container-small">
|
||||
<div
|
||||
className="progress-bar-fill-small"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: progressColor
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="read-badge" title="Read">
|
||||
✓ Read
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -159,16 +153,18 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
<div className="row-labels">
|
||||
{item.labels && item.labels.length > 0 && (
|
||||
<div className="labels-list">
|
||||
{(showAllLabels ? item.labels : item.labels.slice(0, 2)).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="label-badge-small"
|
||||
style={{ backgroundColor: label.color }}
|
||||
title={label.description || label.name}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(showAllLabels ? item.labels : item.labels.slice(0, 2)).map(
|
||||
(label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="label-badge-small"
|
||||
style={{ backgroundColor: label.color }}
|
||||
title={label.description || label.name}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{item.labels.length > 2 && (
|
||||
<span
|
||||
className="label-badge-small label-more-small"
|
||||
|
|
@ -196,7 +192,14 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
disabled={isProcessing}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
|
|
@ -205,17 +208,47 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
|
||||
{/* Dropdown menu */}
|
||||
{showMenu && (
|
||||
<div ref={menuRef} className="row-menu-dropdown" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="card-menu-item" onClick={() => handleMenuAction('set-labels')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="row-menu-dropdown"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('set-labels')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
||||
</svg>
|
||||
<span>Set Labels</span>
|
||||
</button>
|
||||
|
||||
<button className="card-menu-item" onClick={() => handleMenuAction(isRead ? 'mark-unread' : 'mark-read')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() =>
|
||||
handleMenuAction(isRead ? 'mark-unread' : 'mark-read')
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{isRead ? (
|
||||
<>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
|
|
@ -232,8 +265,22 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
<span>{isRead ? 'Mark as Unread' : 'Mark as Read'}</span>
|
||||
</button>
|
||||
|
||||
<button className="card-menu-item" onClick={() => handleMenuAction(isArchived ? 'unarchive' : 'archive')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() =>
|
||||
handleMenuAction(isArchived ? 'unarchive' : 'archive')
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 8v13H3V8"></path>
|
||||
<path d="M1 3h22v5H1z"></path>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
|
|
@ -241,16 +288,40 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
<span>{isArchived ? 'Unarchive' : 'Archive'}</span>
|
||||
</button>
|
||||
|
||||
<button className="card-menu-item" onClick={() => handleMenuAction('open-notebook')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('open-notebook')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||
</svg>
|
||||
<span>Open Notebook</span>
|
||||
</button>
|
||||
|
||||
<button className="card-menu-item" onClick={() => handleMenuAction('open-original')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('open-original')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
|
|
@ -258,8 +329,20 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
<span>Open Original</span>
|
||||
</button>
|
||||
|
||||
<button className="card-menu-item" onClick={() => handleMenuAction('edit-info')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<button
|
||||
className="card-menu-item"
|
||||
onClick={() => handleMenuAction('edit-info')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
|
|
@ -268,8 +351,20 @@ const LibraryItemRow: React.FC<LibraryItemRowProps> = ({
|
|||
|
||||
<div className="card-menu-divider"></div>
|
||||
|
||||
<button className="card-menu-item card-menu-item-danger" onClick={() => handleMenuAction('delete')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<button
|
||||
className="card-menu-item card-menu-item-danger"
|
||||
onClick={() => handleMenuAction('delete')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@
|
|||
* - Escape key to exit multi-select mode
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import '../styles/MultiSelectActionBar.css'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
interface MultiSelectActionBarProps {
|
||||
selectedCount: number
|
||||
onArchive: () => void
|
||||
|
|
@ -43,6 +44,7 @@ const MultiSelectActionBar: React.FC<MultiSelectActionBarProps> = ({
|
|||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [onExitMultiSelect])
|
||||
|
||||
|
|
|
|||
220
packages/web-vite/src/components/NotebookModal.tsx
Normal file
220
packages/web-vite/src/components/NotebookModal.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Notebook Modal Component - Markdown-based note taking for articles
|
||||
// Allows users to write notes about articles they're reading
|
||||
|
||||
import '../styles/NotebookModal.css'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
|
||||
interface NotebookModalProps {
|
||||
itemTitle: string
|
||||
currentNote?: string | null
|
||||
onSave: (note: string) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const NotebookModal: React.FC<NotebookModalProps> = ({
|
||||
itemTitle,
|
||||
currentNote,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const [note, setNote] = useState(currentNote || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'edit' | 'preview'>('edit')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(note !== (currentNote || ''))
|
||||
}, [note, currentNote])
|
||||
|
||||
// Auto-focus textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
// Place cursor at end
|
||||
textareaRef.current.selectionStart = note.length
|
||||
textareaRef.current.selectionEnd = note.length
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl + S to save
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (hasChanges) {
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
// Escape to close (with confirmation if there are unsaved changes)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [hasChanges, note])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return
|
||||
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
|
||||
try {
|
||||
await onSave(note)
|
||||
setSaved(true)
|
||||
setHasChanges(false)
|
||||
|
||||
// Clear "saved" indicator after 2 seconds
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to save notebook:', error)
|
||||
alert('Failed to save notebook. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (hasChanges) {
|
||||
const confirmed = window.confirm(
|
||||
'You have unsaved changes. Are you sure you want to close without saving?'
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
const wordCount = note.trim().split(/\s+/).filter(Boolean).length
|
||||
const charCount = note.length
|
||||
|
||||
return (
|
||||
<div className="notebook-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="notebook-modal">
|
||||
{/* Header */}
|
||||
<div className="notebook-header">
|
||||
<div className="notebook-title-section">
|
||||
<h2 className="notebook-title">Notebook</h2>
|
||||
<p className="notebook-article-title" title={itemTitle}>
|
||||
{itemTitle}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="notebook-close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close notebook"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="notebook-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`notebook-tab ${viewMode === 'edit' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('edit')}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`notebook-tab ${viewMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('preview')}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor / Preview */}
|
||||
<div className="notebook-editor">
|
||||
{viewMode === 'edit' ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="notebook-textarea"
|
||||
placeholder="Write your notes here... Markdown supported."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
spellCheck
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="notebook-preview"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(note) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with stats and actions */}
|
||||
<div className="notebook-footer">
|
||||
<div className="notebook-stats">
|
||||
<span className="notebook-stat">
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
</span>
|
||||
<span className="notebook-stat-separator">•</span>
|
||||
<span className="notebook-stat">
|
||||
{charCount} {charCount === 1 ? 'character' : 'characters'}
|
||||
</span>
|
||||
{hasChanges && (
|
||||
<>
|
||||
<span className="notebook-stat-separator">•</span>
|
||||
<span className="notebook-unsaved">Unsaved changes</span>
|
||||
</>
|
||||
)}
|
||||
{saved && (
|
||||
<>
|
||||
<span className="notebook-stat-separator">•</span>
|
||||
<span className="notebook-saved">Saved!</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notebook-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="notebook-btn notebook-btn-secondary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{hasChanges ? 'Cancel' : 'Close'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="notebook-btn notebook-btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="notebook-help">
|
||||
<span className="notebook-help-hint">
|
||||
💡 Tip: Use Markdown formatting. Press <kbd>⌘S</kbd> or{' '}
|
||||
<kbd>Ctrl+S</kbd> to save
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotebookModal
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
// Tests for API client
|
||||
// Comprehensive testing of API communication
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
|
||||
import { OmnivoreApiClient, AUTH_TOKEN_STORAGE_KEY } from '../api-client'
|
||||
import { mockUser, mockArticle } from '../../test/utils'
|
||||
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mockArticle, mockUser } from '../../test/utils'
|
||||
import { AUTH_TOKEN_STORAGE_KEY, OmnivoreApiClient } from '../api-client'
|
||||
|
||||
const fetchMock = () => fetch as unknown as Mock
|
||||
|
||||
|
|
|
|||
427
packages/web-vite/src/lib/anchoredHighlights.ts
Normal file
427
packages/web-vite/src/lib/anchoredHighlights.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
// AnchoredHighlights - Robust highlighting with multi-selector anchoring
|
||||
// Provides DOM Range → TextPosition → TextQuote fallback anchoring,
|
||||
// idempotent application, multi-node wrapping, and mutation observers
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import type {
|
||||
AnchorDomRange,
|
||||
AnchoredSelectors,
|
||||
AnchorTextPosition,
|
||||
AnchorTextQuote,
|
||||
HighlightColor,
|
||||
} from '../types/api'
|
||||
|
||||
export interface AnchoredHighlight {
|
||||
id: string
|
||||
color: HighlightColor
|
||||
annotation?: string
|
||||
selectors: AnchoredSelectors
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
id: string
|
||||
marks: HTMLElement[]
|
||||
}
|
||||
|
||||
/**********************
|
||||
* Internal utilities *
|
||||
**********************/
|
||||
|
||||
// Compact path serialization: indexes through childNodes from root
|
||||
function pathTo(node: Node, root: Node): string {
|
||||
const parts: string[] = []
|
||||
let n: Node | null = node
|
||||
while (n && n !== root) {
|
||||
const parent: Node | null = n.parentNode
|
||||
if (!parent) break
|
||||
const i = Array.prototype.indexOf.call(parent.childNodes, n)
|
||||
parts.push(String(i))
|
||||
n = parent
|
||||
}
|
||||
|
||||
return parts.reverse().join('/')
|
||||
}
|
||||
|
||||
function nodeFromPath(path: string, root: Node): Node | null {
|
||||
if (!path) return null
|
||||
|
||||
return path.split('/').reduce<Node | null>((curr, idx) => {
|
||||
if (!curr) return null
|
||||
const i = Number(idx)
|
||||
|
||||
return curr.childNodes[i] ?? null
|
||||
}, root)
|
||||
}
|
||||
|
||||
// Text index maps linear offsets ↔ Text nodes for the container subtree
|
||||
type TextSlice = { node: Text; start: number; end: number }
|
||||
|
||||
function buildIndex(root: HTMLElement) {
|
||||
const slices: TextSlice[] = []
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
let node: Node | null
|
||||
let offset = 0
|
||||
while ((node = walker.nextNode())) {
|
||||
const t = node as Text
|
||||
const len = t.data.length
|
||||
slices.push({ node: t, start: offset, end: offset + len })
|
||||
offset += len
|
||||
}
|
||||
|
||||
return { slices, length: offset }
|
||||
}
|
||||
|
||||
function positionFromRange(range: Range, root: HTMLElement) {
|
||||
const { slices } = buildIndex(root)
|
||||
const toAbs = (n: Node, off: number) => {
|
||||
if (n.nodeType !== Node.TEXT_NODE) {
|
||||
// If selection boundary is an element, move inward to nearest text
|
||||
const walker = document.createTreeWalker(n, NodeFilter.SHOW_TEXT)
|
||||
const t = walker.nextNode() as Text | null
|
||||
if (!t) throw new Error('No text node at boundary')
|
||||
n = t
|
||||
off = 0
|
||||
}
|
||||
const slice = slices.find((s) => s.node === n)
|
||||
if (!slice) throw new Error('Node not indexed')
|
||||
|
||||
return slice.start + off
|
||||
}
|
||||
|
||||
return {
|
||||
start: toAbs(range.startContainer, range.startOffset),
|
||||
end: toAbs(range.endContainer, range.endOffset),
|
||||
}
|
||||
}
|
||||
|
||||
function rangeFromPosition(pos: AnchorTextPosition, root: HTMLElement) {
|
||||
const { slices } = buildIndex(root)
|
||||
let startNode: Text | null = null
|
||||
let startOff = 0
|
||||
let endNode: Text | null = null
|
||||
let endOff = 0
|
||||
for (const s of slices) {
|
||||
if (!startNode && pos.start >= s.start && pos.start <= s.end) {
|
||||
startNode = s.node
|
||||
startOff = pos.start - s.start
|
||||
}
|
||||
if (!endNode && pos.end >= s.start && pos.end <= s.end) {
|
||||
endNode = s.node
|
||||
endOff = pos.end - s.start
|
||||
}
|
||||
if (startNode && endNode) break
|
||||
}
|
||||
if (!startNode || !endNode) return null
|
||||
const r = document.createRange()
|
||||
r.setStart(startNode, startOff)
|
||||
r.setEnd(endNode, endOff)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Tolerant text search using exact + (optional) prefix/suffix with whitespace normalization
|
||||
function findByQuote(root: HTMLElement, q: AnchorTextQuote): Range | null {
|
||||
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
||||
const full = norm(root.textContent || '')
|
||||
const exact = norm(q.exact)
|
||||
if (!exact) return null
|
||||
|
||||
// If prefix/suffix provided, search in that context window to reduce false matches
|
||||
let startIndex = 0
|
||||
let endIndex = full.length
|
||||
if (q.prefix) {
|
||||
const p = norm(q.prefix)
|
||||
const i = full.indexOf(p)
|
||||
if (i >= 0) startIndex = i + p.length
|
||||
}
|
||||
if (q.suffix) {
|
||||
const s = norm(q.suffix)
|
||||
const i = full.indexOf(s)
|
||||
if (i >= 0) endIndex = i
|
||||
}
|
||||
|
||||
const segment = full.slice(startIndex, endIndex)
|
||||
const rel = segment.indexOf(exact)
|
||||
if (rel < 0) return null
|
||||
|
||||
const absStart = startIndex + rel
|
||||
const absEnd = absStart + exact.length
|
||||
|
||||
return rangeFromPosition(
|
||||
{
|
||||
start: mapNormalizedToRawOffset(root, absStart),
|
||||
end: mapNormalizedToRawOffset(root, absEnd),
|
||||
},
|
||||
root,
|
||||
)
|
||||
}
|
||||
|
||||
// Map normalized (collapsed-space) offset back to raw offset
|
||||
function mapNormalizedToRawOffset(root: HTMLElement, target: number): number {
|
||||
// Walk text nodes accumulating a normalized counter and return raw offset index
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
let node: Node | null
|
||||
let normCount = 0
|
||||
while ((node = walker.nextNode())) {
|
||||
const t = node as Text
|
||||
const raw = t.data
|
||||
let i = 0
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i]
|
||||
const isSpace = /\s/.test(ch)
|
||||
if (!isSpace) {
|
||||
if (normCount === target) {
|
||||
return buildIndex(root).slices.find((s) => s.node === t)!.start + i
|
||||
}
|
||||
normCount++
|
||||
} else {
|
||||
// collapse sequences of whitespace to a single space
|
||||
// count one normalized space when encountering the first of a run
|
||||
if (i === 0 || !/\s/.test(raw[i - 1])) {
|
||||
if (normCount === target) {
|
||||
return buildIndex(root).slices.find((s) => s.node === t)!.start + i
|
||||
}
|
||||
normCount++
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
// If target beyond, clamp to end
|
||||
return buildIndex(root).length
|
||||
}
|
||||
|
||||
function serializeRange(range: Range, root: HTMLElement): AnchorDomRange {
|
||||
const startNode =
|
||||
range.startContainer.nodeType === Node.TEXT_NODE
|
||||
? range.startContainer
|
||||
: range.startContainer.childNodes[range.startOffset]
|
||||
const endNode =
|
||||
range.endContainer.nodeType === Node.TEXT_NODE
|
||||
? range.endContainer
|
||||
: range.endContainer.childNodes[range.endOffset]
|
||||
|
||||
const sNode =
|
||||
startNode?.nodeType === Node.TEXT_NODE ? startNode : range.startContainer
|
||||
const eNode =
|
||||
endNode?.nodeType === Node.TEXT_NODE ? endNode : range.endContainer
|
||||
|
||||
return {
|
||||
startPath: pathTo(sNode!, root),
|
||||
startOffset: range.startOffset,
|
||||
endPath: pathTo(eNode!, root),
|
||||
endOffset: range.endOffset,
|
||||
}
|
||||
}
|
||||
|
||||
function rangeFromDomSelector(
|
||||
sel: AnchorDomRange,
|
||||
root: HTMLElement,
|
||||
): Range | null {
|
||||
const sNode = nodeFromPath(sel.startPath, root)
|
||||
const eNode = nodeFromPath(sel.endPath, root)
|
||||
if (!sNode || !eNode) return null
|
||||
if (sNode.nodeType !== Node.TEXT_NODE || eNode.nodeType !== Node.TEXT_NODE) {
|
||||
return null
|
||||
}
|
||||
const r = document.createRange()
|
||||
try {
|
||||
r.setStart(sNode as Text, sel.startOffset)
|
||||
r.setEnd(eNode as Text, sel.endOffset)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Wrap a Range across multiple text nodes, return all created marks
|
||||
function wrapRange(root: HTMLElement, range: Range, cls: string, id: string) {
|
||||
const marks: HTMLElement[] = []
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const texts: Text[] = []
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) texts.push(node as Text)
|
||||
|
||||
const startT = range.startContainer as Text
|
||||
const endT = range.endContainer as Text
|
||||
|
||||
let active = false
|
||||
for (const t of texts) {
|
||||
if (!active && t === startT) active = true
|
||||
if (!active) continue
|
||||
|
||||
const sOff = t === startT ? range.startOffset : 0
|
||||
const eOff = t === endT ? range.endOffset : t.data.length
|
||||
if (eOff > sOff) {
|
||||
// Split the text node to isolate the highlighted portion
|
||||
// If sOff > 0: split to separate text before highlight
|
||||
// If eOff < length: split to separate text after highlight
|
||||
let targetNode = t
|
||||
|
||||
// Split off text before the highlight (if any)
|
||||
if (sOff > 0) {
|
||||
targetNode = t.splitText(sOff) as Text
|
||||
}
|
||||
|
||||
// Split off text after the highlight (if any)
|
||||
if (eOff < t.data.length) {
|
||||
targetNode.splitText(eOff - sOff) as Text
|
||||
}
|
||||
|
||||
// Wrap the target node in a mark element
|
||||
const mark = document.createElement('mark')
|
||||
mark.className = cls
|
||||
mark.dataset.hl = '1'
|
||||
mark.dataset.id = id
|
||||
mark.setAttribute('aria-label', 'Highlight')
|
||||
mark.textContent = targetNode.data
|
||||
targetNode.parentNode!.replaceChild(mark, targetNode)
|
||||
marks.push(mark)
|
||||
}
|
||||
if (t === endT) break
|
||||
}
|
||||
|
||||
return marks
|
||||
}
|
||||
|
||||
function clearExistingMarks(root: HTMLElement) {
|
||||
root.querySelectorAll('mark[data-hl="1"]').forEach((m) => {
|
||||
const parent = m.parentNode
|
||||
const text = document.createTextNode(m.textContent || '')
|
||||
m.replaceWith(text)
|
||||
// Normalize AFTER replace, using saved parent reference
|
||||
parent?.normalize()
|
||||
})
|
||||
}
|
||||
|
||||
/**********************
|
||||
* Hook: useAnchors *
|
||||
**********************/
|
||||
|
||||
export function useAnchoredHighlights(
|
||||
contentRef: React.RefObject<HTMLElement>,
|
||||
highlights: AnchoredHighlight[],
|
||||
) {
|
||||
const reapply = useRef<() => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
const root = contentRef.current
|
||||
if (!root) return
|
||||
|
||||
const apply = () => {
|
||||
clearExistingMarks(root)
|
||||
|
||||
const applied: ApplyResult[] = []
|
||||
|
||||
for (const h of highlights) {
|
||||
let r: Range | null = null
|
||||
// 1) DOM Range
|
||||
if (h.selectors.domRange) {
|
||||
r = rangeFromDomSelector(h.selectors.domRange, root)
|
||||
}
|
||||
// 2) TextPosition
|
||||
if (!r && h.selectors.textPosition) {
|
||||
r = rangeFromPosition(h.selectors.textPosition, root)
|
||||
}
|
||||
// 3) TextQuote
|
||||
if (!r && h.selectors.textQuote) {
|
||||
r = findByQuote(root, h.selectors.textQuote)
|
||||
}
|
||||
|
||||
if (r) {
|
||||
const cls = `highlight highlight-${h.color.toLowerCase()}`
|
||||
const marks = wrapRange(root, r, cls, h.id)
|
||||
if (marks.length) {
|
||||
applied.push({ id: h.id, marks })
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[AnchoredHighlights] Could not find range for highlight ${h.id}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return applied
|
||||
}
|
||||
|
||||
reapply.current = apply
|
||||
|
||||
// Defer initial application to avoid blocking the main thread
|
||||
const applyTimer = setTimeout(() => apply(), 100)
|
||||
|
||||
// TEMPORARILY DISABLED - MutationObserver causing performance issues
|
||||
// Only reapply on explicit highlight changes, not on every DOM mutation
|
||||
// TODO: Re-enable with throttling once performance is stable
|
||||
|
||||
// const mo = new MutationObserver(() => {
|
||||
// queueMicrotask(() => reapply.current())
|
||||
// })
|
||||
// mo.observe(root, { subtree: true, characterData: true, childList: true })
|
||||
|
||||
// const ro =
|
||||
// typeof ResizeObserver !== 'undefined'
|
||||
// ? new ResizeObserver(() => reapply.current())
|
||||
// : null
|
||||
|
||||
// if (ro) ro.observe(root)
|
||||
|
||||
return () => {
|
||||
clearTimeout(applyTimer)
|
||||
// mo.disconnect()
|
||||
// ro?.disconnect()
|
||||
}
|
||||
}, [contentRef, highlights])
|
||||
|
||||
return {
|
||||
reapply: () => reapply.current?.(),
|
||||
jumpTo: (id: string) => {
|
||||
const el = contentRef.current?.querySelector(`mark[data-id="${id}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('highlight-flash')
|
||||
setTimeout(() => el.classList.remove('highlight-flash'), 900)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/********************************
|
||||
* Selector capture on creation *
|
||||
********************************/
|
||||
|
||||
export function buildSelectorsFromSelection(
|
||||
selection: Selection,
|
||||
contentEl: HTMLElement,
|
||||
textQuoteContextWords = 5,
|
||||
): AnchoredSelectors {
|
||||
if (!selection.rangeCount) throw new Error('No selection range')
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
// DOM Range
|
||||
const domRange = serializeRange(range, contentEl)
|
||||
|
||||
// TextPosition
|
||||
const pos = positionFromRange(range, contentEl)
|
||||
|
||||
// TextQuote (with ±N words context from linearized content)
|
||||
const full = contentEl.textContent || ''
|
||||
const exact = selection.toString()
|
||||
const { start, end } = pos
|
||||
|
||||
const left = full.slice(0, start).trim()
|
||||
const right = full.slice(end).trim()
|
||||
const wordsL = left.split(/\s+/).slice(-textQuoteContextWords).join(' ')
|
||||
const wordsR = right.split(/\s+/).slice(0, textQuoteContextWords).join(' ')
|
||||
|
||||
return {
|
||||
domRange,
|
||||
textPosition: { start, end },
|
||||
textQuote: {
|
||||
exact,
|
||||
prefix: wordsL || undefined,
|
||||
suffix: wordsR || undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export const AUTH_TOKEN_STORAGE_KEY = 'omnivore-auth-token'
|
|||
|
||||
const resolveBaseUrl = (): string => {
|
||||
const envUrl = import.meta.env?.VITE_API_URL as string | undefined
|
||||
|
||||
return (envUrl && envUrl.trim().length > 0 ? envUrl : DEFAULT_BASE_URL).replace(
|
||||
/\/$/,
|
||||
''
|
||||
|
|
@ -30,6 +31,7 @@ const getStoredToken = (): string | null => {
|
|||
return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)
|
||||
} catch (error) {
|
||||
console.warn('Unable to read auth token from storage', error)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +40,7 @@ const buildUrl = (baseUrl: string, endpoint: string): string => {
|
|||
const normalizedEndpoint = endpoint.startsWith('/')
|
||||
? endpoint
|
||||
: `/${endpoint}`
|
||||
|
||||
return `${baseUrl}${normalizedEndpoint}`
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +120,7 @@ class OmnivoreApiClient {
|
|||
if (isBrowser) {
|
||||
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
return { authStatus: 'NOT_AUTHENTICATED' }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
packages/web-vite/src/lib/contentHash.ts
Normal file
83
packages/web-vite/src/lib/contentHash.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Content hash utilities for sentinel-based reading progress
|
||||
*
|
||||
* Generates stable SHA-256 hashes of HTML content to track versions
|
||||
* and detect when article content has changed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a SHA-256 hash of HTML content for version tracking
|
||||
*
|
||||
* Uses the Web Crypto API for consistent, browser-native hashing.
|
||||
* Normalizes whitespace before hashing to ensure consistency.
|
||||
*
|
||||
* @param html - The HTML content to hash
|
||||
* @returns Promise resolving to hex-encoded SHA-256 hash
|
||||
*/
|
||||
export async function generateContentHash(html: string): Promise<string> {
|
||||
// Normalize whitespace for consistent hashing
|
||||
const normalized = html
|
||||
.replace(/\s+/g, ' ') // Collapse multiple whitespace to single space
|
||||
.trim()
|
||||
|
||||
// Convert string to Uint8Array for Web Crypto API
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(normalized)
|
||||
|
||||
// Generate SHA-256 hash
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
|
||||
// Convert ArrayBuffer to hex string
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
// Truncate to 64 chars to match database column size
|
||||
return hashHex.substring(0, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous fallback hash for environments without crypto.subtle
|
||||
* Uses simple string hashing algorithm (djb2)
|
||||
*
|
||||
* @param html - The HTML content to hash
|
||||
* @returns Hex-encoded hash string
|
||||
*/
|
||||
export function generateContentHashSync(html: string): string {
|
||||
const normalized = html.replace(/\s+/g, ' ').trim()
|
||||
|
||||
let hash = 5381
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
hash = (hash * 33) ^ normalized.charCodeAt(i)
|
||||
}
|
||||
|
||||
// Convert to unsigned 32-bit integer, then to hex
|
||||
const hashValue = (hash >>> 0).toString(16).padStart(8, '0')
|
||||
|
||||
// Pad to 64 chars for consistency with SHA-256
|
||||
return hashValue.padEnd(64, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Web Crypto API is available
|
||||
*/
|
||||
export function isCryptoAvailable(): boolean {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content hash with automatic fallback
|
||||
*
|
||||
* Prefers Web Crypto API (SHA-256), falls back to simple hash if unavailable
|
||||
*
|
||||
* @param html - The HTML content to hash
|
||||
* @returns Promise resolving to hex-encoded hash
|
||||
*/
|
||||
export async function generateContentHashWithFallback(
|
||||
html: string
|
||||
): Promise<string> {
|
||||
if (isCryptoAvailable()) {
|
||||
return generateContentHash(html)
|
||||
}
|
||||
|
||||
return generateContentHashSync(html)
|
||||
}
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
// Minimal GraphQL helper targeting the NestJS `/api/graphql` endpoint
|
||||
// Mirrors the behaviour of the legacy web package's fetcher but keeps dependencies light
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { LibraryItem, DeleteResult } from '../types/api'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import type { DeleteResult, HighlightColor, LibraryItem } from '../types/api'
|
||||
import {
|
||||
HIGHLIGHT_FRAGMENT,
|
||||
LABEL_BASIC_FRAGMENT,
|
||||
LABEL_FRAGMENT,
|
||||
LIBRARY_ITEM_FULL_FRAGMENT,
|
||||
READING_PROGRESS_FRAGMENT,
|
||||
} from './graphql-fragments'
|
||||
|
||||
const DEFAULT_GRAPHQL_PATH = '/api/graphql'
|
||||
const TOKEN_STORAGE_KEY = 'omnivore-auth-token'
|
||||
|
|
@ -22,7 +30,7 @@ const resolveGraphqlUrl = (): string => {
|
|||
if (normalizedBase.endsWith('/api/v2')) {
|
||||
return `${normalizedBase.slice(
|
||||
0,
|
||||
-'/api/v2'.length
|
||||
-'/api/v2'.length,
|
||||
)}${DEFAULT_GRAPHQL_PATH}`
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +50,7 @@ export interface GraphqlResponse<T> {
|
|||
|
||||
export async function graphqlRequest<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const endpoint = resolveGraphqlUrl()
|
||||
const token = isBrowser
|
||||
|
|
@ -99,18 +107,6 @@ const DELETE_LIBRARY_ITEM_MUTATION = `
|
|||
}
|
||||
`
|
||||
|
||||
const UPDATE_READING_PROGRESS_MUTATION = `
|
||||
mutation UpdateReadingProgress($id: String!, $progress: ReadingProgressInput!) {
|
||||
updateReadingProgress(id: $id, progress: $progress) {
|
||||
id
|
||||
readingProgressTopPercent
|
||||
readingProgressBottomPercent
|
||||
readAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION = `
|
||||
mutation MoveLibraryItemToFolder($id: String!, $folder: String!) {
|
||||
moveLibraryItemToFolder(id: $id, folder: $folder) {
|
||||
|
|
@ -171,22 +167,11 @@ const BULK_MARK_AS_READ_MUTATION = `
|
|||
`
|
||||
|
||||
const SAVE_URL_MUTATION = `
|
||||
${LIBRARY_ITEM_FULL_FRAGMENT}
|
||||
${LABEL_BASIC_FRAGMENT}
|
||||
mutation SaveUrl($input: SaveUrlInput!) {
|
||||
saveUrl(input: $input) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
originalUrl
|
||||
author
|
||||
description
|
||||
savedAt
|
||||
createdAt
|
||||
updatedAt
|
||||
publishedAt
|
||||
readAt
|
||||
state
|
||||
contentReader
|
||||
folder
|
||||
...LibraryItemFullFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -214,6 +199,7 @@ export function useArchiveItem() {
|
|||
archived,
|
||||
})
|
||||
setState({ loading: false, error: null, data })
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Archive failed')
|
||||
|
|
@ -237,6 +223,7 @@ export function useDeleteItem() {
|
|||
try {
|
||||
const data = await graphqlRequest(DELETE_LIBRARY_ITEM_MUTATION, { id })
|
||||
setState({ loading: false, error: null, data })
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Delete failed')
|
||||
|
|
@ -248,44 +235,6 @@ export function useDeleteItem() {
|
|||
return { ...state, deleteItem }
|
||||
}
|
||||
|
||||
export function useUpdateReadingProgress() {
|
||||
const [state, setState] = useState<MutationState<any>>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const updateProgress = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
progress: {
|
||||
readingProgressTopPercent: number
|
||||
readingProgressBottomPercent: number
|
||||
readingProgressAnchorIndex?: number
|
||||
readingProgressHighestAnchor?: number
|
||||
}
|
||||
) => {
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const data = await graphqlRequest(UPDATE_READING_PROGRESS_MUTATION, {
|
||||
id,
|
||||
progress,
|
||||
})
|
||||
setState({ loading: false, error: null, data })
|
||||
return data
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Update progress failed')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { ...state, updateProgress }
|
||||
}
|
||||
|
||||
export function useMoveToFolder() {
|
||||
const [state, setState] = useState<MutationState<any>>({
|
||||
loading: false,
|
||||
|
|
@ -301,6 +250,7 @@ export function useMoveToFolder() {
|
|||
folder,
|
||||
})
|
||||
setState({ loading: false, error: null, data })
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
const err =
|
||||
|
|
@ -338,6 +288,7 @@ export function useBulkArchive() {
|
|||
bulkArchiveItems: BulkActionResult
|
||||
}>(BULK_ARCHIVE_ITEMS_MUTATION, { itemIds, archived })
|
||||
setState({ loading: false, error: null, data: result.bulkArchiveItems })
|
||||
|
||||
return result.bulkArchiveItems
|
||||
} catch (error) {
|
||||
const err =
|
||||
|
|
@ -346,7 +297,7 @@ export function useBulkArchive() {
|
|||
throw err
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, bulkArchive }
|
||||
|
|
@ -362,14 +313,15 @@ export function useBulkDelete() {
|
|||
const bulkDelete = useCallback(async (itemIds: string[]) => {
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{ bulkDeleteItems: BulkActionResult }>(
|
||||
BULK_DELETE_ITEMS_MUTATION,
|
||||
{ itemIds }
|
||||
)
|
||||
const result = await graphqlRequest<{
|
||||
bulkDeleteItems: BulkActionResult
|
||||
}>(BULK_DELETE_ITEMS_MUTATION, { itemIds })
|
||||
setState({ loading: false, error: null, data: result.bulkDeleteItems })
|
||||
|
||||
return result.bulkDeleteItems
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Bulk delete failed')
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Bulk delete failed')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
|
|
@ -393,15 +345,18 @@ export function useBulkMoveToFolder() {
|
|||
bulkMoveToFolder: BulkActionResult
|
||||
}>(BULK_MOVE_TO_FOLDER_MUTATION, { itemIds, folder })
|
||||
setState({ loading: false, error: null, data: result.bulkMoveToFolder })
|
||||
|
||||
return result.bulkMoveToFolder
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Bulk move to folder failed')
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Bulk move to folder failed')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, bulkMoveToFolder }
|
||||
|
|
@ -421,6 +376,7 @@ export function useBulkMarkAsRead() {
|
|||
bulkMarkAsRead: BulkActionResult
|
||||
}>(BULK_MARK_AS_READ_MUTATION, { itemIds })
|
||||
setState({ loading: false, error: null, data: result.bulkMarkAsRead })
|
||||
|
||||
return result.bulkMarkAsRead
|
||||
} catch (error) {
|
||||
const err =
|
||||
|
|
@ -446,17 +402,19 @@ export function useSaveUrl() {
|
|||
try {
|
||||
const result = await graphqlRequest<{ saveUrl: any }>(
|
||||
SAVE_URL_MUTATION,
|
||||
{ input }
|
||||
{ input },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.saveUrl })
|
||||
|
||||
return result.saveUrl
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Save URL failed')
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Save URL failed')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, saveUrl }
|
||||
|
|
@ -490,30 +448,26 @@ export interface UpdateLabelInput {
|
|||
// ==================== LIBRARY ITEM QUERIES ====================
|
||||
|
||||
const GET_LIBRARY_ITEM_QUERY = `
|
||||
${LIBRARY_ITEM_FULL_FRAGMENT}
|
||||
${LABEL_BASIC_FRAGMENT}
|
||||
query GetLibraryItem($id: String!) {
|
||||
libraryItem(id: $id) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
originalUrl
|
||||
author
|
||||
description
|
||||
content
|
||||
savedAt
|
||||
createdAt
|
||||
publishedAt
|
||||
readAt
|
||||
updatedAt
|
||||
readingProgressTopPercent
|
||||
readingProgressBottomPercent
|
||||
state
|
||||
contentReader
|
||||
folder
|
||||
labels {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
...LibraryItemFullFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Batched query for reader page - fetches item + highlights in one request
|
||||
const GET_READER_PAGE_DATA_QUERY = `
|
||||
${LIBRARY_ITEM_FULL_FRAGMENT}
|
||||
${LABEL_FRAGMENT}
|
||||
${HIGHLIGHT_FRAGMENT}
|
||||
query GetReaderPageData($id: String!) {
|
||||
libraryItem(id: $id) {
|
||||
...LibraryItemFullFields
|
||||
}
|
||||
highlights(libraryItemId: $id) {
|
||||
...HighlightFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -521,31 +475,19 @@ const GET_LIBRARY_ITEM_QUERY = `
|
|||
// ==================== LABEL QUERIES ====================
|
||||
|
||||
const GET_LABELS_QUERY = `
|
||||
${LABEL_FRAGMENT}
|
||||
query GetLabels {
|
||||
labels {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
position
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_LABEL_QUERY = `
|
||||
${LABEL_FRAGMENT}
|
||||
query GetLabel($id: String!) {
|
||||
label(id: $id) {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
position
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -553,30 +495,19 @@ const GET_LABEL_QUERY = `
|
|||
// ==================== LABEL MUTATIONS ====================
|
||||
|
||||
const CREATE_LABEL_MUTATION = `
|
||||
${LABEL_FRAGMENT}
|
||||
mutation CreateLabel($input: CreateLabelInput!) {
|
||||
createLabel(input: $input) {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
position
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_LABEL_MUTATION = `
|
||||
${LABEL_FRAGMENT}
|
||||
mutation UpdateLabel($id: String!, $input: UpdateLabelInput!) {
|
||||
updateLabel(id: $id, input: $input) {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
position
|
||||
internal
|
||||
updatedAt
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -592,11 +523,10 @@ const DELETE_LABEL_MUTATION = `
|
|||
`
|
||||
|
||||
const SET_LIBRARY_ITEM_LABELS_MUTATION = `
|
||||
${LABEL_BASIC_FRAGMENT}
|
||||
mutation SetLibraryItemLabels($itemId: String!, $labelIds: [String!]!) {
|
||||
setLibraryItemLabels(itemId: $itemId, labelIds: $labelIds) {
|
||||
id
|
||||
name
|
||||
color
|
||||
...LabelBasicFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -608,6 +538,7 @@ const UPDATE_LIBRARY_ITEM_MUTATION = `
|
|||
title
|
||||
author
|
||||
description
|
||||
readAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
|
@ -631,9 +562,11 @@ export function useLabels() {
|
|||
try {
|
||||
const result = await graphqlRequest<{ labels: Label[] }>(GET_LABELS_QUERY)
|
||||
setState({ loading: false, error: null, data: result.labels })
|
||||
|
||||
return result.labels
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to fetch labels')
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to fetch labels')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
|
|
@ -654,12 +587,14 @@ export function useCreateLabel() {
|
|||
try {
|
||||
const result = await graphqlRequest<{ createLabel: Label }>(
|
||||
CREATE_LABEL_MUTATION,
|
||||
{ input }
|
||||
{ input },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.createLabel })
|
||||
|
||||
return result.createLabel
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to create label')
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to create label')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
|
|
@ -681,17 +616,19 @@ export function useUpdateLabel() {
|
|||
try {
|
||||
const result = await graphqlRequest<{ updateLabel: Label }>(
|
||||
UPDATE_LABEL_MUTATION,
|
||||
{ id, input }
|
||||
{ id, input },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.updateLabel })
|
||||
|
||||
return result.updateLabel
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to update label')
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to update label')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, updateLabel }
|
||||
|
|
@ -709,12 +646,14 @@ export function useDeleteLabel() {
|
|||
try {
|
||||
const result = await graphqlRequest<{ deleteLabel: DeleteResult }>(
|
||||
DELETE_LABEL_MUTATION,
|
||||
{ id }
|
||||
{ id },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.deleteLabel })
|
||||
|
||||
return result.deleteLabel
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to delete label')
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to delete label')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
|
|
@ -742,15 +681,18 @@ export function useSetLibraryItemLabels() {
|
|||
error: null,
|
||||
data: result.setLibraryItemLabels,
|
||||
})
|
||||
|
||||
return result.setLibraryItemLabels
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to set item labels')
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to set item labels')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, setLibraryItemLabels }
|
||||
|
|
@ -776,12 +718,16 @@ export function useLibraryItem(id: string) {
|
|||
try {
|
||||
const result = await graphqlRequest<{ libraryItem: LibraryItem | null }>(
|
||||
GET_LIBRARY_ITEM_QUERY,
|
||||
{ id }
|
||||
{ id },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.libraryItem })
|
||||
|
||||
return result.libraryItem
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to fetch library item')
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to fetch library item')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
|
|
@ -790,10 +736,56 @@ export function useLibraryItem(id: string) {
|
|||
return { ...state, fetchLibraryItem }
|
||||
}
|
||||
|
||||
// Batched hook for reader page - fetches item + highlights in one request
|
||||
export function useReaderPageData(id: string) {
|
||||
const [state, setState] = useState<{
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
item: LibraryItem | null
|
||||
highlights: Highlight[] | null
|
||||
}>({
|
||||
loading: false,
|
||||
error: null,
|
||||
item: null,
|
||||
highlights: null,
|
||||
})
|
||||
|
||||
const fetchReaderPageData = useCallback(async () => {
|
||||
if (!id) return
|
||||
|
||||
setState({ loading: true, error: null, item: null, highlights: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{
|
||||
libraryItem: LibraryItem | null
|
||||
highlights: Highlight[]
|
||||
}>(GET_READER_PAGE_DATA_QUERY, { id })
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
item: result.libraryItem,
|
||||
highlights: result.highlights || [],
|
||||
})
|
||||
|
||||
return { item: result.libraryItem, highlights: result.highlights }
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to fetch reader page data')
|
||||
setState({ loading: false, error: err, item: null, highlights: null })
|
||||
throw err
|
||||
}
|
||||
}, [id])
|
||||
|
||||
return { ...state, fetchReaderPageData }
|
||||
}
|
||||
|
||||
export interface UpdateLibraryItemInput {
|
||||
title?: string
|
||||
author?: string
|
||||
description?: string
|
||||
readAt?: string | null
|
||||
}
|
||||
|
||||
export function useUpdateLibraryItem() {
|
||||
|
|
@ -809,19 +801,408 @@ export function useUpdateLibraryItem() {
|
|||
try {
|
||||
const result = await graphqlRequest<{ updateLibraryItem: any }>(
|
||||
UPDATE_LIBRARY_ITEM_MUTATION,
|
||||
{ id, input }
|
||||
{ id, input },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.updateLibraryItem })
|
||||
setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: result.updateLibraryItem,
|
||||
})
|
||||
|
||||
return result.updateLibraryItem
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to update library item')
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to update library item')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, updateLibraryItem }
|
||||
}
|
||||
|
||||
// ==================== HIGHLIGHT TYPES ====================
|
||||
|
||||
export interface Highlight {
|
||||
id: string
|
||||
shortId: string
|
||||
libraryItemId: string
|
||||
quote: string | null
|
||||
prefix: string | null
|
||||
suffix: string | null
|
||||
patch: string | null
|
||||
annotation: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
sharedAt: string | null
|
||||
highlightPositionPercent: number
|
||||
highlightPositionAnchorIndex: number
|
||||
highlightType: 'HIGHLIGHT' | 'REDACTION' | 'NOTE'
|
||||
html: string | null
|
||||
color: HighlightColor
|
||||
representation: 'CONTENT' | 'FEED_CONTENT'
|
||||
selectors: Record<string, any> | null // AnchoredSelectors object (GraphQLJSON scalar)
|
||||
contentVersion?: string | null
|
||||
}
|
||||
|
||||
export interface CreateHighlightInput {
|
||||
libraryItemId: string
|
||||
quote: string
|
||||
annotation?: string
|
||||
color?: HighlightColor
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
highlightPositionPercent: number
|
||||
highlightPositionAnchorIndex?: number
|
||||
selectors?: Record<string, any> // AnchoredSelectors object (GraphQLJSON scalar)
|
||||
contentVersion?: string
|
||||
}
|
||||
|
||||
export interface UpdateHighlightInput {
|
||||
annotation?: string
|
||||
color?: HighlightColor
|
||||
}
|
||||
|
||||
// ==================== HIGHLIGHT QUERIES ====================
|
||||
|
||||
const GET_HIGHLIGHTS_QUERY = `
|
||||
${HIGHLIGHT_FRAGMENT}
|
||||
query GetHighlights($libraryItemId: String!) {
|
||||
highlights(libraryItemId: $libraryItemId) {
|
||||
...HighlightFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_HIGHLIGHT_QUERY = `
|
||||
${HIGHLIGHT_FRAGMENT}
|
||||
query GetHighlight($id: String!) {
|
||||
highlight(id: $id) {
|
||||
...HighlightFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ==================== HIGHLIGHT MUTATIONS ====================
|
||||
|
||||
const CREATE_HIGHLIGHT_MUTATION = `
|
||||
${HIGHLIGHT_FRAGMENT}
|
||||
mutation CreateHighlight($input: CreateHighlightInput!) {
|
||||
createHighlight(input: $input) {
|
||||
...HighlightFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_HIGHLIGHT_MUTATION = `
|
||||
${HIGHLIGHT_FRAGMENT}
|
||||
mutation UpdateHighlight($id: String!, $input: UpdateHighlightInput!) {
|
||||
updateHighlight(id: $id, input: $input) {
|
||||
...HighlightFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DELETE_HIGHLIGHT_MUTATION = `
|
||||
mutation DeleteHighlight($id: String!) {
|
||||
deleteHighlight(id: $id) {
|
||||
success
|
||||
message
|
||||
itemId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ==================== HIGHLIGHT HOOKS ====================
|
||||
|
||||
export function useHighlights(libraryItemId: string) {
|
||||
const [state, setState] = useState<{
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
data: Highlight[] | null
|
||||
}>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const fetchHighlights = useCallback(async () => {
|
||||
if (!libraryItemId) return
|
||||
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{ highlights: Highlight[] }>(
|
||||
GET_HIGHLIGHTS_QUERY,
|
||||
{ libraryItemId },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.highlights })
|
||||
|
||||
return result.highlights
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to fetch highlights')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
}, [libraryItemId])
|
||||
|
||||
return { ...state, fetchHighlights, refetch: fetchHighlights }
|
||||
}
|
||||
|
||||
export function useCreateHighlight() {
|
||||
const [state, setState] = useState<MutationState<Highlight>>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const createHighlight = useCallback(async (input: CreateHighlightInput) => {
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{ createHighlight: Highlight }>(
|
||||
CREATE_HIGHLIGHT_MUTATION,
|
||||
{ input },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.createHighlight })
|
||||
|
||||
return result.createHighlight
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to create highlight')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { ...state, createHighlight }
|
||||
}
|
||||
|
||||
export function useUpdateHighlight() {
|
||||
const [state, setState] = useState<MutationState<Highlight>>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const updateHighlight = useCallback(
|
||||
async (id: string, input: UpdateHighlightInput) => {
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{ updateHighlight: Highlight }>(
|
||||
UPDATE_HIGHLIGHT_MUTATION,
|
||||
{ id, input },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.updateHighlight })
|
||||
|
||||
return result.updateHighlight
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to update highlight')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, updateHighlight }
|
||||
}
|
||||
|
||||
export function useDeleteHighlight() {
|
||||
const [state, setState] = useState<MutationState<DeleteResult>>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const deleteHighlight = useCallback(async (id: string) => {
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{ deleteHighlight: DeleteResult }>(
|
||||
DELETE_HIGHLIGHT_MUTATION,
|
||||
{ id },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.deleteHighlight })
|
||||
|
||||
return result.deleteHighlight
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to delete highlight')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { ...state, deleteHighlight }
|
||||
}
|
||||
|
||||
// ==================== NOTEBOOK MUTATIONS ====================
|
||||
|
||||
const UPDATE_NOTEBOOK_MUTATION = `
|
||||
mutation UpdateNotebook($id: String!, $input: UpdateNotebookInput!) {
|
||||
updateNotebook(id: $id, input: $input) {
|
||||
id
|
||||
note
|
||||
noteUpdatedAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ==================== NOTEBOOK HOOKS ====================
|
||||
|
||||
export interface UpdateNotebookInput {
|
||||
note: string
|
||||
}
|
||||
|
||||
export function useUpdateNotebook() {
|
||||
const [state, setState] = useState<MutationState<LibraryItem>>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const updateNotebook = useCallback(async (itemId: string, note: string) => {
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{ updateNotebook: LibraryItem }>(
|
||||
UPDATE_NOTEBOOK_MUTATION,
|
||||
{ id: itemId, input: { note } },
|
||||
)
|
||||
setState({ loading: false, error: null, data: result.updateNotebook })
|
||||
|
||||
return result.updateNotebook
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Failed to update notebook')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { ...state, updateNotebook }
|
||||
}
|
||||
|
||||
// ==================== READING PROGRESS TYPES ====================
|
||||
|
||||
export interface ReadingProgress {
|
||||
id: string
|
||||
libraryItemId: string
|
||||
contentVersion: string | null
|
||||
lastSeenSentinel: number
|
||||
highestSeenSentinel: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UpdateReadingProgressInput {
|
||||
libraryItemId: string
|
||||
contentVersion?: string
|
||||
lastSeenSentinel: number
|
||||
highestSeenSentinel: number
|
||||
totalSentinels?: number
|
||||
}
|
||||
|
||||
// ==================== READING PROGRESS QUERIES ====================
|
||||
|
||||
const GET_READING_PROGRESS_QUERY = `
|
||||
${READING_PROGRESS_FRAGMENT}
|
||||
query GetReadingProgress($libraryItemId: String!, $contentVersion: String) {
|
||||
readingProgress(libraryItemId: $libraryItemId, contentVersion: $contentVersion) {
|
||||
...ReadingProgressFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ==================== READING PROGRESS MUTATIONS ====================
|
||||
|
||||
const UPDATE_READING_PROGRESS_MUTATION = `
|
||||
${READING_PROGRESS_FRAGMENT}
|
||||
mutation UpdateReadingProgress($input: UpdateReadingProgressInput!) {
|
||||
updateReadingProgress(input: $input) {
|
||||
...ReadingProgressFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ==================== READING PROGRESS HOOKS ====================
|
||||
|
||||
export function useReadingProgress(
|
||||
libraryItemId: string,
|
||||
contentVersion?: string,
|
||||
) {
|
||||
const [state, setState] = useState<{
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
data: ReadingProgress | null
|
||||
}>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const fetchProgress = useCallback(async () => {
|
||||
if (!libraryItemId) return
|
||||
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{
|
||||
readingProgress: ReadingProgress | null
|
||||
}>(GET_READING_PROGRESS_QUERY, { libraryItemId, contentVersion })
|
||||
setState({ loading: false, error: null, data: result.readingProgress })
|
||||
|
||||
return result.readingProgress
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to fetch reading progress')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
}, [libraryItemId, contentVersion])
|
||||
|
||||
return { ...state, fetchProgress, refetch: fetchProgress }
|
||||
}
|
||||
|
||||
export function useUpdateReadingProgress() {
|
||||
const [state, setState] = useState<MutationState<ReadingProgress>>({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
})
|
||||
|
||||
const updateProgress = useCallback(
|
||||
async (input: UpdateReadingProgressInput) => {
|
||||
setState({ loading: true, error: null, data: null })
|
||||
try {
|
||||
const result = await graphqlRequest<{
|
||||
updateReadingProgress: ReadingProgress
|
||||
}>(UPDATE_READING_PROGRESS_MUTATION, { input })
|
||||
setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
data: result.updateReadingProgress,
|
||||
})
|
||||
|
||||
return result.updateReadingProgress
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to update reading progress')
|
||||
setState({ loading: false, error: err, data: null })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return { ...state, updateProgress }
|
||||
}
|
||||
|
|
|
|||
121
packages/web-vite/src/lib/graphql-fragments.ts
Normal file
121
packages/web-vite/src/lib/graphql-fragments.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* GraphQL Fragments
|
||||
* Reusable field selections to reduce duplication and improve maintainability
|
||||
*/
|
||||
|
||||
export const LABEL_FRAGMENT = `
|
||||
fragment LabelFields on Label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
position
|
||||
internal
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`
|
||||
|
||||
export const LABEL_BASIC_FRAGMENT = `
|
||||
fragment LabelBasicFields on Label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
internal
|
||||
}
|
||||
`
|
||||
|
||||
export const HIGHLIGHT_FRAGMENT = `
|
||||
fragment HighlightFields on Highlight {
|
||||
id
|
||||
shortId
|
||||
libraryItemId
|
||||
quote
|
||||
prefix
|
||||
suffix
|
||||
patch
|
||||
annotation
|
||||
createdAt
|
||||
updatedAt
|
||||
sharedAt
|
||||
highlightPositionPercent
|
||||
highlightPositionAnchorIndex
|
||||
highlightType
|
||||
html
|
||||
color
|
||||
representation
|
||||
selectors
|
||||
contentVersion
|
||||
}
|
||||
`
|
||||
|
||||
export const LIBRARY_ITEM_BASIC_FRAGMENT = `
|
||||
fragment LibraryItemBasicFields on LibraryItem {
|
||||
id
|
||||
title
|
||||
slug
|
||||
originalUrl
|
||||
author
|
||||
description
|
||||
savedAt
|
||||
createdAt
|
||||
updatedAt
|
||||
publishedAt
|
||||
readAt
|
||||
state
|
||||
contentReader
|
||||
folder
|
||||
thumbnail
|
||||
wordCount
|
||||
siteName
|
||||
siteIcon
|
||||
itemType
|
||||
note
|
||||
noteUpdatedAt
|
||||
readingProgressPercent
|
||||
}
|
||||
`
|
||||
|
||||
export const LIBRARY_ITEM_FULL_FRAGMENT = `
|
||||
fragment LibraryItemFullFields on LibraryItem {
|
||||
id
|
||||
title
|
||||
slug
|
||||
originalUrl
|
||||
author
|
||||
description
|
||||
content
|
||||
savedAt
|
||||
createdAt
|
||||
updatedAt
|
||||
publishedAt
|
||||
readAt
|
||||
state
|
||||
contentReader
|
||||
folder
|
||||
thumbnail
|
||||
wordCount
|
||||
siteName
|
||||
siteIcon
|
||||
itemType
|
||||
note
|
||||
noteUpdatedAt
|
||||
labels {
|
||||
...LabelBasicFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const READING_PROGRESS_FRAGMENT = `
|
||||
fragment ReadingProgressFields on ReadingProgress {
|
||||
id
|
||||
libraryItemId
|
||||
contentVersion
|
||||
lastSeenSentinel
|
||||
highestSeenSentinel
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`
|
||||
|
||||
148
packages/web-vite/src/lib/markdown.ts
Normal file
148
packages/web-vite/src/lib/markdown.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Lightweight markdown renderer
|
||||
// Supports basic markdown without external dependencies
|
||||
|
||||
/**
|
||||
* Groups contiguous <li> elements into separate <ul> blocks
|
||||
* Handles whitespace and newlines between items correctly
|
||||
* Uses stateful parsing to avoid greedy regex
|
||||
*/
|
||||
function groupListItems(html: string): string {
|
||||
if (!html.includes('<li>')) {
|
||||
return html
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
let currentListItems: string[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < html.length) {
|
||||
// Find the next <li> tag
|
||||
const liStart = html.indexOf('<li>', i)
|
||||
|
||||
if (liStart === -1) {
|
||||
// No more list items, add remaining content and close any open list
|
||||
if (currentListItems.length > 0) {
|
||||
result.push(`<ul>${currentListItems.join('\n')}</ul>`)
|
||||
currentListItems = []
|
||||
}
|
||||
// Add remaining content
|
||||
if (i < html.length) {
|
||||
const remaining = html.substring(i)
|
||||
if (remaining.trim()) {
|
||||
result.push(remaining)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Add content before this <li> tag
|
||||
const beforeLi = html.substring(i, liStart)
|
||||
if (beforeLi.trim()) {
|
||||
// If we have accumulated list items, close that list first
|
||||
if (currentListItems.length > 0) {
|
||||
result.push(`<ul>${currentListItems.join('\n')}</ul>`)
|
||||
currentListItems = []
|
||||
}
|
||||
result.push(beforeLi)
|
||||
} else if (beforeLi && currentListItems.length === 0) {
|
||||
// Pure whitespace before first item - preserve it if no list is open
|
||||
result.push(beforeLi)
|
||||
}
|
||||
|
||||
// Find the matching </li> tag
|
||||
const liEnd = html.indexOf('</li>', liStart)
|
||||
if (liEnd === -1) {
|
||||
// Malformed HTML - no closing tag, skip this <li>
|
||||
i = liStart + 4
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the list item (including tags)
|
||||
const listItem = html.substring(liStart, liEnd + 5)
|
||||
currentListItems.push(listItem.trim())
|
||||
|
||||
i = liEnd + 5 // Move past </li>
|
||||
}
|
||||
|
||||
// Close any remaining list items
|
||||
if (currentListItems.length > 0) {
|
||||
result.push(`<ul>${currentListItems.join('\n')}</ul>`)
|
||||
}
|
||||
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
if (!markdown) return ''
|
||||
|
||||
let html = markdown
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
// Headers (must come before other rules)
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
|
||||
// Strikethrough
|
||||
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
||||
|
||||
// Code blocks (must come before inline code)
|
||||
html = html.replace(
|
||||
/```(\w*)\n([\s\S]*?)```/g,
|
||||
'<pre><code class="language-$1">$2</code></pre>',
|
||||
)
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
|
||||
// Links
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
)
|
||||
|
||||
// Images
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gim, '<hr />')
|
||||
html = html.replace(/^\*\*\*$/gim, '<hr />')
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^\* (.*$)/gim, '<li>$1</li>')
|
||||
html = html.replace(/^- (.*$)/gim, '<li>$1</li>')
|
||||
|
||||
// Ordered lists
|
||||
html = html.replace(/^\d+\. (.*$)/gim, '<li>$1</li>')
|
||||
|
||||
// Group contiguous <li> elements into separate <ul> blocks
|
||||
html = groupListItems(html)
|
||||
|
||||
// Wrap entire content in <p> if it doesn't already start with a tag
|
||||
// This ensures paragraphs are properly enclosed before splitting
|
||||
if (!html.trim().startsWith('<')) {
|
||||
html = `<p>${html}</p>`
|
||||
}
|
||||
|
||||
// Replace double newlines with closing and opening paragraph tags
|
||||
// This splits content into separate paragraphs while maintaining proper nesting
|
||||
html = html.replace(/\n\n/g, '</p><p>')
|
||||
|
||||
// Replace single newlines with line breaks
|
||||
html = html.replace(/\n/g, '<br />')
|
||||
|
||||
return html
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ export function calculateReadingTime(
|
|||
|
||||
if (minutes < 1) return '< 1 min'
|
||||
if (minutes === 1) return '1 min'
|
||||
|
||||
return `${minutes} min`
|
||||
}
|
||||
|
||||
|
|
@ -42,12 +43,14 @@ export function formatTimestamp(dateString: string): string {
|
|||
const diffInMinutes = Math.floor(diffInMs / (1000 * 60))
|
||||
if (diffInMinutes < 1) return 'Just now'
|
||||
if (diffInMinutes === 1) return '1 minute ago'
|
||||
|
||||
return `${diffInMinutes} minutes ago`
|
||||
}
|
||||
|
||||
// Less than 24 hours
|
||||
if (diffInHours < 24) {
|
||||
if (diffInHours === 1) return '1 hour ago'
|
||||
|
||||
return `${diffInHours} hours ago`
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +58,7 @@ export function formatTimestamp(dateString: string): string {
|
|||
if (diffInHours < 168) {
|
||||
const diffInDays = Math.floor(diffInHours / 24)
|
||||
if (diffInDays === 1) return '1 day ago'
|
||||
|
||||
return `${diffInDays} days ago`
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +80,7 @@ export function getProgressColor(percent: number): string {
|
|||
if (percent < 25) return '#4a9eff'
|
||||
if (percent < 75) return '#ffd234'
|
||||
if (percent < 100) return '#ff9500'
|
||||
|
||||
return '#4caf50' // Completed
|
||||
}
|
||||
|
||||
|
|
@ -92,5 +97,6 @@ export function formatReadingProgress(
|
|||
const percent = topPercent ?? 0
|
||||
if (percent === 0) return ''
|
||||
if (percent >= 100) return 'Completed'
|
||||
|
||||
return `${Math.round(percent)}% read`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const registerSchema = z
|
|||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
message: 'Passwords don\'t match',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
// Import global base styles
|
||||
import './index.css'
|
||||
// Import consolidated CSS bundle - Vite will code-split automatically
|
||||
import './styles/index.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
// Email login page component for Omnivore Vite app
|
||||
// Email/password authentication form
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores'
|
||||
import { loginSchema, type LoginFormData } from '../lib/validation'
|
||||
|
||||
import ErrorBoundary from '../components/ErrorBoundary'
|
||||
import { type LoginFormData, loginSchema } from '../lib/validation'
|
||||
import { useAuthStore } from '../stores'
|
||||
|
||||
const EmailLoginPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useEffect, useState, useRef } from 'react'
|
||||
import {
|
||||
useLabels,
|
||||
useCreateLabel,
|
||||
useUpdateLabel,
|
||||
useDeleteLabel,
|
||||
type Label,
|
||||
type CreateLabelInput,
|
||||
type UpdateLabelInput,
|
||||
} from '../lib/graphql-client'
|
||||
import '../styles/LabelsPage.css'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
type CreateLabelInput,
|
||||
type Label,
|
||||
type UpdateLabelInput,
|
||||
useCreateLabel,
|
||||
useDeleteLabel,
|
||||
useLabels,
|
||||
useUpdateLabel,
|
||||
} from '../lib/graphql-client'
|
||||
|
||||
export function LabelsPage() {
|
||||
const { data: labels, loading, error, fetchLabels } = useLabels()
|
||||
const { createLabel, loading: creating } = useCreateLabel()
|
||||
|
|
@ -46,6 +48,7 @@ export function LabelsPage() {
|
|||
}
|
||||
if (openMenuId) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [openMenuId])
|
||||
|
|
@ -103,6 +106,7 @@ export function LabelsPage() {
|
|||
const handleDeleteLabel = async (label: Label) => {
|
||||
if (label.internal) {
|
||||
showToast('Cannot delete system labels', 'error')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +129,7 @@ export function LabelsPage() {
|
|||
const startEdit = (label: Label) => {
|
||||
if (label.internal) {
|
||||
showToast('Cannot edit system labels', 'error')
|
||||
|
||||
return
|
||||
}
|
||||
setEditingLabel(label)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const LandingPage: React.FC = () => {
|
|||
{
|
||||
titleText: 'Save it now. Read it later.',
|
||||
descriptionText:
|
||||
"Save articles and PDFs as you come across them using Omnivore's mobile apps and browser extensions. Read them later using our distraction free reader.",
|
||||
'Save articles and PDFs as you come across them using Omnivore\'s mobile apps and browser extensions. Read them later using our distraction free reader.',
|
||||
image: '/static/images/landing/landing-01-save-it-now.png',
|
||||
imagePosition: 'right' as const,
|
||||
},
|
||||
|
|
@ -23,7 +23,7 @@ const LandingPage: React.FC = () => {
|
|||
{
|
||||
titleText: 'Keep your reading organized, whatever that means to you.',
|
||||
descriptionText:
|
||||
"Keep your reading organized and easily available with labels, filters, rules, and full text searches. We're not here to tell you how to stay organized — our job is to give you the tools to build a system that works for you.",
|
||||
'Keep your reading organized and easily available with labels, filters, rules, and full text searches. We\'re not here to tell you how to stay organized — our job is to give you the tools to build a system that works for you.',
|
||||
image: '/static/images/landing/landing-03-organisation.png',
|
||||
imagePosition: 'right' as const,
|
||||
},
|
||||
|
|
@ -44,14 +44,14 @@ const LandingPage: React.FC = () => {
|
|||
{
|
||||
titleText: 'Listen to your reading with text-to-speech.',
|
||||
descriptionText:
|
||||
"Work through your to-be-read list and give your eyes a break with text-to-speech, exclusively in the Omnivore app for iOS. Realistic, natural-sounding AI voices will read any saved article aloud.",
|
||||
'Work through your to-be-read list and give your eyes a break with text-to-speech, exclusively in the Omnivore app for iOS. Realistic, natural-sounding AI voices will read any saved article aloud.',
|
||||
image: '/static/images/landing/landing-06-tts.png',
|
||||
imagePosition: 'left' as const,
|
||||
},
|
||||
{
|
||||
titleText: "Open source means you're in control.",
|
||||
titleText: 'Open source means you\'re in control.',
|
||||
descriptionText:
|
||||
"Reading is a lifetime activity, and you shouldn't have to worry you'll lose your library after you've spent years building it. Our open-source platform ensures your reading won't be held prisoner in a proprietary system.",
|
||||
'Reading is a lifetime activity, and you shouldn\'t have to worry you\'ll lose your library after you\'ve spent years building it. Our open-source platform ensures your reading won\'t be held prisoner in a proprietary system.',
|
||||
image: '/static/images/landing/landing-07-oss.png',
|
||||
imagePosition: 'right' as const,
|
||||
},
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue