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:
Timothy Atapagra 2025-11-21 18:05:55 -05:00 committed by GitHub
parent c4a4b7c4fb
commit 48fd77c46b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 8781 additions and 1642 deletions

118
.eslintrc.base.json Normal file
View 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
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}

5
.husky/pre-commit Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

15
.lintstagedrc.json Normal file
View 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
View 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

View file

@ -1,4 +1,6 @@
{
"semi": false,
"singleQuote": true
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "auto"
}

16
commitlint.config.js Normal file
View 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'],
],
},
}

View file

@ -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"
}
}

View file

@ -0,0 +1,8 @@
{
"extends": ["../../.eslintrc.base.json"],
"rules": {
"semi": [2, "never"],
"@typescript-eslint/no-unnecessary-type-assertion": [0, "never"]
}
}

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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],

View file

@ -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
}

View 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,
)
}
}

View file

@ -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 {}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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)

View file

@ -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,
) {}

View file

@ -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
}

View file

@ -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()

View file

@ -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: '' })

View file

@ -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,

View file

@ -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()}`
}
}
}

View file

@ -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',

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View 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

View file

@ -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[]>>
}

View file

@ -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[]>>
}

View file

@ -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

View file

@ -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>>
}

View file

@ -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 })

View file

@ -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
}
}

View file

@ -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
],
})

View file

@ -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!')
})

View file

@ -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({

View file

@ -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,
})

View file

@ -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)
})
})
})

View file

@ -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
}

View file

@ -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)
})

View file

@ -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',
)
})
})

View file

@ -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')
})
})
})

View 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)
})
})
})
})

View file

@ -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'

View 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
}

View file

@ -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',

View 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;

View 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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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;

View 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;

View file

@ -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"
}
}

View file

@ -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"
]
}

View file

@ -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,
},
},
])
]

View file

@ -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>

View file

@ -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",

View file

@ -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">

View file

@ -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', () => {

View file

@ -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
}
}

View file

@ -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 = () => {

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View 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

View file

@ -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
})
}

View file

@ -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
})

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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])

View 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

View file

@ -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

View 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,
},
}
}

View file

@ -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' }
}
}

View 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)
}

View file

@ -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 }
}

View 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
}
`

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// 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(/^&gt; (.*$)/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
}

View file

@ -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`
}

View file

@ -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'],
})

View file

@ -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(

View file

@ -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()

View file

@ -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)

View file

@ -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