diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad124c1a6..a92e49e44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: devDependencies: '@commitlint/cli': specifier: 19.6.0 - version: 19.6.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241127) + version: 19.6.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241202) '@commitlint/config-conventional': specifier: ^19.5.0 version: 19.6.0 @@ -45,7 +45,7 @@ importers: version: link:packages/eslint-config syncpack: specifier: ^13.0.0 - version: 13.0.0(typescript@5.8.0-dev.20241127) + version: 13.0.0(typescript@5.8.0-dev.20241202) tsconfig: specifier: workspace:* version: link:packages/tsconfig @@ -2493,7 +2493,7 @@ importers: version: 1.9.0 '@opentelemetry/auto-instrumentations-node': specifier: 0.53.0 - version: 0.53.0(@opentelemetry/api@1.9.0) + version: 0.53.0(@opentelemetry/api@1.9.0)(encoding@0.1.13) '@opentelemetry/context-async-hooks': specifier: 1.28.0 version: 1.28.0(@opentelemetry/api@1.9.0) @@ -3510,6 +3510,12 @@ importers: prosemirror-model: specifier: 1.23.0 version: 1.23.0 + prosemirror-state: + specifier: 1.4.3 + version: 1.4.3 + prosemirror-transform: + specifier: 1.10.2 + version: 1.10.2 tslib: specifier: 2.8.0 version: 2.8.0 @@ -4009,7 +4015,7 @@ importers: version: link:../../packages/eslint-config '@snowplow/snowtype': specifier: ^0.10.1 - version: 0.10.1(commander@12.1.0)(encoding@0.1.13) + version: 0.10.1(commander@12.1.0) '@types/jest': specifier: 29.5.14 version: 29.5.14 @@ -12919,6 +12925,15 @@ packages: prosemirror-model@1.23.0: resolution: {integrity: sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==} + prosemirror-state@1.4.3: + resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} + + prosemirror-transform@1.10.2: + resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==} + + prosemirror-view@1.37.0: + resolution: {integrity: sha512-z2nkKI1sJzyi7T47Ji/ewBPuIma1RNvQCCYVdV+MqWBV7o4Sa1n94UJCJJ1aQRF/xRkFfyqLGlGFWitIcCOtbg==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -14182,8 +14197,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.8.0-dev.20241127: - resolution: {integrity: sha512-TGb4/mmMCY5GOBuEry4xaOcDonyPwnxPc2zaR/8jgA6twK+iGEPsB6EeT+lSSrpOP906WPlFAn3XVQfQm4d7TQ==} + typescript@5.8.0-dev.20241202: + resolution: {integrity: sha512-4b4+FbKk5Wp51uwCztSyh68rdK1rv4afoC/P/g8HOmWmfHIdCgZNxzLS6TvjBU7e8XVzmW3jE/CJl1n59lcIhg==} engines: {node: '>=14.17'} hasBin: true @@ -16488,11 +16503,11 @@ snapshots: dependencies: commander: 12.1.0 - '@commitlint/cli@19.6.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241127)': + '@commitlint/cli@19.6.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241202)': dependencies: '@commitlint/format': 19.5.0 '@commitlint/lint': 19.6.0 - '@commitlint/load': 19.5.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241127) + '@commitlint/load': 19.5.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241202) '@commitlint/read': 19.5.0 '@commitlint/types': 19.5.0 tinyexec: 0.3.1 @@ -16539,15 +16554,15 @@ snapshots: '@commitlint/rules': 19.6.0 '@commitlint/types': 19.5.0 - '@commitlint/load@19.5.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241127)': + '@commitlint/load@19.5.0(@types/node@22.10.0)(typescript@5.8.0-dev.20241202)': dependencies: '@commitlint/config-validator': 19.5.0 '@commitlint/execute-rule': 19.5.0 '@commitlint/resolve-extends': 19.5.0 '@commitlint/types': 19.5.0 chalk: 5.3.0 - cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241127) - cosmiconfig-typescript-loader: 5.1.0(@types/node@22.10.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241127))(typescript@5.8.0-dev.20241127) + cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241202) + cosmiconfig-typescript-loader: 5.1.0(@types/node@22.10.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241202))(typescript@5.8.0-dev.20241202) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -17993,7 +18008,7 @@ snapshots: '@opentelemetry/api@1.9.0': {} - '@opentelemetry/auto-instrumentations-node@0.53.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/auto-instrumentations-node@0.53.0(@opentelemetry/api@1.9.0)(encoding@0.1.13)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.55.0(@opentelemetry/api@1.9.0) @@ -18040,7 +18055,7 @@ snapshots: '@opentelemetry/resource-detector-aws': 1.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-azure': 0.3.0(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-container': 0.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-gcp': 0.30.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-gcp': 0.30.0(@opentelemetry/api@1.9.0)(encoding@0.1.13) '@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': 0.55.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: @@ -18836,7 +18851,7 @@ snapshots: '@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.28.0 - '@opentelemetry/resource-detector-gcp@0.30.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resource-detector-gcp@0.30.0(@opentelemetry/api@1.9.0)(encoding@0.1.13)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.0) @@ -19930,20 +19945,20 @@ snapshots: got: 11.8.6 tslib: 2.8.0 - '@snowplow/snowtype-core@0.10.1(encoding@0.1.13)': + '@snowplow/snowtype-core@0.10.1': dependencies: '@fastify/merge-json-schemas': 0.2.0 handlebars: 4.7.8 json-pointer: 0.6.2 - quicktype-core: 23.0.170(encoding@0.1.13) + quicktype-core: 23.0.170 transitivePeerDependencies: - encoding - '@snowplow/snowtype@0.10.1(commander@12.1.0)(encoding@0.1.13)': + '@snowplow/snowtype@0.10.1(commander@12.1.0)': dependencies: '@commander-js/extra-typings': 11.1.0(commander@12.1.0) '@inquirer/prompts': 3.3.2 - '@snowplow/snowtype-core': 0.10.1(encoding@0.1.13) + '@snowplow/snowtype-core': 0.10.1 chalk: 4.1.2 cli-spinner: 0.2.10 dotenv: 16.4.5 @@ -21647,12 +21662,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig-typescript-loader@5.1.0(@types/node@22.10.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241127))(typescript@5.8.0-dev.20241127): + cosmiconfig-typescript-loader@5.1.0(@types/node@22.10.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241202))(typescript@5.8.0-dev.20241202): dependencies: '@types/node': 22.10.0 - cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241127) + cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241202) jiti: 1.21.6 - typescript: 5.8.0-dev.20241127 + typescript: 5.8.0-dev.20241202 cosmiconfig@8.3.6(typescript@5.5.4): dependencies: @@ -21672,14 +21687,14 @@ snapshots: optionalDependencies: typescript: 5.5.4 - cosmiconfig@9.0.0(typescript@5.8.0-dev.20241127): + cosmiconfig@9.0.0(typescript@5.8.0-dev.20241202): dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 js-yaml: 4.1.0 parse-json: 5.2.0 optionalDependencies: - typescript: 5.8.0-dev.20241127 + typescript: 5.8.0-dev.20241202 cpu-features@0.0.2: dependencies: @@ -22009,7 +22024,7 @@ snapshots: dependencies: semver: 7.6.3 shelljs: 0.8.5 - typescript: 5.8.0-dev.20241127 + typescript: 5.8.0-dev.20241202 dreamopt@0.8.0: dependencies: @@ -25792,6 +25807,22 @@ snapshots: dependencies: orderedmap: 2.1.1 + prosemirror-state@1.4.3: + dependencies: + prosemirror-model: 1.23.0 + prosemirror-transform: 1.10.2 + prosemirror-view: 1.37.0 + + prosemirror-transform@1.10.2: + dependencies: + prosemirror-model: 1.23.0 + + prosemirror-view@1.37.0: + dependencies: + prosemirror-model: 1.23.0 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.2 + proto-list@1.2.4: {} proto3-json-serializer@2.0.1: @@ -25872,7 +25903,7 @@ snapshots: quick-lru@5.1.1: {} - quicktype-core@23.0.170(encoding@0.1.13): + quicktype-core@23.0.170: dependencies: '@glideapps/ts-necessities': 2.2.3 browser-or-node: 3.0.0 @@ -26833,13 +26864,13 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.8.0 - syncpack@13.0.0(typescript@5.8.0-dev.20241127): + syncpack@13.0.0(typescript@5.8.0-dev.20241202): dependencies: '@effect/schema': 0.71.1(effect@3.6.5) chalk: 5.3.0 chalk-template: 1.1.0 commander: 12.1.0 - cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241127) + cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241202) effect: 3.6.5 enquirer: 2.4.1 fast-check: 3.21.0 @@ -27219,7 +27250,7 @@ snapshots: typescript@5.5.4: {} - typescript@5.8.0-dev.20241127: {} + typescript@5.8.0-dev.20241202: {} ua-parser-js@1.0.39: {} diff --git a/servers/notes-api/package.json b/servers/notes-api/package.json index 0b2b55d55..de44c65ea 100644 --- a/servers/notes-api/package.json +++ b/servers/notes-api/package.json @@ -57,6 +57,8 @@ "prisma": "5.21.1", "prosemirror-markdown": "1.13.1", "prosemirror-model": "1.23.0", + "prosemirror-state": "1.4.3", + "prosemirror-transform": "1.10.2", "tslib": "2.8.0", "uuid": "^10.0.0" }, diff --git a/servers/notes-api/schema.graphql b/servers/notes-api/schema.graphql index 9155871b8..e8ae5e95c 100644 --- a/servers/notes-api/schema.graphql +++ b/servers/notes-api/schema.graphql @@ -97,10 +97,48 @@ input CreateNoteInput { createdAt: ISOString } +""" +Input to create a new Note seeded with copied content from a page. +The entire content becomes editable and is not able to be "reattached" +like a traditional highlight. +""" +input CreateNoteFromQuoteInput { + """Optional title for this Note""" + title: String + """ + Client-provided UUID for the new Note. + If not provided, will be generated on the server. + """ + id: ID + """ + The Web Resource where the quote is taken from. + This should always be sent by the client where possible, + but in some cases (e.g. copying from mobile apps) there may + not be an accessible source url. + """ + source: ValidUrl + """ + JSON representation of a ProseMirror document, which + contains the formatted snipped text. This is used to seed + the initial Note document state, and will become editable. + """ + quote: ProseMirrorJson! + """ + When this note was created. If not provided, defaults to server time upon + receiving request. + """ + createdAt: ISOString +} + type Mutation { """ Create a new note, optionally with title and content """ createNote(input: CreateNoteInput!): Note! @requiresScopes(scopes: [["ROLE_USER"]]) + """ + Create a new note, with a pre-populated block that contains the quoted and cited text + selected by a user. + """ + createNoteFromQuote(input: CreateNoteFromQuoteInput!): Note! @requiresScopes(scopes: [["ROLE_USER"]]) } diff --git a/servers/notes-api/src/__generated__/graphql.d.ts b/servers/notes-api/src/__generated__/graphql.d.ts index d3a06271b..72a1a710f 100644 --- a/servers/notes-api/src/__generated__/graphql.d.ts +++ b/servers/notes-api/src/__generated__/graphql.d.ts @@ -20,10 +20,44 @@ export type Scalars = { Float: { input: number; output: number; } ISOString: { input: Date | string; output: Date | string; } Markdown: { input: string; output: string; } + ProseMirrorJson: { input: string; output: string; } ValidUrl: { input: URL | string; output: URL | string; } _FieldSet: { input: any; output: any; } }; +/** + * Input to create a new Note seeded with copied content from a page. + * The entire content becomes editable and is not able to be "reattached" + * like a traditional highlight. + */ +export type CreateNoteFromQuoteInput = { + /** + * When this note was created. If not provided, defaults to server time upon + * receiving request. + */ + createdAt?: InputMaybe; + /** + * Client-provided UUID for the new Note. + * If not provided, will be generated on the server. + */ + id?: InputMaybe; + /** + * JSON representation of a ProseMirror document, which + * contains the formatted snipped text. This is used to seed + * the initial Note document state, and will become editable. + */ + quote: Scalars['String']['input']; + /** + * The Web Resource where the quote is taken from. + * This should always be sent by the client where possible, + * but in some cases (e.g. copying from mobile apps) there may + * not be an accessible source url. + */ + source?: InputMaybe; + /** Optional title for this Note */ + title?: InputMaybe; +}; + /** Input to create a new Note */ export type CreateNoteInput = { /** @@ -32,7 +66,7 @@ export type CreateNoteInput = { */ createdAt?: InputMaybe; /** JSON representation of a ProseMirror document */ - docContent: Scalars['String']['input']; + docContent: Scalars['ProseMirrorJson']['input']; /** * Client-provided UUID for the new Note. * If not provided, will be generated on the server. @@ -48,6 +82,11 @@ export type Mutation = { __typename?: 'Mutation'; /** Create a new note, optionally with title and content */ createNote: Note; + /** + * Create a new note, with a pre-populated block that contains the quoted and cited text + * selected by a user. + */ + createNoteFromQuote: Note; }; @@ -55,6 +94,11 @@ export type MutationCreateNoteArgs = { input: CreateNoteInput; }; + +export type MutationCreateNoteFromQuoteArgs = { + input: CreateNoteFromQuoteInput; +}; + /** * A Note is an entity which may contain extracted components * from websites (clippings/snippets), user-generated rich text content, @@ -75,7 +119,7 @@ export type Note = { */ deleted: Scalars['Boolean']['output']; /** JSON representation of a ProseMirror document */ - docContent?: Maybe; + docContent?: Maybe; /** This Note's identifier */ id: Scalars['ID']['output']; /** @@ -194,14 +238,16 @@ export type DirectiveResolverFn; + CreateNoteFromQuoteInput: CreateNoteFromQuoteInput; ID: ResolverTypeWrapper; + String: ResolverTypeWrapper; + CreateNoteInput: CreateNoteInput; ISOString: ResolverTypeWrapper; Markdown: ResolverTypeWrapper; Mutation: ResolverTypeWrapper<{}>; Note: ResolverTypeWrapper; Boolean: ResolverTypeWrapper; + ProseMirrorJson: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; SavedItem: ResolverTypeWrapper; ValidUrl: ResolverTypeWrapper; @@ -209,14 +255,16 @@ export type ResolversTypes = ResolversObject<{ /** Mapping between all available schema types and the resolvers parents */ export type ResolversParentTypes = ResolversObject<{ - CreateNoteInput: CreateNoteInput; - String: Scalars['String']['output']; + CreateNoteFromQuoteInput: CreateNoteFromQuoteInput; ID: Scalars['ID']['output']; + String: Scalars['String']['output']; + CreateNoteInput: CreateNoteInput; ISOString: Scalars['ISOString']['output']; Markdown: Scalars['Markdown']['output']; Mutation: {}; Note: Note; Boolean: Scalars['Boolean']['output']; + ProseMirrorJson: Scalars['ProseMirrorJson']['output']; Query: {}; SavedItem: SavedItem; ValidUrl: Scalars['ValidUrl']['output']; @@ -232,6 +280,7 @@ export interface MarkdownScalarConfig extends GraphQLScalarTypeConfig = ResolversObject<{ createNote?: Resolver>; + createNoteFromQuote?: Resolver>; }>; export type NoteResolvers = ResolversObject<{ @@ -240,7 +289,7 @@ export type NoteResolvers, ParentType, ContextType>; createdAt?: Resolver; deleted?: Resolver; - docContent?: Resolver, ParentType, ContextType>; + docContent?: Resolver, ParentType, ContextType>; id?: Resolver; savedItem?: Resolver, ParentType, ContextType>; source?: Resolver, ParentType, ContextType>; @@ -249,6 +298,10 @@ export type NoteResolvers; }>; +export interface ProseMirrorJsonScalarConfig extends GraphQLScalarTypeConfig { + name: 'ProseMirrorJson'; +} + export type QueryResolvers = ResolversObject<{ note?: Resolver, ParentType, ContextType, RequireFields>; }>; @@ -268,6 +321,7 @@ export type Resolvers = ResolversObject<{ Markdown?: GraphQLScalarType; Mutation?: MutationResolvers; Note?: NoteResolvers; + ProseMirrorJson?: GraphQLScalarType; Query?: QueryResolvers; SavedItem?: SavedItemResolvers; ValidUrl?: GraphQLScalarType; diff --git a/servers/notes-api/src/apollo/resolvers.ts b/servers/notes-api/src/apollo/resolvers.ts index 9fb137886..c654d307e 100644 --- a/servers/notes-api/src/apollo/resolvers.ts +++ b/servers/notes-api/src/apollo/resolvers.ts @@ -12,5 +12,8 @@ export const resolvers: Resolvers = { createNote(root, { input }, context) { return context.NoteModel.create(input); }, + createNoteFromQuote(root, { input }, context) { + return context.NoteModel.fromQuote(input); + }, }, }; diff --git a/servers/notes-api/src/models/Note.ts b/servers/notes-api/src/models/Note.ts index d9ab9991a..15da5df0a 100644 --- a/servers/notes-api/src/models/Note.ts +++ b/servers/notes-api/src/models/Note.ts @@ -1,11 +1,15 @@ import DataLoader from 'dataloader'; -import { Note, CreateNoteInput } from '../__generated__/graphql'; +import { + Note, + CreateNoteInput, + CreateNoteFromQuoteInput, +} from '../__generated__/graphql'; import { Note as NoteEntity } from '../__generated__/db'; import { Insertable, Selectable } from 'kysely'; import { orderAndMap } from '../utils/dataloader'; import { IContext } from '../apollo/context'; import { NotesService } from '../datasources/NoteService'; -import { ProseMirrorDoc } from './ProseMirrorDoc'; +import { ProseMirrorDoc, wrapDocInBlockQuote } from './ProseMirrorDoc'; import { UserInputError } from '@pocket-tools/apollo-utils'; import { DatabaseError } from 'pg'; @@ -117,4 +121,32 @@ export class NoteModel { } } } + /** + * Create a new Note seeded with a blockquote (optionally with + * an additional paragraph with the source link). + */ + async fromQuote(input: CreateNoteFromQuoteInput) { + try { + const docContent = JSON.parse(input.quote); + const options = + input.source != null ? { source: input.source.toString() } : undefined; + const quotedDoc = wrapDocInBlockQuote(docContent, options); + const createInput: CreateNoteInput = { + docContent: JSON.stringify(quotedDoc), + createdAt: input.createdAt, + id: input.id, + title: input.title, + source: input.source, + }; + return this.create(createInput); + } catch (error) { + if (error instanceof SyntaxError) { + throw new UserInputError( + `Received malformed JSON for docContent: ${error.message}`, + ); + } else { + throw error; + } + } + } } diff --git a/servers/notes-api/src/models/ProseMirrorDoc.spec.ts b/servers/notes-api/src/models/ProseMirrorDoc.spec.ts index 8057ebc62..db3aa025b 100644 --- a/servers/notes-api/src/models/ProseMirrorDoc.spec.ts +++ b/servers/notes-api/src/models/ProseMirrorDoc.spec.ts @@ -1,11 +1,47 @@ import basicText from '../test/documents/basicText.json'; -import { ProseMirrorDoc } from './ProseMirrorDoc'; +import { ProseMirrorDoc, wrapDocInBlockQuote } from './ProseMirrorDoc'; import { schema } from 'prosemirror-markdown'; +import fromQuote from '../test/documents/fromQuote.json'; +import { UserInputError } from '@pocket-tools/apollo-utils'; describe('ProseMirrorDoc', () => { // TODO - Improve specificity when preview format is decided - it('converts a multi-paragraph input to a string', () => { - const doc = new ProseMirrorDoc(basicText, schema); - expect(doc.preview).toBeString(); + describe('preview', () => { + it('converts a multi-paragraph input to a string', () => { + const doc = new ProseMirrorDoc(basicText, schema); + expect(doc.preview).toBeString(); + }); + }); + describe('quote constructor', () => { + it('wraps quote in blockquote and adds attribution', () => { + const { input, expectedSource } = fromQuote; + const actual = wrapDocInBlockQuote(input, { source: 'localhost:3001' }); + expect(actual).toEqual(expectedSource); + }); + it('wraps quote in blockquote without attribution', () => { + const { input, expectedNoSource } = fromQuote; + const actual = wrapDocInBlockQuote(input); + expect(actual).toEqual(expectedNoSource); + }); + it('throws error if an invalid node is encountered', () => { + const bad = { + type: 'doc', + content: [ + { + type: 'invalid', + content: [ + { + type: 'text', + text: '', + }, + ], + }, + ], + }; + expect(() => wrapDocInBlockQuote(bad)).toThrowWithMessage( + UserInputError, + /.*Invalid Document.*/, + ); + }); }); }); diff --git a/servers/notes-api/src/models/ProseMirrorDoc.ts b/servers/notes-api/src/models/ProseMirrorDoc.ts index 3a9b2dd98..7308054c7 100644 --- a/servers/notes-api/src/models/ProseMirrorDoc.ts +++ b/servers/notes-api/src/models/ProseMirrorDoc.ts @@ -1,8 +1,13 @@ -import { Node, type Schema } from 'prosemirror-model'; +import { Node, Schema } from 'prosemirror-model'; +import { EditorState, AllSelection } from 'prosemirror-state'; +import { findWrapping } from 'prosemirror-transform'; import { defaultMarkdownSerializer, schema as commonMarkSchema, } from 'prosemirror-markdown'; +import { UserInputError } from '@pocket-tools/apollo-utils'; +import { serverLogger } from '@pocket-tools/ts-logger'; +import * as Sentry from '@sentry/node'; /** * Class for handling ProseMirror documents @@ -24,3 +29,77 @@ export class ProseMirrorDoc { return defaultMarkdownSerializer.serialize(this.document); } } + +/** + * Wrap a JSON representation of a ProseMirror document in a blockquote, + * optionally with an additional paragraph that references the source + * link from where the document was copied. + * Returns a JSON-serializable representation of the new formatted document. + */ +export function wrapDocInBlockQuote< + S extends Schema<'blockquote' | 'paragraph' | 'text', 'link'>, +>(quoteDoc: any, options?: { source?: string; schema?: S }): any { + const opts = options ?? {}; + Sentry.addBreadcrumb({ + message: 'wrapDocInBlockQuote: input document', + type: 'log', + timestamp: Date.now(), + data: { source: opts.source, doc: quoteDoc }, + }); + const schema = opts.schema ?? commonMarkSchema; + const source = opts.source; + let initialState: EditorState; + try { + initialState = EditorState.create({ doc: Node.fromJSON(schema, quoteDoc) }); + } catch (error) { + if (error instanceof RangeError) { + serverLogger.warn({ + message: 'Attempted to parse document with unknown node type', + errorData: error, + document: quoteDoc, + }); + throw new UserInputError(`Invalid Document: ${error.message}`); + } else { + throw error; + } + } + // Kind of a silly closure, but helps keep this more organized + // without having to pass along everything to a new function for + // error reporting/logging + const wrapDoc = (state: EditorState) => { + // Logic for wrapping in blockquote + const docSelect = new AllSelection(state.doc); + const range = docSelect.$from.blockRange(docSelect.$to); + if (range == null) { + const message = `Could not generate range from document`; + serverLogger.error({ message, document: quoteDoc, source }); + throw new UserInputError(`${message} -- is the document malformed?`); + } else { + const wrapping = findWrapping(range, schema.nodes.blockquote, {}); + if (wrapping == null) { + const message = `Could not wrap document selection`; + serverLogger.error({ message, document: quoteDoc, source }); + throw new UserInputError(`${message} -- is the document malformed?`); + } else { + const transaction = state.tr.wrap(range, wrapping); + const trxResult = state.applyTransaction(transaction); + return trxResult.state; + } + } + }; + // Wrap document in blockquote + const state = wrapDoc(initialState); + // Insert paragraph with source link if provided + if (source != null) { + const node = schema.node('paragraph', {}, [ + schema.text('Source: '), + schema.text(source, [schema.mark('link', { href: source })]), + ]); + const transaction = state.tr.insert(state.tr.doc.content.size, node); + const trxResult = state.applyTransaction(transaction); + return trxResult.state.doc.toJSON(); + } else { + // Just return the blockquoted-doc + return state.doc.toJSON(); + } +} diff --git a/servers/notes-api/src/test/documents/fromQuote.json b/servers/notes-api/src/test/documents/fromQuote.json new file mode 100644 index 000000000..ee70f8ab5 --- /dev/null +++ b/servers/notes-api/src/test/documents/fromQuote.json @@ -0,0 +1,204 @@ +{ + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This is something I’m copying from somewhere else" + } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "I want to make everything a blockquote" } + ] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Here’s a list:" }] + }, + { + "type": "bullet_list", + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "abc" }] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "123" }] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "def" }] + } + ] + } + ] + } + ] + }, + "expectedSource": { + "type": "doc", + "content": [ + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This is something I’m copying from somewhere else" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "I want to make everything a blockquote" + } + ] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Here’s a list:" }] + }, + { + "type": "bullet_list", + "attrs": { "tight": false }, + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "abc" }] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "123" }] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "def" }] + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Source: " }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { "href": "localhost:3001", "title": null } + } + ], + "text": "localhost:3001" + } + ] + } + ] + }, + "expectedNoSource": { + "type": "doc", + "content": [ + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This is something I’m copying from somewhere else" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "I want to make everything a blockquote" + } + ] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Here’s a list:" }] + }, + { + "type": "bullet_list", + "attrs": { "tight": false }, + "content": [ + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "abc" }] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "123" }] + } + ] + }, + { + "type": "list_item", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "def" }] + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/servers/notes-api/src/test/mutations/createNoteFromQuote.integration.ts b/servers/notes-api/src/test/mutations/createNoteFromQuote.integration.ts new file mode 100644 index 000000000..62a4defc7 --- /dev/null +++ b/servers/notes-api/src/test/mutations/createNoteFromQuote.integration.ts @@ -0,0 +1,157 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { CREATE_NOTE_QUOTE } from '../operations'; +import { db } from '../../datasources/db'; +import { sql } from 'kysely'; +import { CreateNoteFromQuoteInput } from '../../__generated__/graphql'; +import fromQuote from '../documents/fromQuote.json'; +import { Chance } from 'chance'; + +let app: Application; +let server: ApolloServer; +let graphQLUrl: string; + +beforeAll(async () => { + // port 0 tells express to dynamically assign an available port + ({ app, server, url: graphQLUrl } = await startServer(0)); +}); +afterAll(async () => { + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + await server.stop(); + await db.destroy(); +}); + +describe('note', () => { + it('creates a note with minimal inputs', async () => { + const input: CreateNoteFromQuoteInput = { + quote: JSON.stringify(fromQuote.input), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_QUOTE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNoteFromQuote).toMatchObject({ + archived: false, + contentPreview: expect.toBeString(), + createdAt: expect.toBeDateString(), + deleted: false, + id: expect.toBeString(), + savedItem: null, + source: null, + title: null, + updatedAt: expect.toBeDateString(), + }); + // The keys may get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.createNoteFromQuote?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(fromQuote.expectedNoSource); + }); + it('creates a note with optional fields', async () => { + const chance = new Chance(); + const createdAt = new Date(chance.hammertime()); + const input: CreateNoteFromQuoteInput = { + title: chance.sentence(), + createdAt, + source: 'localhost:3001', + id: chance.guid({ version: 4 }), + quote: JSON.stringify(fromQuote.input), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_QUOTE, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNoteFromQuote).toMatchObject({ + archived: false, + contentPreview: expect.toBeString(), + createdAt: new Date( + Math.round(createdAt.getTime() / 1000) * 1000, + ).toISOString(), + deleted: false, + id: input.id, + savedItem: { + url: input.source, + }, + source: input.source, + title: input.title, + updatedAt: createdAt.toISOString(), + }); + // The keys may get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.createNoteFromQuote?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(fromQuote.expectedSource); + }); + it('throws error for duplicate UUID', async () => { + const uuid = 'ccab26fb-64a5-4071-9044-f42bc2470884'; + const input: CreateNoteFromQuoteInput = { + quote: JSON.stringify(fromQuote.input), + }; + const seed = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ + query: CREATE_NOTE_QUOTE, + variables: { input: { ...input, id: uuid } }, + }); + expect(seed.body.errors).toBeNil(); + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ + query: CREATE_NOTE_QUOTE, + variables: { input: { ...input, id: uuid } }, + }); + expect(res.body.errors).toBeArrayOfSize(1); + expect(res.body.errors[0].extensions.code).toEqual('BAD_USER_INPUT'); + expect(res.body.errors[0].message).toMatch( + 'Received duplicate value for note ID', + ); + }); + it('throws error for bad JSON', async () => { + const input: CreateNoteFromQuoteInput = { + quote: "{ 'bad': 'json'", + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_QUOTE, variables: { input } }); + + expect(res.body.errors).toBeArrayOfSize(1); + expect(res.body.errors[0].extensions.code).toEqual('BAD_USER_INPUT'); + expect(res.body.errors[0].message).toMatch( + 'Received malformed JSON for docContent', + ); + }); + it('throws error for unparseable quote document', async () => { + const bad = { + type: 'doc', + content: [ + { + type: 'invalid', + content: [ + { + type: 'text', + text: '', + }, + ], + }, + ], + }; + const input: CreateNoteFromQuoteInput = { + quote: JSON.stringify(bad), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_QUOTE, variables: { input } }); + + expect(res.body.errors).toBeArrayOfSize(1); + expect(res.body.errors[0].extensions.code).toEqual('BAD_USER_INPUT'); + expect(res.body.errors[0].message).toMatch('Invalid Document'); + }); +}); diff --git a/servers/notes-api/src/test/operations/mutations.ts b/servers/notes-api/src/test/operations/mutations.ts index 9ab35d815..3ab9790fd 100644 --- a/servers/notes-api/src/test/operations/mutations.ts +++ b/servers/notes-api/src/test/operations/mutations.ts @@ -26,3 +26,12 @@ export const CREATE_NOTE = print(gql` } } `); + +export const CREATE_NOTE_QUOTE = print(gql` + ${NoteFragment} + mutation CreateNoteFromQuote($input: CreateNoteFromQuoteInput!) { + createNoteFromQuote(input: $input) { + ...NoteFields + } + } +`);