From 38d010ff79bf7afe8756466210d88f7d591188c2 Mon Sep 17 00:00:00 2001 From: Kat Schelonka <34227334+kschelonka@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:39:13 -0800 Subject: [PATCH] feat(notes): create and edit doc content with markdown (#996) [POCKET-10895] --- pnpm-lock.yaml | 38 +- .../src/generated/graphql/types.ts | 345 ++++++++++++++++++ servers/notes-api/package.json | 4 +- servers/notes-api/schema.graphql | 98 +++++ .../notes-api/src/__generated__/graphql.ts | 108 ++++++ servers/notes-api/src/apollo/resolvers.ts | 9 + servers/notes-api/src/models/Note.ts | 45 ++- .../src/models/ProseMirrorDoc.spec.ts | 32 +- .../notes-api/src/models/ProseMirrorDoc.ts | 10 + .../notes-api/src/test/documents/badMd.json | 17 + .../src/test/documents/basicText.json | 4 - .../src/test/documents/basicTextMD.txt | 7 + .../src/test/documents/fromQuoteMd.txt | 11 + ...createNoteFromQuoteMarkdown.integration.ts | 122 +++++++ .../createNoteMarkdown.integration.ts | 119 ++++++ .../editNoteContentMarkdown.integration.ts | 105 ++++++ .../src/test/operations/mutations.ts | 27 ++ servers/parser-graphql-wrapper/package.json | 4 +- servers/shareable-lists-api/package.json | 2 +- .../src/generated/graphql/types.ts | 345 ++++++++++++++++++ 20 files changed, 1422 insertions(+), 30 deletions(-) create mode 100644 servers/notes-api/src/test/documents/badMd.json create mode 100644 servers/notes-api/src/test/documents/basicTextMD.txt create mode 100644 servers/notes-api/src/test/documents/fromQuoteMd.txt create mode 100644 servers/notes-api/src/test/mutations/createNoteFromQuoteMarkdown.integration.ts create mode 100644 servers/notes-api/src/test/mutations/createNoteMarkdown.integration.ts create mode 100644 servers/notes-api/src/test/mutations/editNoteContentMarkdown.integration.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b008ac607..2044525a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3496,8 +3496,8 @@ importers: specifier: 2.12.6 version: 2.12.6(graphql@16.9.0) kysely: - specifier: 0.27.4 - version: 0.27.4 + specifier: 0.27.5 + version: 0.27.5 pg: specifier: 8.13.1 version: 8.13.1 @@ -3572,8 +3572,8 @@ importers: specifier: 4.0.2 version: 4.0.2(jest@29.7.0(@types/node@22.9.0)(ts-node@10.9.2(@types/node@22.9.0)(typescript@5.5.4))) kysely-codegen: - specifier: ^0.16.4 - version: 0.16.5(kysely@0.27.4)(mysql2@3.11.4)(pg@8.13.1)(tarn@3.0.2) + specifier: 0.17.0 + version: 0.17.0(kysely@0.27.5)(mysql2@3.11.4)(pg@8.13.1)(tarn@3.0.2) nock: specifier: 14.0.0-beta.11 version: 14.0.0-beta.11 @@ -3683,8 +3683,8 @@ importers: specifier: 4.5.4 version: 4.5.4 kysely: - specifier: 0.27.4 - version: 0.27.4 + specifier: 0.27.5 + version: 0.27.5 lodash: specifier: 4.17.21 version: 4.17.21 @@ -3759,8 +3759,8 @@ importers: specifier: 29.7.0 version: 29.7.0(@types/node@22.9.0)(ts-node@10.9.2(@types/node@22.9.0)(typescript@5.5.4)) kysely-codegen: - specifier: ^0.16.4 - version: 0.16.5(kysely@0.27.4)(mysql2@3.11.3)(pg@8.13.1)(tarn@3.0.2) + specifier: 0.17.0 + version: 0.17.0(kysely@0.27.5)(mysql2@3.11.3)(pg@8.13.1)(tarn@3.0.2) nock: specifier: 14.0.0-beta.11 version: 14.0.0-beta.11 @@ -3904,8 +3904,8 @@ importers: specifier: 4.5.4 version: 4.5.4 kysely: - specifier: 0.27.4 - version: 0.27.4 + specifier: 0.27.5 + version: 0.27.5 mysql2: specifier: 3.11.3 version: 3.11.3 @@ -11464,8 +11464,8 @@ packages: kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} - kysely-codegen@0.16.5: - resolution: {integrity: sha512-TiGbwsqdnGR/2vcd072NRNIbyPQYDlnJg/IMas+xgCMEqp2M2T+f6Xonx4I9APXOWl4Mcv1nPMnG/lQT5Yty3A==} + kysely-codegen@0.17.0: + resolution: {integrity: sha512-C36g6epial8cIOSBEWGI9sRfkKSsEzTcivhjPivtYFQnhMdXnrVFaUe7UMZHeSdXaHiWDqDOkReJgWLD8nPKdg==} hasBin: true peerDependencies: '@libsql/kysely-libsql': ^0.3.0 @@ -11498,8 +11498,8 @@ packages: tedious: optional: true - kysely@0.27.4: - resolution: {integrity: sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA==} + kysely@0.27.5: + resolution: {integrity: sha512-s7hZHcQeSNKpzCkHRm8yA+0JPLjncSWnjb+2TIElwS2JAqYr+Kv3Ess+9KFfJS0C1xcQ1i9NkNHpWwCYpHMWsA==} engines: {node: '>=14.0.0'} lazy-cache@0.2.7: @@ -24493,13 +24493,13 @@ snapshots: kuler@2.0.0: {} - kysely-codegen@0.16.5(kysely@0.27.4)(mysql2@3.11.3)(pg@8.13.1)(tarn@3.0.2): + kysely-codegen@0.17.0(kysely@0.27.5)(mysql2@3.11.3)(pg@8.13.1)(tarn@3.0.2): dependencies: chalk: 4.1.2 dotenv: 16.4.5 dotenv-expand: 11.0.6 git-diff: 2.0.6 - kysely: 0.27.4 + kysely: 0.27.5 micromatch: 4.0.8 minimist: 1.2.8 pluralize: 8.0.0 @@ -24508,13 +24508,13 @@ snapshots: pg: 8.13.1 tarn: 3.0.2 - kysely-codegen@0.16.5(kysely@0.27.4)(mysql2@3.11.4)(pg@8.13.1)(tarn@3.0.2): + kysely-codegen@0.17.0(kysely@0.27.5)(mysql2@3.11.4)(pg@8.13.1)(tarn@3.0.2): dependencies: chalk: 4.1.2 dotenv: 16.4.5 dotenv-expand: 11.0.6 git-diff: 2.0.6 - kysely: 0.27.4 + kysely: 0.27.5 micromatch: 4.0.8 minimist: 1.2.8 pluralize: 8.0.0 @@ -24523,7 +24523,7 @@ snapshots: pg: 8.13.1 tarn: 3.0.2 - kysely@0.27.4: {} + kysely@0.27.5: {} lazy-cache@0.2.7: {} diff --git a/servers/braze-content-proxy/src/generated/graphql/types.ts b/servers/braze-content-proxy/src/generated/graphql/types.ts index cb06fbef3..2f3618bf7 100644 --- a/servers/braze-content-proxy/src/generated/graphql/types.ts +++ b/servers/braze-content-proxy/src/generated/graphql/types.ts @@ -24,6 +24,7 @@ export type Scalars = { Markdown: { input: any; output: any; } Max300CharString: { input: any; output: any; } NonNegativeInt: { input: any; output: any; } + ProseMirrorJson: { input: any; output: any; } Timestamp: { input: any; output: any; } Url: { input: any; output: any; } ValidUrl: { input: any; output: any; } @@ -64,6 +65,20 @@ export enum ApprovedItemGrade { C = 'C' } +export type ArchiveNoteInput = { + /** + * The ID of the note to archive or unarchive + * (depends on mutation). + */ + id: Scalars['ID']['input']; + /** + * When the note was archived or unarchived. + * If not provided, defaults to the server time upon + * receiving request. + */ + updatedAt?: InputMaybe; +}; + export type ArticleMarkdown = { __typename?: 'ArticleMarkdown'; images?: Maybe>; @@ -617,6 +632,59 @@ export type CreateHighlightInput = { version: Scalars['Int']['input']; }; +/** + * 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['ProseMirrorJson']['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 = { + /** + * When this note was created. If not provided, defaults to server time upon + * receiving request. + */ + createdAt?: InputMaybe; + /** JSON representation of a ProseMirror document */ + docContent: Scalars['ProseMirrorJson']['input']; + /** + * Client-provided UUID for the new Note. + * If not provided, will be generated on the server. + */ + id?: InputMaybe; + /** Optional URL to link this Note to. */ + source?: InputMaybe; + /** Optional title for this Note */ + title?: InputMaybe; +}; + /** Input data for creating a Shareable List. */ export type CreateShareableListInput = { description?: InputMaybe; @@ -680,6 +748,16 @@ export type DateFilter = { before?: InputMaybe; }; +export type DeleteNoteInput = { + /** + * When the note was deleted was made. If not provided, defaults to + * the server time upon receiving request. + */ + deletedAt?: InputMaybe; + /** The ID of the note to delete */ + id: Scalars['ID']['input']; +}; + export type DeleteSavedItemTagsInput = { /** The id of the SavedItem from which to delete a Tag association */ savedItemId: Scalars['ID']['input']; @@ -698,6 +776,28 @@ export type DomainMetadata = { name?: Maybe; }; +/** Input for editing the content of a Note (user-generated) */ +export type EditNoteContentInput = { + /** JSON representation of a ProseMirror document */ + docContent: Scalars['ProseMirrorJson']['input']; + /** The ID of the note to edit */ + noteId: Scalars['ID']['input']; + /** The time this update was made (defaults to server time) */ + updatedAt?: InputMaybe; +}; + +export type EditNoteTitleInput = { + /** The ID of the note to edit */ + id: Scalars['ID']['input']; + /** The new title for the note (can be an empty string) */ + title: Scalars['String']['input']; + /** + * When the update was made. If not provided, defaults to the server + * time upon receiving request. + */ + updatedAt?: InputMaybe; +}; + /** The reason a user web session is being expired. */ export enum ExpireUserWebSessionReason { /** Expire web session upon logging out. */ @@ -1184,6 +1284,13 @@ export type Mutation = { addShareContext?: Maybe; /** Add a batch of items to an existing shareable list. */ addToShareableList: ShareableList; + /** + * Archive a note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + archiveNote?: Maybe; /** * Make requests to create and delete highlights in a single batch. * Mutation is atomic -- if there is a response, all operations were successful. @@ -1195,6 +1302,13 @@ export type Mutation = { createAndAddToShareableList?: Maybe; /** Create new highlight annotation(s). Returns the data for the created Highlight object. */ createHighlightByUrl: Highlight; + /** 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; /** Create new highlight note. Returns the data for the created Highlight note. */ createSavedItemHighlightNote?: Maybe; /** Create new highlight annotation(s). Returns the data for the created Highlight object(s). */ @@ -1217,6 +1331,12 @@ export type Mutation = { createShareableList?: Maybe; /** Creates a Shareable List Item. */ createShareableListItem?: Maybe; + /** + * Delete a note and all attachments. Returns True if the note was successfully + * deleted. If the note cannot be deleted or does not exist, returns False. + * Errors will be included in the errors array if applicable. + */ + deleteNote: Scalars['ID']['output']; /** * Deletes a SavedItem from the users list. Returns ID of the * deleted SavedItem @@ -1253,6 +1373,20 @@ export type Mutation = { * Returns firefox account ID sent as the query parameter with the request. */ deleteUserByFxaId: Scalars['ID']['output']; + /** + * Edit the content of a Note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + editNoteContent?: Maybe; + /** + * Edit the title of a Note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + editNoteTitle?: Maybe; /** * Expires a user's web session tokens by firefox account ID. * Called by fxa-webhook proxy. Need to supply a reason why to expire user web session. @@ -1359,6 +1493,13 @@ export type Mutation = { * In the future this will be more permissive. */ savedItemUpdateTitle?: Maybe; + /** + * Unarchive a note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + unArchiveNote?: Maybe; /** * Update an existing highlight annotation, by its ID. * If the given highlight ID does not exist, will return error data @@ -1461,6 +1602,12 @@ export type MutationAddToShareableListArgs = { }; +/** Default Mutation Type */ +export type MutationArchiveNoteArgs = { + input: ArchiveNoteInput; +}; + + /** Default Mutation Type */ export type MutationBatchWriteHighlightsArgs = { input?: InputMaybe; @@ -1487,6 +1634,18 @@ export type MutationCreateHighlightByUrlArgs = { }; +/** Default Mutation Type */ +export type MutationCreateNoteArgs = { + input: CreateNoteInput; +}; + + +/** Default Mutation Type */ +export type MutationCreateNoteFromQuoteArgs = { + input: CreateNoteFromQuoteInput; +}; + + /** Default Mutation Type */ export type MutationCreateSavedItemHighlightNoteArgs = { id: Scalars['ID']['input']; @@ -1527,6 +1686,12 @@ export type MutationCreateShareableListItemArgs = { }; +/** Default Mutation Type */ +export type MutationDeleteNoteArgs = { + input: DeleteNoteInput; +}; + + /** Default Mutation Type */ export type MutationDeleteSavedItemArgs = { id: Scalars['ID']['input']; @@ -1583,6 +1748,18 @@ export type MutationDeleteUserByFxaIdArgs = { }; +/** Default Mutation Type */ +export type MutationEditNoteContentArgs = { + input: EditNoteContentInput; +}; + + +/** Default Mutation Type */ +export type MutationEditNoteTitleArgs = { + input: EditNoteTitleInput; +}; + + /** Default Mutation Type */ export type MutationExpireUserWebSessionByFxaIdArgs = { id: Scalars['ID']['input']; @@ -1752,6 +1929,12 @@ export type MutationSavedItemUpdateTitleArgs = { }; +/** Default Mutation Type */ +export type MutationUnArchiveNoteArgs = { + input: ArchiveNoteInput; +}; + + /** Default Mutation Type */ export type MutationUpdateHighlightArgs = { id: Scalars['ID']['input']; @@ -1885,6 +2068,112 @@ export type NotFound = BaseError & { value?: Maybe; }; +/** + * A Note is an entity which may contain extracted components + * from websites (clippings/snippets), user-generated rich text content, + * and may be linked to a source url. + */ +export type Note = { + __typename?: 'Note'; + /** Whether this Note has been marked as archived (hide from default view). */ + archived: Scalars['Boolean']['output']; + /** Markdown preview of the note content for summary view. */ + contentPreview?: Maybe; + /** When this note was created */ + createdAt: Scalars['ISOString']['output']; + /** + * Whether this Note has been marked for deletion (will be eventually + * removed from the server). Clients should delete Notes from their local + * storage if this value is true. + */ + deleted: Scalars['Boolean']['output']; + /** + * JSON representation of a ProseMirror document + * (compatible with Markdown) + */ + docContent?: Maybe; + /** Markdown representation of the note content */ + docMarkdown?: Maybe; + /** This Note's identifier */ + id: Scalars['ID']['output']; + /** + * The SavedItem entity this note is attached to (either directly + * or via a Clipping, if applicable) + */ + savedItem?: Maybe; + /** + * The URL this entity was created from (either directly or via + * a Clipping, if applicable). + */ + source?: Maybe; + /** Title of this note */ + title?: Maybe; + /** When this note was last updated */ + updatedAt: Scalars['ISOString']['output']; +}; + +/** The connection type for Note. */ +export type NoteConnection = { + __typename?: 'NoteConnection'; + /** A list of edges. */ + edges?: Maybe>>; + /** Information to aid in pagination. */ + pageInfo: PageInfo; + /** Identifies the total count of Notes in the connection. */ + totalCount: Scalars['Int']['output']; +}; + +/** An edge in a connection. */ +export type NoteEdge = { + __typename?: 'NoteEdge'; + /** A cursor for use in pagination. */ + cursor: Scalars['String']['output']; + /** The Note at the end of the edge. */ + node?: Maybe; +}; + +/** Filter for retrieving Notes */ +export type NoteFilterInput = { + /** + * Filter to retrieve Notes by archived status (true/false). + * If not provided, notes will not be filtered by archived status. + */ + archived?: InputMaybe; + /** + * Filter to choose whether to include notes marked for server-side + * deletion in the response (defaults to false). + */ + excludeDeleted?: InputMaybe; + /** + * Filter to show notes which are attached to a source URL + * directly or via clipping, or are standalone + * notes. If not provided, notes will not be filtered by source url. + */ + isAttachedToSave?: InputMaybe; + /** Filter to retrieve notes after a timestamp, e.g. for syncing. */ + since?: InputMaybe; +}; + +/** Enum to specify the sort by field (these are the current options, we could add more in the future) */ +export enum NoteSortBy { + CreatedAt = 'CREATED_AT', + UpdatedAt = 'UPDATED_AT' +} + +/** Input to sort fetched Notes. If unspecified, defaults to UPDATED_AT, DESC. */ +export type NoteSortInput = { + /** The field by which to sort Notes */ + sortBy: NoteSortBy; + /** The order in which to sort Notes */ + sortOrder: NoteSortOrder; +}; + +/** Possible values for sort ordering (ascending/descending) */ +export enum NoteSortOrder { + Asc = 'ASC', + Desc = 'DESC' +} + export type NumberedListElement = ListElement & { __typename?: 'NumberedListElement'; /** Row in a list */ @@ -2221,6 +2510,10 @@ export type Query = { listTopics: Array; /** Get a slate of ranked recommendations for the Firefox New Tab. Currently supports the Italy, France, and Spain markets. */ newTabSlate: CorpusSlate; + /** Retrieve a specific Note */ + note?: Maybe; + /** Retrieve a user's Notes */ + notes?: Maybe; /** * Resolve Reader View links which might point to SavedItems that do not * exist, aren't in the Pocket User's list, or are requested by a logged-out @@ -2384,6 +2677,26 @@ export type QueryNewTabSlateArgs = { }; +/** + * Default root level query type. All authorization checks are done in these queries. + * TODO: These belong in a seperate User Service that provides a User object (the user settings will probably exist there too) + */ +export type QueryNoteArgs = { + id: Scalars['ID']['input']; +}; + + +/** + * Default root level query type. All authorization checks are done in these queries. + * TODO: These belong in a seperate User Service that provides a User object (the user settings will probably exist there too) + */ +export type QueryNotesArgs = { + filter?: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe; +}; + + /** * Default root level query type. All authorization checks are done in these queries. * TODO: These belong in a seperate User Service that provides a User object (the user settings will probably exist there too) @@ -2686,6 +2999,11 @@ export type SavedItem = RemoteEntity & { isFavorite: Scalars['Boolean']['output']; /** Link to the underlying Pocket Item for the URL */ item: ItemResult; + /** + * The notes associated with this SavedItem, optionally + * filtered to retrieve after a 'since' parameter. + */ + notes: NoteConnection; /** The status of this SavedItem */ status?: Maybe; /** The Suggested Tags associated with this SavedItem, if the user is not premium or there are none, this will be empty. */ @@ -2698,6 +3016,17 @@ export type SavedItem = RemoteEntity & { url: Scalars['String']['output']; }; + +/** + * Represents a Pocket Item that a user has saved to their list. + * (Said otherways, indicates a saved url to a users list and associated user specific information.) + */ +export type SavedItemNotesArgs = { + filter?: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe; +}; + /** * Container for all annotations associated to a SavedItem. * Can be extended when more types of annotations are added. @@ -2739,6 +3068,22 @@ export type SavedItemEdge = { /** All types in this union should implement BaseError, for client fallback */ export type SavedItemMutationError = NotFound | SyncConflict; +/** Filter for retrieving Notes attached to a SavedItem */ +export type SavedItemNoteFilterInput = { + /** + * Filter to retrieve Notes by archived status (true/false). + * If not provided, notes will not be filtered by archived status. + */ + archived?: InputMaybe; + /** + * Filter to choose whether to include notes marked for server-side + * deletion in the response (defaults to false). + */ + excludeDeleted?: InputMaybe; + /** Filter to retrieve notes after a timestamp, e.g. for syncing. */ + since?: InputMaybe; +}; + /** * We don't have official oneOf support, but this will * throw if both `id` and `url` are unset/null. diff --git a/servers/notes-api/package.json b/servers/notes-api/package.json index 73574406e..38c38e00a 100644 --- a/servers/notes-api/package.json +++ b/servers/notes-api/package.json @@ -52,7 +52,7 @@ "graphql-constraint-directive": "5.4.2", "graphql-scalars": "^1.23.0", "graphql-tag": "2.12.6", - "kysely": "0.27.4", + "kysely": "0.27.5", "pg": "8.13.1", "prisma": "5.21.1", "prosemirror-markdown": "1.13.1", @@ -79,7 +79,7 @@ "concurrently": "^8.2.2", "jest": "29.7.0", "jest-extended": "4.0.2", - "kysely-codegen": "^0.16.4", + "kysely-codegen": "0.17.0", "nock": "14.0.0-beta.11", "nodemon": "3.1.7", "supertest": "7.0.0", diff --git a/servers/notes-api/schema.graphql b/servers/notes-api/schema.graphql index d7998beac..3f1f36548 100644 --- a/servers/notes-api/schema.graphql +++ b/servers/notes-api/schema.graphql @@ -249,6 +249,34 @@ input CreateNoteInput { createdAt: ISOString } + +""" +Input to create a new Note with markdown-formatted +content string. +""" +input CreateNoteMarkdownInput { + """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 + """ + Optional URL to link this Note to. + """ + source: ValidUrl + """ + The document content in Commonmark Markdown. + """ + docMarkdown: Markdown! + """ + When this note was created. If not provided, defaults to server time upon + receiving request. + """ + 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" @@ -282,6 +310,39 @@ input CreateNoteFromQuoteInput { 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 CreateNoteFromQuoteMarkdownInput { + """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 + """ + Commonmark Markdown document, which contains the formatted + snipped text. This is used to seed the initial Note + document state, and will become editable. + """ + quote: Markdown! + """ + When this note was created. If not provided, defaults to server time upon + receiving request. + """ + createdAt: ISOString +} + input EditNoteTitleInput { """The ID of the note to edit""" id: ID! @@ -311,6 +372,25 @@ input EditNoteContentInput { updatedAt: ISOString } +""" +Input for editing the content of a Note (user-generated), +providing the content as a Markdown-formatted string. +""" +input EditNoteContentMarkdownInput { + """ + The ID of the note to edit + """ + noteId: ID! + """ + Commonmark Markdown string representing the document content. + """ + docMarkdown: Markdown! + """ + The time this update was made (defaults to server time) + """ + updatedAt: ISOString +} + input DeleteNoteInput { """The ID of the note to delete""" id: ID! @@ -341,10 +421,20 @@ type Mutation { """ createNote(input: CreateNoteInput!): Note! @requiresScopes(scopes: [["ROLE_USER"]]) """ + Create a new note, optionally with title and markdown content + """ + createNoteMarkdown(input: CreateNoteMarkdownInput!): 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"]]) + """ + Create a new note, with a pre-populated block that contains the quoted and cited text + selected by a user. + """ + createNoteFromQuoteMarkdown(input: CreateNoteFromQuoteMarkdownInput!): Note! @requiresScopes(scopes: [["ROLE_USER"]]) + """ Edit the title of a Note. If the Note does not exist or is inaccessible for the current user, @@ -360,6 +450,14 @@ type Mutation { """ editNoteContent(input: EditNoteContentInput!): Note @requiresScopes(scopes: [["ROLE_USER"]]) """ + Edit the content of a Note, providing a markdown document instead + of a Prosemirror JSON. + If the Note does not exist or is inaccessible for the current user, + response will be null and a NOT_FOUND error will be included in the + errors array. + """ + editNoteContentMarkdown(input: EditNoteContentMarkdownInput!): Note @requiresScopes(scopes: [["ROLE_USER"]]) + """ Delete a note and all attachments. Returns True if the note was successfully deleted. If the note cannot be deleted or does not exist, returns False. Errors will be included in the errors array if applicable. diff --git a/servers/notes-api/src/__generated__/graphql.ts b/servers/notes-api/src/__generated__/graphql.ts index b521f2452..9b5fda66e 100644 --- a/servers/notes-api/src/__generated__/graphql.ts +++ b/servers/notes-api/src/__generated__/graphql.ts @@ -75,6 +75,39 @@ export type CreateNoteFromQuoteInput = { title?: InputMaybe; }; +/** + * 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 CreateNoteFromQuoteMarkdownInput = { + /** + * 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; + /** + * Commonmark Markdown document, which contains the formatted + * snipped text. This is used to seed the initial Note + * document state, and will become editable. + */ + quote: Scalars['Markdown']['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 = { /** @@ -95,6 +128,29 @@ export type CreateNoteInput = { title?: InputMaybe; }; +/** + * Input to create a new Note with markdown-formatted + * content string. + */ +export type CreateNoteMarkdownInput = { + /** + * When this note was created. If not provided, defaults to server time upon + * receiving request. + */ + createdAt?: InputMaybe; + /** The document content in Commonmark Markdown. */ + docMarkdown: Scalars['Markdown']['input']; + /** + * Client-provided UUID for the new Note. + * If not provided, will be generated on the server. + */ + id?: InputMaybe; + /** Optional URL to link this Note to. */ + source?: InputMaybe; + /** Optional title for this Note */ + title?: InputMaybe; +}; + export type DeleteNoteInput = { /** * When the note was deleted was made. If not provided, defaults to @@ -115,6 +171,19 @@ export type EditNoteContentInput = { updatedAt?: InputMaybe; }; +/** + * Input for editing the content of a Note (user-generated), + * providing the content as a Markdown-formatted string. + */ +export type EditNoteContentMarkdownInput = { + /** Commonmark Markdown string representing the document content. */ + docMarkdown: Scalars['Markdown']['input']; + /** The ID of the note to edit */ + noteId: Scalars['ID']['input']; + /** The time this update was made (defaults to server time) */ + updatedAt?: InputMaybe; +}; + export type EditNoteTitleInput = { /** The ID of the note to edit */ id: Scalars['ID']['input']; @@ -143,6 +212,13 @@ export type Mutation = { * selected by a user. */ createNoteFromQuote: Note; + /** + * Create a new note, with a pre-populated block that contains the quoted and cited text + * selected by a user. + */ + createNoteFromQuoteMarkdown: Note; + /** Create a new note, optionally with title and markdown content */ + createNoteMarkdown: Note; /** * Delete a note and all attachments. Returns True if the note was successfully * deleted. If the note cannot be deleted or does not exist, returns False. @@ -156,6 +232,14 @@ export type Mutation = { * errors array. */ editNoteContent?: Maybe; + /** + * Edit the content of a Note, providing a markdown document instead + * of a Prosemirror JSON. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + editNoteContentMarkdown?: Maybe; /** * Edit the title of a Note. * If the Note does not exist or is inaccessible for the current user, @@ -188,6 +272,16 @@ export type MutationCreateNoteFromQuoteArgs = { }; +export type MutationCreateNoteFromQuoteMarkdownArgs = { + input: CreateNoteFromQuoteMarkdownInput; +}; + + +export type MutationCreateNoteMarkdownArgs = { + input: CreateNoteMarkdownInput; +}; + + export type MutationDeleteNoteArgs = { input: DeleteNoteInput; }; @@ -198,6 +292,11 @@ export type MutationEditNoteContentArgs = { }; +export type MutationEditNoteContentMarkdownArgs = { + input: EditNoteContentMarkdownInput; +}; + + export type MutationEditNoteTitleArgs = { input: EditNoteTitleInput; }; @@ -499,9 +598,12 @@ export type ResolversTypes = ResolversObject<{ ID: ResolverTypeWrapper; CreateNoteFromQuoteInput: CreateNoteFromQuoteInput; String: ResolverTypeWrapper; + CreateNoteFromQuoteMarkdownInput: CreateNoteFromQuoteMarkdownInput; CreateNoteInput: CreateNoteInput; + CreateNoteMarkdownInput: CreateNoteMarkdownInput; DeleteNoteInput: DeleteNoteInput; EditNoteContentInput: EditNoteContentInput; + EditNoteContentMarkdownInput: EditNoteContentMarkdownInput; EditNoteTitleInput: EditNoteTitleInput; ISOString: ResolverTypeWrapper; Markdown: ResolverTypeWrapper; @@ -530,9 +632,12 @@ export type ResolversParentTypes = ResolversObject<{ ID: Scalars['ID']['output']; CreateNoteFromQuoteInput: CreateNoteFromQuoteInput; String: Scalars['String']['output']; + CreateNoteFromQuoteMarkdownInput: CreateNoteFromQuoteMarkdownInput; CreateNoteInput: CreateNoteInput; + CreateNoteMarkdownInput: CreateNoteMarkdownInput; DeleteNoteInput: DeleteNoteInput; EditNoteContentInput: EditNoteContentInput; + EditNoteContentMarkdownInput: EditNoteContentMarkdownInput; EditNoteTitleInput: EditNoteTitleInput; ISOString: Scalars['ISOString']['output']; Markdown: Scalars['Markdown']['output']; @@ -565,8 +670,11 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createNote?: Resolver>; createNoteFromQuote?: Resolver>; + createNoteFromQuoteMarkdown?: Resolver>; + createNoteMarkdown?: Resolver>; deleteNote?: Resolver>; editNoteContent?: Resolver, ParentType, ContextType, RequireFields>; + editNoteContentMarkdown?: Resolver, ParentType, ContextType, RequireFields>; editNoteTitle?: Resolver, ParentType, ContextType, RequireFields>; unArchiveNote?: Resolver, ParentType, ContextType, RequireFields>; }>; diff --git a/servers/notes-api/src/apollo/resolvers.ts b/servers/notes-api/src/apollo/resolvers.ts index 5f6caae6c..8ec7ef914 100644 --- a/servers/notes-api/src/apollo/resolvers.ts +++ b/servers/notes-api/src/apollo/resolvers.ts @@ -66,15 +66,24 @@ export const resolvers: Resolvers = { createNote(root, { input }, context) { return context.NoteModel.create(input); }, + createNoteMarkdown(root, { input }, context) { + return context.NoteModel.createFromMarkdown(input); + }, createNoteFromQuote(root, { input }, context) { return context.NoteModel.fromQuote(input); }, + createNoteFromQuoteMarkdown(root, { input }, context) { + return context.NoteModel.fromMarkdownQuote(input); + }, editNoteTitle(root, { input }, context) { return context.NoteModel.editTitle(input); }, editNoteContent(root, { input }, context) { return context.NoteModel.editContent(input); }, + editNoteContentMarkdown(root, { input }, context) { + return context.NoteModel.editContentMarkdown(input); + }, deleteNote(root, { input }, context) { return context.NoteModel.deleteNote(input); }, diff --git a/servers/notes-api/src/models/Note.ts b/servers/notes-api/src/models/Note.ts index 7793397c1..679234776 100644 --- a/servers/notes-api/src/models/Note.ts +++ b/servers/notes-api/src/models/Note.ts @@ -6,6 +6,9 @@ import { EditNoteTitleInput, EditNoteContentInput, DeleteNoteInput, + CreateNoteFromQuoteMarkdownInput, + CreateNoteMarkdownInput, + EditNoteContentMarkdownInput, ArchiveNoteInput, } from '../__generated__/graphql'; import { DB, Note as NoteEntity } from '../__generated__/db'; @@ -13,7 +16,11 @@ import { Insertable, NoResultError, Selectable } from 'kysely'; import { orderAndMap } from '../utils/dataloader'; import { IContext } from '../apollo/context'; import { NotesService } from '../datasources/NoteService'; -import { ProseMirrorDoc, wrapDocInBlockQuote } from './ProseMirrorDoc'; +import { + docFromMarkdown, + ProseMirrorDoc, + wrapDocInBlockQuote, +} from './ProseMirrorDoc'; import { DatabaseError } from 'pg'; import { NoteFilterInput, @@ -173,6 +180,18 @@ export class NoteModel { } } } + /** + * Create a new note, with CommonMark Markdown-formatted content + * instead of a JSON representation of a Prosemirror document. + */ + async createFromMarkdown(input: CreateNoteMarkdownInput) { + const doc = docFromMarkdown(input.docMarkdown); + const createInput: CreateNoteInput = { + ...input, + docContent: JSON.stringify(doc.toJSON()), + }; + return await this.create(createInput); + } /** * Create a new Note seeded with a blockquote (optionally with * an additional paragraph with the source link). @@ -201,6 +220,18 @@ export class NoteModel { } } } + /** + * Create a new Note seeded with a blockquote (optionally with + * an additional paragraph with the source link). + */ + async fromMarkdownQuote(input: CreateNoteFromQuoteMarkdownInput) { + const quote = docFromMarkdown(input.quote); + const createInput: CreateNoteFromQuoteInput = { + ...input, + quote: JSON.stringify(quote.toJSON()), + }; + return await this.fromQuote(createInput); + } /** * Edit a note's title */ @@ -251,6 +282,18 @@ export class NoteModel { } } } + /** + * Edit a note's content, replacing it with the + * the Commonmark markdown document content input. + */ + async editContentMarkdown(input: EditNoteContentMarkdownInput) { + const doc = docFromMarkdown(input.docMarkdown); + const editInput: EditNoteContentInput = { + ...input, + docContent: JSON.stringify(doc.toJSON()), + }; + return await this.editContent(editInput); + } /** * Delete a Note */ diff --git a/servers/notes-api/src/models/ProseMirrorDoc.spec.ts b/servers/notes-api/src/models/ProseMirrorDoc.spec.ts index 25f60028a..3f56bd21a 100644 --- a/servers/notes-api/src/models/ProseMirrorDoc.spec.ts +++ b/servers/notes-api/src/models/ProseMirrorDoc.spec.ts @@ -1,8 +1,20 @@ import basicText from '../test/documents/basicText.json'; -import { ProseMirrorDoc, wrapDocInBlockQuote } from './ProseMirrorDoc'; +import { + docFromMarkdown, + ProseMirrorDoc, + wrapDocInBlockQuote, +} from './ProseMirrorDoc'; import { schema } from 'prosemirror-markdown'; import fromQuote from '../test/documents/fromQuote.json'; +import badMd from '../test/documents/badMd.json'; import { UserInputError } from '@pocket-tools/apollo-utils'; +import * as fs from 'fs'; +import path from 'path'; + +const basicTextMd = fs.readFileSync( + path.resolve(__dirname, '../test/documents/basicTextMD.txt'), + 'utf8', +); describe('ProseMirrorDoc', () => { // TODO - Improve specificity when preview format is decided @@ -50,4 +62,22 @@ describe('ProseMirrorDoc', () => { ); }); }); + describe('markdown parser', () => { + it('parses plain text paragraphs', () => { + const doc = docFromMarkdown(basicTextMd); + expect(doc.toJSON()).toEqual(basicText); + }); + it('passes invalid markdown as literal string content', () => { + const doc = docFromMarkdown(badMd.md); + expect(doc.toJSON()).toEqual(badMd.pm); + }); + it('works for empty string', () => { + const doc = docFromMarkdown(''); + const expected = { + type: 'doc', + content: [{ type: 'paragraph' }], + }; + expect(doc.toJSON()).toEqual(expected); + }); + }); }); diff --git a/servers/notes-api/src/models/ProseMirrorDoc.ts b/servers/notes-api/src/models/ProseMirrorDoc.ts index c90f186eb..48d6948bb 100644 --- a/servers/notes-api/src/models/ProseMirrorDoc.ts +++ b/servers/notes-api/src/models/ProseMirrorDoc.ts @@ -3,6 +3,7 @@ import { EditorState, AllSelection } from 'prosemirror-state'; import { findWrapping } from 'prosemirror-transform'; import { defaultMarkdownSerializer, + defaultMarkdownParser, schema as commonMarkSchema, } from 'prosemirror-markdown'; import { UserInputError } from '@pocket-tools/apollo-utils'; @@ -38,6 +39,15 @@ export class ProseMirrorDoc { } } +/** + * Create a new Prosemirror editor state from a markdown string. + * If the string cannot be serialized into a markdown mark, + * it will just be parsed as a string literal. + */ +export function docFromMarkdown(doc: string) { + return defaultMarkdownParser.parse(doc); +} + /** * Wrap a JSON representation of a ProseMirror document in a blockquote, * optionally with an additional paragraph that references the source diff --git a/servers/notes-api/src/test/documents/badMd.json b/servers/notes-api/src/test/documents/badMd.json new file mode 100644 index 000000000..9d3dec839 --- /dev/null +++ b/servers/notes-api/src/test/documents/badMd.json @@ -0,0 +1,17 @@ +{ + "pm": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "I just love **bold text with links](http://localhost.com)" + } + ] + } + ] + }, + "md": "I just love **bold text with links](http://localhost.com)" +} diff --git a/servers/notes-api/src/test/documents/basicText.json b/servers/notes-api/src/test/documents/basicText.json index 22d13d32a..7e97752a4 100644 --- a/servers/notes-api/src/test/documents/basicText.json +++ b/servers/notes-api/src/test/documents/basicText.json @@ -3,14 +3,12 @@ "content": [ { "type": "paragraph", - "attrs": { "textAlign": "left" }, "content": [ { "type": "text", "text": "“We should rewrite it all,” said Pham." } ] }, { "type": "paragraph", - "attrs": { "textAlign": "left" }, "content": [ { "type": "text", @@ -20,7 +18,6 @@ }, { "type": "paragraph", - "attrs": { "textAlign": "left" }, "content": [ { "type": "text", @@ -30,7 +27,6 @@ }, { "type": "paragraph", - "attrs": { "textAlign": "left" }, "content": [ { "type": "text", diff --git a/servers/notes-api/src/test/documents/basicTextMD.txt b/servers/notes-api/src/test/documents/basicTextMD.txt new file mode 100644 index 000000000..b82074888 --- /dev/null +++ b/servers/notes-api/src/test/documents/basicTextMD.txt @@ -0,0 +1,7 @@ +“We should rewrite it all,” said Pham. + +“It’s been done,” said Sura, not looking up. She was preparing to go off-Watch, and had spent the last four days trying to root a problem out of the coldsleep automation. + +“It’s been tried,” corrected Bret, just back from the freezers. “But even the top levels of fleet system code are enormous. You and a thousand of your friends would have to work for a century or so to reproduce it.” Trinli grinned evilly. “And guess what—even if you did, by the time you finished, you’d have your own set of inconsistencies. And you still wouldn’t be consistent with all the applications that might be needed now and then.” + +Sura gave up on her debugging for the moment. “The word for all this is ‘mature programming environment.’ Basically, when hardware performance has been pushed to its final limit, and programmers have had several centuries to code, you reach a point where there is far more signicant code than can be rationalized. The best you can do is understand the overall layering, and know how to search for the oddball tool that may come in handy—take the situation I have here.” She waved at the dependency chart she had been working on. “We are low on working fluid for the coffins. Like a million other things, there was none for sale on dear old Canberra. Well, the obvious thing is to move the coffins near the aft hull, and cool by direct radiation. We don’t have the proper equipment to support this—so lately, I’ve been doing my share of archeology. It seems that five hundred years ago, a similar thing happened after an in-system war at Torma. They hacked together a temperature maintenance package that is precisely what we need.” \ No newline at end of file diff --git a/servers/notes-api/src/test/documents/fromQuoteMd.txt b/servers/notes-api/src/test/documents/fromQuoteMd.txt new file mode 100644 index 000000000..bc77c2be9 --- /dev/null +++ b/servers/notes-api/src/test/documents/fromQuoteMd.txt @@ -0,0 +1,11 @@ +This is something I’m copying from somewhere else + +I want to make everything a blockquote + +Here’s a list: + +* abc + +* 123 + +* def \ No newline at end of file diff --git a/servers/notes-api/src/test/mutations/createNoteFromQuoteMarkdown.integration.ts b/servers/notes-api/src/test/mutations/createNoteFromQuoteMarkdown.integration.ts new file mode 100644 index 000000000..7319f1486 --- /dev/null +++ b/servers/notes-api/src/test/mutations/createNoteFromQuoteMarkdown.integration.ts @@ -0,0 +1,122 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { CREATE_NOTE_QUOTE_MD } from '../operations'; +import { db } from '../../datasources/db'; +import { sql } from 'kysely'; +import { CreateNoteFromQuoteMarkdownInput } from '../../__generated__/graphql'; +import fromQuote from '../documents/fromQuote.json'; +import { Chance } from 'chance'; +import * as fs from 'fs'; +import path from 'path'; + +const fromQuoteMd = fs.readFileSync( + path.resolve(__dirname, '../documents/fromQuoteMd.txt'), + 'utf8', +); + +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: CreateNoteFromQuoteMarkdownInput = { + quote: fromQuoteMd, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_QUOTE_MD, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNoteFromQuoteMarkdown).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?.createNoteFromQuoteMarkdown?.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: CreateNoteFromQuoteMarkdownInput = { + title: chance.sentence(), + createdAt, + source: 'localhost:3001', + id: chance.guid({ version: 4 }), + quote: fromQuoteMd, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_QUOTE_MD, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNoteFromQuoteMarkdown).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?.createNoteFromQuoteMarkdown?.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: CreateNoteFromQuoteMarkdownInput = { + quote: JSON.stringify(fromQuote.input), + }; + const seed = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ + query: CREATE_NOTE_QUOTE_MD, + 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_MD, + 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', + ); + }); +}); diff --git a/servers/notes-api/src/test/mutations/createNoteMarkdown.integration.ts b/servers/notes-api/src/test/mutations/createNoteMarkdown.integration.ts new file mode 100644 index 000000000..0a0589cb7 --- /dev/null +++ b/servers/notes-api/src/test/mutations/createNoteMarkdown.integration.ts @@ -0,0 +1,119 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { CREATE_NOTE_MD } from '../operations'; +import { db } from '../../datasources/db'; +import { sql } from 'kysely'; +import { CreateNoteMarkdownInput } from '../../__generated__/graphql'; +import basicText from '../documents/basicText.json'; +import { Chance } from 'chance'; +import * as fs from 'fs'; +import path from 'path'; + +const basicTextMd = fs.readFileSync( + path.resolve(__dirname, '../documents/basicTextMD.txt'), + 'utf8', +); + +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: CreateNoteMarkdownInput = { + docMarkdown: basicTextMd, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_MD, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNoteMarkdown).toMatchObject({ + archived: false, + contentPreview: expect.toBeString(), + docMarkdown: basicTextMd, + createdAt: expect.toBeDateString(), + deleted: false, + id: expect.toBeString(), + savedItem: null, + source: null, + title: null, + updatedAt: expect.toBeDateString(), + }); + // The keys get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.createNoteMarkdown?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(basicText); + }); + it('creates a note with optional fields', async () => { + const chance = new Chance(); + const createdAt = new Date(chance.hammertime()); + const input: CreateNoteMarkdownInput = { + title: chance.sentence(), + createdAt, + source: chance.url(), + id: chance.guid({ version: 4 }), + docMarkdown: basicTextMd, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: CREATE_NOTE_MD, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.createNoteMarkdown).toMatchObject({ + archived: false, + contentPreview: expect.toBeString(), + createdAt: new Date( + Math.round(createdAt.getTime() / 1000) * 1000, + ).toISOString(), + docMarkdown: basicTextMd, + deleted: false, + id: input.id, + savedItem: { + url: input.source, + }, + source: input.source, + title: input.title, + updatedAt: createdAt.toISOString(), + }); + }); + it('throws error for duplicate UUID', async () => { + const uuid = 'ccab26fb-64a5-4071-9044-f42bc2470884'; + const input: CreateNoteMarkdownInput = { + docMarkdown: basicTextMd, + }; + const seed = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ + query: CREATE_NOTE_MD, + variables: { input: { ...input, id: uuid } }, + }); + expect(seed.body.errors).toBeNil(); + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ + query: CREATE_NOTE_MD, + 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', + ); + }); +}); diff --git a/servers/notes-api/src/test/mutations/editNoteContentMarkdown.integration.ts b/servers/notes-api/src/test/mutations/editNoteContentMarkdown.integration.ts new file mode 100644 index 000000000..e4793b126 --- /dev/null +++ b/servers/notes-api/src/test/mutations/editNoteContentMarkdown.integration.ts @@ -0,0 +1,105 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { EDIT_NOTE_CONTENT_MD } from '../operations'; +import { db } from '../../datasources/db'; +import { sql } from 'kysely'; +import { Chance } from 'chance'; +import { Note as NoteFaker } from '../fakes/Note'; +import { EditNoteContentMarkdownInput } from '../../__generated__/graphql'; +import basicText from '../documents/basicText.json'; +import * as fs from 'fs'; +import path from 'path'; + +const basicTextMd = fs.readFileSync( + path.resolve(__dirname, '../documents/basicTextMD.txt'), + 'utf8', +); + +let app: Application; +let server: ApolloServer; +let graphQLUrl: string; +const chance = new Chance(); +const notes = [...Array(4).keys()].map((_) => NoteFaker(chance)); + +beforeAll(async () => { + // port 0 tells express to dynamically assign an available port + ({ app, server, url: graphQLUrl } = await startServer(0)); + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + await db + .insertInto('Note') + .values(notes) + .returning(['noteId', 'userId']) + .execute(); +}); +afterAll(async () => { + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + await server.stop(); + await db.destroy(); +}); + +describe('note', () => { + it('edits a note content with a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[0]; + + const input: EditNoteContentMarkdownInput = { + noteId, + docMarkdown: basicTextMd, + updatedAt: now.toISOString(), + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: EDIT_NOTE_CONTENT_MD, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.editNoteContentMarkdown).toMatchObject({ + docContent: expect.toBeString(), + updatedAt: now.toISOString(), + }); + // The keys get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.editNoteContentMarkdown?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(basicText); + }); + it('edits a note title without a timestamp', async () => { + const now = new Date(Date.now()); + const { userId, noteId } = notes[1]; + const input: EditNoteContentMarkdownInput = { + noteId, + docMarkdown: basicTextMd, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: EDIT_NOTE_CONTENT_MD, variables: { input } }); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data?.editNoteContentMarkdown).toMatchObject({ + docContent: expect.toBeString(), + updatedAt: expect.toBeDateString(), + }); + const updatedAt = new Date( + res.body.data?.editNoteContentMarkdown?.updatedAt, + ); + expect(updatedAt.getTime() - now.getTime()).toBeLessThanOrEqual(10000); // within 10 seconds of when this test started + // The keys get reordered so we have to deeply compare the + // JSON-serialized results + const receivedDoc = res.body.data?.editNoteContentMarkdown?.docContent; + expect(receivedDoc).not.toBeNil(); + expect(JSON.parse(receivedDoc)).toStrictEqual(basicText); + }); + it('includes not found error for nonexistent note', async () => { + const input: EditNoteContentMarkdownInput = { + noteId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', + docMarkdown: basicTextMd, + }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: '1' }) + .send({ query: EDIT_NOTE_CONTENT_MD, variables: { input } }); + expect(res.body.errors).toBeArrayOfSize(1); + expect(res.body.errors[0].extensions.code).toEqual('NOT_FOUND'); + }); +}); diff --git a/servers/notes-api/src/test/operations/mutations.ts b/servers/notes-api/src/test/operations/mutations.ts index edcfe9db5..dfd35c7ad 100644 --- a/servers/notes-api/src/test/operations/mutations.ts +++ b/servers/notes-api/src/test/operations/mutations.ts @@ -28,6 +28,15 @@ export const CREATE_NOTE = print(gql` } `); +export const CREATE_NOTE_MD = print(gql` + ${NoteFragment} + mutation CreateNoteMd($input: CreateNoteMarkdownInput!) { + createNoteMarkdown(input: $input) { + ...NoteFields + } + } +`); + export const CREATE_NOTE_QUOTE = print(gql` ${NoteFragment} mutation CreateNoteFromQuote($input: CreateNoteFromQuoteInput!) { @@ -37,6 +46,15 @@ export const CREATE_NOTE_QUOTE = print(gql` } `); +export const CREATE_NOTE_QUOTE_MD = print(gql` + ${NoteFragment} + mutation CreateNoteFromQuoteMd($input: CreateNoteFromQuoteMarkdownInput!) { + createNoteFromQuoteMarkdown(input: $input) { + ...NoteFields + } + } +`); + export const EDIT_NOTE_TITLE = print(gql` ${NoteFragment} mutation EditNoteTitle($input: EditNoteTitleInput!) { @@ -55,6 +73,15 @@ export const EDIT_NOTE_CONTENT = print(gql` } `); +export const EDIT_NOTE_CONTENT_MD = print(gql` + ${NoteFragment} + mutation EditNoteContentMd($input: EditNoteContentMarkdownInput!) { + editNoteContentMarkdown(input: $input) { + ...NoteFields + } + } +`); + export const DELETE_NOTE = print(gql` mutation DeleteNote($input: DeleteNoteInput!) { deleteNote(input: $input) diff --git a/servers/parser-graphql-wrapper/package.json b/servers/parser-graphql-wrapper/package.json index 7023b5b1e..218698f5f 100644 --- a/servers/parser-graphql-wrapper/package.json +++ b/servers/parser-graphql-wrapper/package.json @@ -47,7 +47,7 @@ "graphql-tag": "2.12.6", "html-entities": "2.5.2", "keyv": "4.5.4", - "kysely": "0.27.4", + "kysely": "0.27.5", "lodash": "4.17.21", "luxon": "3.5.0", "markdown-to-txt": "2.0.1", @@ -74,7 +74,7 @@ "@types/turndown": "5.0.5", "concurrently": "^8.2.2", "jest": "29.7.0", - "kysely-codegen": "^0.16.4", + "kysely-codegen": "0.17.0", "nock": "14.0.0-beta.11", "nodemon": "3.1.7", "supertest": "7.0.0", diff --git a/servers/shareable-lists-api/package.json b/servers/shareable-lists-api/package.json index c379c84e0..c7d68db06 100644 --- a/servers/shareable-lists-api/package.json +++ b/servers/shareable-lists-api/package.json @@ -52,7 +52,7 @@ "graphql-constraint-directive": "5.4.2", "graphql-tag": "2.12.6", "keyv": "4.5.4", - "kysely": "0.27.4", + "kysely": "0.27.5", "mysql2": "3.11.3", "prisma": "5.21.1", "prisma-kysely": "1.8.0", diff --git a/servers/v3-proxy-api/src/generated/graphql/types.ts b/servers/v3-proxy-api/src/generated/graphql/types.ts index ca093cd5e..2abcbb880 100644 --- a/servers/v3-proxy-api/src/generated/graphql/types.ts +++ b/servers/v3-proxy-api/src/generated/graphql/types.ts @@ -24,6 +24,7 @@ export type Scalars = { Markdown: { input: any; output: any; } Max300CharString: { input: any; output: any; } NonNegativeInt: { input: any; output: any; } + ProseMirrorJson: { input: any; output: any; } Timestamp: { input: any; output: any; } Url: { input: any; output: any; } ValidUrl: { input: any; output: any; } @@ -64,6 +65,20 @@ export enum ApprovedItemGrade { C = 'C' } +export type ArchiveNoteInput = { + /** + * The ID of the note to archive or unarchive + * (depends on mutation). + */ + id: Scalars['ID']['input']; + /** + * When the note was archived or unarchived. + * If not provided, defaults to the server time upon + * receiving request. + */ + updatedAt?: InputMaybe; +}; + export type ArticleMarkdown = { __typename?: 'ArticleMarkdown'; images?: Maybe>; @@ -617,6 +632,59 @@ export type CreateHighlightInput = { version: Scalars['Int']['input']; }; +/** + * 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['ProseMirrorJson']['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 = { + /** + * When this note was created. If not provided, defaults to server time upon + * receiving request. + */ + createdAt?: InputMaybe; + /** JSON representation of a ProseMirror document */ + docContent: Scalars['ProseMirrorJson']['input']; + /** + * Client-provided UUID for the new Note. + * If not provided, will be generated on the server. + */ + id?: InputMaybe; + /** Optional URL to link this Note to. */ + source?: InputMaybe; + /** Optional title for this Note */ + title?: InputMaybe; +}; + /** Input data for creating a Shareable List. */ export type CreateShareableListInput = { description?: InputMaybe; @@ -680,6 +748,16 @@ export type DateFilter = { before?: InputMaybe; }; +export type DeleteNoteInput = { + /** + * When the note was deleted was made. If not provided, defaults to + * the server time upon receiving request. + */ + deletedAt?: InputMaybe; + /** The ID of the note to delete */ + id: Scalars['ID']['input']; +}; + export type DeleteSavedItemTagsInput = { /** The id of the SavedItem from which to delete a Tag association */ savedItemId: Scalars['ID']['input']; @@ -698,6 +776,28 @@ export type DomainMetadata = { name?: Maybe; }; +/** Input for editing the content of a Note (user-generated) */ +export type EditNoteContentInput = { + /** JSON representation of a ProseMirror document */ + docContent: Scalars['ProseMirrorJson']['input']; + /** The ID of the note to edit */ + noteId: Scalars['ID']['input']; + /** The time this update was made (defaults to server time) */ + updatedAt?: InputMaybe; +}; + +export type EditNoteTitleInput = { + /** The ID of the note to edit */ + id: Scalars['ID']['input']; + /** The new title for the note (can be an empty string) */ + title: Scalars['String']['input']; + /** + * When the update was made. If not provided, defaults to the server + * time upon receiving request. + */ + updatedAt?: InputMaybe; +}; + /** The reason a user web session is being expired. */ export enum ExpireUserWebSessionReason { /** Expire web session upon logging out. */ @@ -1184,6 +1284,13 @@ export type Mutation = { addShareContext?: Maybe; /** Add a batch of items to an existing shareable list. */ addToShareableList: ShareableList; + /** + * Archive a note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + archiveNote?: Maybe; /** * Make requests to create and delete highlights in a single batch. * Mutation is atomic -- if there is a response, all operations were successful. @@ -1195,6 +1302,13 @@ export type Mutation = { createAndAddToShareableList?: Maybe; /** Create new highlight annotation(s). Returns the data for the created Highlight object. */ createHighlightByUrl: Highlight; + /** 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; /** Create new highlight note. Returns the data for the created Highlight note. */ createSavedItemHighlightNote?: Maybe; /** Create new highlight annotation(s). Returns the data for the created Highlight object(s). */ @@ -1217,6 +1331,12 @@ export type Mutation = { createShareableList?: Maybe; /** Creates a Shareable List Item. */ createShareableListItem?: Maybe; + /** + * Delete a note and all attachments. Returns True if the note was successfully + * deleted. If the note cannot be deleted or does not exist, returns False. + * Errors will be included in the errors array if applicable. + */ + deleteNote: Scalars['ID']['output']; /** * Deletes a SavedItem from the users list. Returns ID of the * deleted SavedItem @@ -1253,6 +1373,20 @@ export type Mutation = { * Returns firefox account ID sent as the query parameter with the request. */ deleteUserByFxaId: Scalars['ID']['output']; + /** + * Edit the content of a Note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + editNoteContent?: Maybe; + /** + * Edit the title of a Note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + editNoteTitle?: Maybe; /** * Expires a user's web session tokens by firefox account ID. * Called by fxa-webhook proxy. Need to supply a reason why to expire user web session. @@ -1359,6 +1493,13 @@ export type Mutation = { * In the future this will be more permissive. */ savedItemUpdateTitle?: Maybe; + /** + * Unarchive a note. + * If the Note does not exist or is inaccessible for the current user, + * response will be null and a NOT_FOUND error will be included in the + * errors array. + */ + unArchiveNote?: Maybe; /** * Update an existing highlight annotation, by its ID. * If the given highlight ID does not exist, will return error data @@ -1461,6 +1602,12 @@ export type MutationAddToShareableListArgs = { }; +/** Default Mutation Type */ +export type MutationArchiveNoteArgs = { + input: ArchiveNoteInput; +}; + + /** Default Mutation Type */ export type MutationBatchWriteHighlightsArgs = { input?: InputMaybe; @@ -1487,6 +1634,18 @@ export type MutationCreateHighlightByUrlArgs = { }; +/** Default Mutation Type */ +export type MutationCreateNoteArgs = { + input: CreateNoteInput; +}; + + +/** Default Mutation Type */ +export type MutationCreateNoteFromQuoteArgs = { + input: CreateNoteFromQuoteInput; +}; + + /** Default Mutation Type */ export type MutationCreateSavedItemHighlightNoteArgs = { id: Scalars['ID']['input']; @@ -1527,6 +1686,12 @@ export type MutationCreateShareableListItemArgs = { }; +/** Default Mutation Type */ +export type MutationDeleteNoteArgs = { + input: DeleteNoteInput; +}; + + /** Default Mutation Type */ export type MutationDeleteSavedItemArgs = { id: Scalars['ID']['input']; @@ -1583,6 +1748,18 @@ export type MutationDeleteUserByFxaIdArgs = { }; +/** Default Mutation Type */ +export type MutationEditNoteContentArgs = { + input: EditNoteContentInput; +}; + + +/** Default Mutation Type */ +export type MutationEditNoteTitleArgs = { + input: EditNoteTitleInput; +}; + + /** Default Mutation Type */ export type MutationExpireUserWebSessionByFxaIdArgs = { id: Scalars['ID']['input']; @@ -1752,6 +1929,12 @@ export type MutationSavedItemUpdateTitleArgs = { }; +/** Default Mutation Type */ +export type MutationUnArchiveNoteArgs = { + input: ArchiveNoteInput; +}; + + /** Default Mutation Type */ export type MutationUpdateHighlightArgs = { id: Scalars['ID']['input']; @@ -1885,6 +2068,112 @@ export type NotFound = BaseError & { value?: Maybe; }; +/** + * A Note is an entity which may contain extracted components + * from websites (clippings/snippets), user-generated rich text content, + * and may be linked to a source url. + */ +export type Note = { + __typename?: 'Note'; + /** Whether this Note has been marked as archived (hide from default view). */ + archived: Scalars['Boolean']['output']; + /** Markdown preview of the note content for summary view. */ + contentPreview?: Maybe; + /** When this note was created */ + createdAt: Scalars['ISOString']['output']; + /** + * Whether this Note has been marked for deletion (will be eventually + * removed from the server). Clients should delete Notes from their local + * storage if this value is true. + */ + deleted: Scalars['Boolean']['output']; + /** + * JSON representation of a ProseMirror document + * (compatible with Markdown) + */ + docContent?: Maybe; + /** Markdown representation of the note content */ + docMarkdown?: Maybe; + /** This Note's identifier */ + id: Scalars['ID']['output']; + /** + * The SavedItem entity this note is attached to (either directly + * or via a Clipping, if applicable) + */ + savedItem?: Maybe; + /** + * The URL this entity was created from (either directly or via + * a Clipping, if applicable). + */ + source?: Maybe; + /** Title of this note */ + title?: Maybe; + /** When this note was last updated */ + updatedAt: Scalars['ISOString']['output']; +}; + +/** The connection type for Note. */ +export type NoteConnection = { + __typename?: 'NoteConnection'; + /** A list of edges. */ + edges?: Maybe>>; + /** Information to aid in pagination. */ + pageInfo: PageInfo; + /** Identifies the total count of Notes in the connection. */ + totalCount: Scalars['Int']['output']; +}; + +/** An edge in a connection. */ +export type NoteEdge = { + __typename?: 'NoteEdge'; + /** A cursor for use in pagination. */ + cursor: Scalars['String']['output']; + /** The Note at the end of the edge. */ + node?: Maybe; +}; + +/** Filter for retrieving Notes */ +export type NoteFilterInput = { + /** + * Filter to retrieve Notes by archived status (true/false). + * If not provided, notes will not be filtered by archived status. + */ + archived?: InputMaybe; + /** + * Filter to choose whether to include notes marked for server-side + * deletion in the response (defaults to false). + */ + excludeDeleted?: InputMaybe; + /** + * Filter to show notes which are attached to a source URL + * directly or via clipping, or are standalone + * notes. If not provided, notes will not be filtered by source url. + */ + isAttachedToSave?: InputMaybe; + /** Filter to retrieve notes after a timestamp, e.g. for syncing. */ + since?: InputMaybe; +}; + +/** Enum to specify the sort by field (these are the current options, we could add more in the future) */ +export enum NoteSortBy { + CreatedAt = 'CREATED_AT', + UpdatedAt = 'UPDATED_AT' +} + +/** Input to sort fetched Notes. If unspecified, defaults to UPDATED_AT, DESC. */ +export type NoteSortInput = { + /** The field by which to sort Notes */ + sortBy: NoteSortBy; + /** The order in which to sort Notes */ + sortOrder: NoteSortOrder; +}; + +/** Possible values for sort ordering (ascending/descending) */ +export enum NoteSortOrder { + Asc = 'ASC', + Desc = 'DESC' +} + export type NumberedListElement = ListElement & { __typename?: 'NumberedListElement'; /** Row in a list */ @@ -2221,6 +2510,10 @@ export type Query = { listTopics: Array; /** Get a slate of ranked recommendations for the Firefox New Tab. Currently supports the Italy, France, and Spain markets. */ newTabSlate: CorpusSlate; + /** Retrieve a specific Note */ + note?: Maybe; + /** Retrieve a user's Notes */ + notes?: Maybe; /** * Resolve Reader View links which might point to SavedItems that do not * exist, aren't in the Pocket User's list, or are requested by a logged-out @@ -2384,6 +2677,26 @@ export type QueryNewTabSlateArgs = { }; +/** + * Default root level query type. All authorization checks are done in these queries. + * TODO: These belong in a seperate User Service that provides a User object (the user settings will probably exist there too) + */ +export type QueryNoteArgs = { + id: Scalars['ID']['input']; +}; + + +/** + * Default root level query type. All authorization checks are done in these queries. + * TODO: These belong in a seperate User Service that provides a User object (the user settings will probably exist there too) + */ +export type QueryNotesArgs = { + filter?: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe; +}; + + /** * Default root level query type. All authorization checks are done in these queries. * TODO: These belong in a seperate User Service that provides a User object (the user settings will probably exist there too) @@ -2686,6 +2999,11 @@ export type SavedItem = RemoteEntity & { isFavorite: Scalars['Boolean']['output']; /** Link to the underlying Pocket Item for the URL */ item: ItemResult; + /** + * The notes associated with this SavedItem, optionally + * filtered to retrieve after a 'since' parameter. + */ + notes: NoteConnection; /** The status of this SavedItem */ status?: Maybe; /** The Suggested Tags associated with this SavedItem, if the user is not premium or there are none, this will be empty. */ @@ -2698,6 +3016,17 @@ export type SavedItem = RemoteEntity & { url: Scalars['String']['output']; }; + +/** + * Represents a Pocket Item that a user has saved to their list. + * (Said otherways, indicates a saved url to a users list and associated user specific information.) + */ +export type SavedItemNotesArgs = { + filter?: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe; +}; + /** * Container for all annotations associated to a SavedItem. * Can be extended when more types of annotations are added. @@ -2739,6 +3068,22 @@ export type SavedItemEdge = { /** All types in this union should implement BaseError, for client fallback */ export type SavedItemMutationError = NotFound | SyncConflict; +/** Filter for retrieving Notes attached to a SavedItem */ +export type SavedItemNoteFilterInput = { + /** + * Filter to retrieve Notes by archived status (true/false). + * If not provided, notes will not be filtered by archived status. + */ + archived?: InputMaybe; + /** + * Filter to choose whether to include notes marked for server-side + * deletion in the response (defaults to false). + */ + excludeDeleted?: InputMaybe; + /** Filter to retrieve notes after a timestamp, e.g. for syncing. */ + since?: InputMaybe; +}; + /** * We don't have official oneOf support, but this will * throw if both `id` and `url` are unset/null.