Skip to content

Commit

Permalink
Merge pull request #4157 from thematters/develop
Browse files Browse the repository at this point in the history
Release: v5.5.1
  • Loading branch information
gary02 committed Aug 29, 2024
2 parents 454222c + 1121ac2 commit 75bfd03
Show file tree
Hide file tree
Showing 38 changed files with 938 additions and 423 deletions.
14 changes: 14 additions & 0 deletions db/migrations/20240814222347_alter_draft_add_indent_first_line.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const table = 'draft'
const newColumn = 'indent_first_line'

exports.up = async (knex) => {
await knex.schema.alterTable(table, (t) => {
t.boolean(newColumn).notNullable().defaultTo(false)
})
}

exports.down = async (knex) => {
await knex.schema.alterTable(table, (t) => {
t.dropColumn(newColumn)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const table = 'article_version'
const newColumn = 'indent_first_line'
const view = 'article_version_newest'

exports.up = async (knex) => {
await knex.schema.alterTable(table, (t) => {
t.boolean(newColumn).notNullable().defaultTo(false)
})
await knex.raw(/*sql*/ `
CREATE OR REPLACE VIEW ${view} AS
SELECT a.*
FROM article_version a
LEFT OUTER JOIN article_version b
ON a.article_id= b.article_id AND a.id < b.id
WHERE b.id IS NULL;
`)
}

exports.down = async (knex) => {
await knex.schema.alterTable(table, (t) => {
t.dropColumn(newColumn)
})
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "matters-server",
"version": "5.5.0",
"version": "5.5.1",
"description": "Matters Server",
"author": "Matters <hi@matters.news>",
"main": "build/index.js",
Expand Down
14 changes: 13 additions & 1 deletion schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,9 @@ type Article implements Node & PinnableWork {
"""whether readers can comment"""
canComment: Boolean!

"""whether the first line of paragraph should be indented"""
indentFirstLine: Boolean!

"""history versions"""
versions(input: ArticleVersionsInput!): ArticleVersionsConnection!

Expand Down Expand Up @@ -702,6 +705,7 @@ input EditArticleInput {
accessType: ArticleAccessType
sensitive: Boolean
license: ArticleLicenseType
indentFirstLine: Boolean
requestForDonation: String
replyToDonator: String

Expand Down Expand Up @@ -1708,6 +1712,9 @@ type Draft implements Node {
"""whether readers can comment"""
canComment: Boolean!

"""whether the first line of paragraph should be indented"""
indentFirstLine: Boolean!

"""associated campaigns"""
campaigns: [ArticleCampaign!]!
}
Expand Down Expand Up @@ -1740,6 +1747,7 @@ input PutDraftInput {
accessType: ArticleAccessType
sensitive: Boolean
license: ArticleLicenseType
indentFirstLine: Boolean
requestForDonation: String
replyToDonator: String

Expand Down Expand Up @@ -3786,7 +3794,11 @@ type CollectionNotice implements Notice {
input CollectionArticlesInput {
after: String
first: Int
reversed: Boolean
before: String
last: Int
includeAfter: Boolean! = false
includeBefore: Boolean! = false
reversed: Boolean! = true
}

input PutCollectionInput {
Expand Down
1 change: 1 addition & 0 deletions src/common/enums/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const GRAPHQL_INPUT_LENGTH_LIMIT = 100
export const BCRYPT_ROUNDS = 12

export const DEFAULT_TAKE_PER_PAGE = 10
export const MAX_TAKE_PER_PAGE = 500

export const LOCAL_S3_ENDPOINT = 'http://localhost:4569'

Expand Down
12 changes: 11 additions & 1 deletion src/common/utils/__test__/mention.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { extractMentionIds } from 'common/utils'
import { extractMentionIds, stripMentions } from 'common/utils'

test('normalizeQueryInput', async () => {
const content = `<p><a class="mention" href="/@mengxiaobai" data-id="VXNlcjozNzA" data-user-name="mengxiaobai" data-display-name="志澤週" ref="noopener noreferrer nofollow"><span>@志澤週</span></a> test</p>`
expect(extractMentionIds(content)).toEqual(['370'])
})

test('strip mentions strips mention', () => {
const content = `<p><a class="mention"><span>@Somebody</span></a> test</p>`
expect(stripMentions(content)).toBe(`<p> test</p>`)
})

test('strip mentions strips multiple mentions', () => {
const content = `<p><a class="mention"><span>@foo</span></a><a class="mention"><span>@bar</span></a> test</p>`
expect(stripMentions(content)).toBe(`<p> test</p>`)
})
165 changes: 154 additions & 11 deletions src/common/utils/connections.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { connectionFromArraySlice } from 'graphql-relay'
import { Base64 } from 'js-base64'
import { Knex } from 'knex'

import { DEFAULT_TAKE_PER_PAGE } from 'common/enums'
import { DEFAULT_TAKE_PER_PAGE, MAX_TAKE_PER_PAGE } from 'common/enums'
import { UserInputError } from 'common/errors'
import { selectWithTotalCount, selectWithRowNumber } from 'common/utils'

export type ConnectionCursor = string

export interface ConnectionArguments {
before?: ConnectionCursor
after?: ConnectionCursor
first?: number
// last?: number
first?: number | null
last?: number | null
includeBefore?: boolean
includeAfter?: boolean
}

export interface Connection<T> {
Expand Down Expand Up @@ -113,20 +118,14 @@ export const cursorToKeys = (
return { offset: parseInt(keys[1], 10), idCursor: keys[2] }
}

/**
* Convert query keys to GQL cursor. For example, the query keys
* `arrayconnection:10:39` will be converted to `YXJyYXljb25uZWN0aW9uOjEwOjM5`.
*
*/
const keysToCursor = (offset: number, idCursor: string): ConnectionCursor =>
Base64.encodeURI(`${PREFIX}:${offset}:${idCursor}`)

/**
* Construct a GQL connection using query keys mechanism. Query keys are
* composed of `offset` and `idCursor`.
* `offset` is for managing connection like `merge`,
* and `idCursor` is for SQL querying.
* (for detail explain see https://github.com/thematters/matters-server/pull/922#discussion_r409256544)
*
* @deprecated use `connectionFromQuery` instead
*/
export const connectionFromArrayWithKeys = <
T extends { id: string; __cursor?: string }
Expand All @@ -138,7 +137,13 @@ export const connectionFromArrayWithKeys = <
const { after } = args
const keys = cursorToKeys(after)

// Convert query keys to GQL cursor. For example, the query keys
// `arrayconnection:10:39` will be converted to `YXJyYXljb25uZWN0aW9uOjEwOjM5`.
const keysToCursor = (offset: number, idCursor: string): ConnectionCursor =>
Base64.encodeURI(`${PREFIX}:${offset}:${idCursor}`)

const edges = data.map((value, index) => ({
// TOFIX: offset calculation should consider `includeBefore` and `includeAfter`
cursor: keysToCursor(index + keys.offset + 1, value.__cursor || value.id),
node: value,
}))
Expand All @@ -160,6 +165,144 @@ export const connectionFromArrayWithKeys = <
}
}

/**
* Construct a GQL connection from knex query using cursor based pagination.
*/
export const connectionFromQuery = async <T extends { id: string }>({
query,
args,
orderBy,
cursorColumn,
}: {
query: Knex.QueryBuilder<T>
orderBy: { column: keyof T; order: 'asc' | 'desc' }

cursorColumn: keyof T
args: ConnectionArguments
}): Promise<Connection<T>> => {
const { after, before, includeBefore, includeAfter } = args

if (after && before) {
throw new UserInputError(
'Cannot use both `after` and `before` at the same time.'
)
}

const first =
args.first === null
? MAX_TAKE_PER_PAGE
: args.first ?? DEFAULT_TAKE_PER_PAGE
const last =
args.last === null ? MAX_TAKE_PER_PAGE : args.last ?? DEFAULT_TAKE_PER_PAGE

const knex = query.client.queryBuilder()
const baseTableName = 'connection_base'
knex.with(
baseTableName,
query.client
.queryBuilder()
.from(query.as('base'))
.select('*')
.orderBy(orderBy.column as string, orderBy.order)
.modify(selectWithTotalCount)
.modify(selectWithRowNumber, orderBy)
)
const decodeCursor = (cursor: ConnectionCursor) =>
Base64.decode(cursor).split(':')[1]
const encodeCursor = (value: string): ConnectionCursor =>
Base64.encodeURI(`${PREFIX}:${value}`)
const getOrderCursor = (cursor: string) => {
const value = decodeCursor(cursor)
return orderBy.column === cursorColumn
? cursor
: knex.client.raw('(SELECT ?? FROM ?? WHERE ?? = ?)', [
orderBy.column,
baseTableName,
cursorColumn,
value,
])
}

// fetch before edges
let beforeWhereOperator = orderBy.order === 'asc' ? '<' : '>'
if (includeBefore) {
beforeWhereOperator += '='
}
const beforeNodes: Array<T & { totalCount: number; rowNumber: number }> =
before
? await knex
.clone()
.from(baseTableName)
.orderBy(
orderBy.column as string,
orderBy.order === 'asc' ? 'desc' : 'asc' // for fetching records right after cursor by `limit`, will reverse back later
)
.where(
orderBy.column as string,
beforeWhereOperator,
getOrderCursor(before)
)
.limit(last)
: []

const beforeEdges = beforeNodes
.map((node) => ({
cursor: encodeCursor(node[cursorColumn] as string),
node,
}))
.reverse()

// fetch after edges
let afterWhereOperator = orderBy.order === 'asc' ? '>' : '<'
if (includeAfter) {
afterWhereOperator += '='
}
const afterNodes: Array<T & { totalCount: number; rowNumber: number }> = after
? await knex
.clone()
.from(baseTableName)
.where(
orderBy.column as string,
afterWhereOperator,
getOrderCursor(after)
)
.limit(first)
: before
? []
: await knex.clone().from(baseTableName).limit(first)

const afterEdges = afterNodes.map((node) => ({
cursor: encodeCursor(node[cursorColumn] as string),
node,
}))

const edges = before ? beforeEdges : afterEdges

const firstEdge = edges[0]
const lastEdge = edges[edges.length - 1]

const totalCount = firstEdge
? firstEdge.node.totalCount
: await knex
.clone()
.from(baseTableName)
.count('*')
.first()
.then((result) => result?.count || 0)

return {
edges,
totalCount,
pageInfo: {
startCursor: firstEdge ? firstEdge.cursor : null,
endCursor: lastEdge ? lastEdge.cursor : null,
hasPreviousPage: firstEdge && firstEdge.node.rowNumber > 1 ? true : false,
hasNextPage:
lastEdge && lastEdge.node.rowNumber < totalCount ? true : false,
},
}
}

export const fromConnectionArgs = (
input: { first?: number | null; after?: string },
options?: {
Expand Down
13 changes: 12 additions & 1 deletion src/common/utils/knex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,16 @@ export const excludeSpam = (
}
}

export const selectWithTotalCount = async (builder: Knex.QueryBuilder) =>
export const selectWithTotalCount = (builder: Knex.QueryBuilder) =>
builder.select(builder.client.raw('count(1) OVER() ::integer AS total_count'))

export const selectWithRowNumber = (
builder: Knex.QueryBuilder,
orderBy: { column: string; order: 'asc' | 'desc' }
) =>
builder.select(
builder.client.raw(
`row_number() OVER(ORDER BY ?? ${orderBy.order}) ::integer AS row_number`,
[orderBy.column]
)
)
6 changes: 6 additions & 0 deletions src/common/utils/mention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ export const extractMentionIds = (content: string): string[] => {
return id
})
}

export const stripMentions = (content: string): string => {
const $ = cheerio.load(content, null, false)
$('a.mention').remove()
return $.html()
}
Loading

0 comments on commit 75bfd03

Please sign in to comment.