Merge pull request #2944 from omnivore-app/main

Web production deployment
This commit is contained in:
Jackson Harper 2023-10-16 18:28:23 +08:00 committed by GitHub
commit 3be7709d0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 546 additions and 212 deletions

View file

@ -17,8 +17,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 33
versionCode 102
versionName "0.0.102"
versionCode 110
versionName "0.0.110"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View file

@ -57,6 +57,8 @@ fragment HighlightFields on Highlight {
updatedAt
sharedAt
color
highlightPositionPercent
highlightPositionAnchorIndex
}
fragment LabelFields on Label {

View file

@ -1,12 +1,10 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItemAndHighlightCrossRef
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -29,6 +27,8 @@ suspend fun DataService.createWebHighlight(jsonString: String, colorName: String
updatedAt = null,
createdByMe = false,
color = colorName ?: createHighlightInput.color.getOrNull(),
highlightPositionPercent = createHighlightInput.highlightPositionPercent.getOrNull() ?: 0.0,
highlightPositionAnchorIndex = createHighlightInput.highlightPositionAnchorIndex.getOrNull() ?: 0
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
@ -66,7 +66,9 @@ suspend fun DataService.createNoteHighlight(savedItemId: String, note: String):
createdAt = null,
updatedAt = null,
createdByMe = true,
color = null
color = null,
highlightPositionAnchorIndex = 0,
highlightPositionPercent = 0.0
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
@ -87,6 +89,8 @@ suspend fun DataService.createNoteHighlight(savedItemId: String, note: String):
quote = null,
patch = null,
annotation = note,
highlightPositionAnchorIndex = 0,
highlightPositionPercent = 0.0
).asCreateHighlightInput())
newHighlight?.let {

View file

@ -1,7 +1,6 @@
package app.omnivore.omnivore.dataService
import android.util.Log
import androidx.room.PrimaryKey
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.*
@ -85,7 +84,9 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S
suffix = highlight.highlightFields.suffix,
createdAt = null,
updatedAt = highlight.highlightFields.updatedAt as String?,
color = highlight.highlightFields.color
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex,
)
} ?: listOf()
SavedItemWithLabelsAndHighlights(

View file

@ -20,7 +20,9 @@ data class CreateHighlightParams(
val quote: String?,
val patch: String?,
val articleId: String?,
val `annotation`: String?
val `annotation`: String?,
val highlightPositionAnchorIndex: Int,
val highlightPositionPercent: Double
) {
fun asCreateHighlightInput() = CreateHighlightInput(
type = Optional.presentIfNotNull(type),
@ -29,7 +31,9 @@ data class CreateHighlightParams(
id = id ?: "",
patch = Optional.presentIfNotNull(patch),
quote = Optional.presentIfNotNull(quote),
shortId = shortId ?: ""
shortId = shortId ?: "",
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlightPositionPercent)
)
}
@ -151,8 +155,10 @@ suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
createdAt = createdHighlight.highlightFields.createdAt.toString(),
updatedAt = createdHighlight.highlightFields.updatedAt.toString(),
createdByMe = createdHighlight.highlightFields.createdByMe,
color = createdHighlight.highlightFields.color
)
color = createdHighlight.highlightFields.color,
highlightPositionPercent = createdHighlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = createdHighlight.highlightFields.highlightPositionAnchorIndex
)
} else {
return null
}

View file

@ -54,7 +54,9 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
createdAt = it.highlightFields.createdAt as String?,
updatedAt = it.highlightFields.updatedAt as String?,
createdByMe = it.highlightFields.createdByMe,
color = it.highlightFields.color
color = it.highlightFields.color,
highlightPositionPercent = it.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = it.highlightFields.highlightPositionAnchorIndex
)
}

View file

@ -1,8 +1,6 @@
package app.omnivore.omnivore.networking
import androidx.room.PrimaryKey
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.persistence.entities.*
import com.apollographql.apollo3.api.Optional
@ -82,7 +80,9 @@ suspend fun Networker.search(
suffix = highlight.highlightFields.suffix,
updatedAt = highlight.highlightFields.updatedAt as String?,
createdAt = highlight.highlightFields.createdAt as String?,
color = highlight.highlightFields.color
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex
)
}
)

View file

@ -13,7 +13,7 @@ import app.omnivore.omnivore.persistence.entities.*
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class
],
version = 9
version = 11
)
abstract class AppDatabase : RoomDatabase() {
abstract fun viewerDao(): ViewerDao

View file

@ -1,6 +1,5 @@
package app.omnivore.omnivore.persistence.entities
import androidx.lifecycle.LiveData
import androidx.room.*
import app.omnivore.omnivore.models.ServerSyncStatus
import com.google.gson.annotations.SerializedName
@ -22,7 +21,9 @@ data class Highlight(
var shortId: String,
val suffix: String?,
val updatedAt: String?,
val color: String?
val color: String?,
val highlightPositionPercent: Double?,
val highlightPositionAnchorIndex: Int?
)
@Entity(

View file

@ -152,18 +152,18 @@ fun WebReader(
webReaderViewModel.resetJavascriptDispatchQueue()
}
})
if (showHighlightColorPalette.value == true) {
HighlightColorPalette(
mode = if (isDarkMode) HighlightColorPaletteMode.Dark else HighlightColorPaletteMode.Light,
selectedColorName = highlightColor.value?.name ?: "yellow",
onColorSelected = {
webReaderViewModel.setHighlightColor(it)
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(12.dp, 12.dp, 12.dp, 36.dp)
)
}
// if (showHighlightColorPalette.value == true) {
// HighlightColorPalette(
// mode = if (isDarkMode) HighlightColorPaletteMode.Dark else HighlightColorPaletteMode.Light,
// selectedColorName = highlightColor.value?.name ?: "yellow",
// onColorSelected = {
// webReaderViewModel.setHighlightColor(it)
// },
// modifier = Modifier
// .align(Alignment.BottomCenter)
// .padding(12.dp, 12.dp, 12.dp, 36.dp)
// )
// }
}
}

View file

@ -59,11 +59,11 @@ export class Highlight {
@Column('timestamp')
sharedAt?: Date
@Column('real')
highlightPositionPercent?: number | null
@Column('real', { default: 0 })
highlightPositionPercent!: number
@Column('integer')
highlightPositionAnchorIndex?: number | null
@Column('integer', { default: 0 })
highlightPositionAnchorIndex!: number
@Column('enum', {
enum: HighlightType,

View file

@ -5,7 +5,6 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { Readability } from '@omnivore/readability'
import graphqlFields from 'graphql-fields'
import { Not } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
@ -54,6 +53,7 @@ import { getInternalLabelWithColor } from '../../repository/label'
import { libraryItemRepository } from '../../repository/library_item'
import { userRepository } from '../../repository/user'
import { createPageSaveRequest } from '../../services/create_page_save_request'
import { findHighlightsByLibraryItemId } from '../../services/highlights'
import {
addLabelsToLibraryItem,
findLabelsByIds,
@ -632,7 +632,7 @@ export const searchResolver = authorized<
SearchSuccess,
SearchError,
QuerySearchArgs
>(async (_obj, params, { uid, log }) => {
>(async (_obj, params, { log, uid }) => {
const startCursor = params.after || ''
const first = params.first || 10
@ -665,6 +665,21 @@ export const searchResolver = authorized<
libraryItems.pop()
}
await Promise.all(
libraryItems.map(async (libraryItem) => {
if (
libraryItem.highlightAnnotations &&
libraryItem.highlightAnnotations.length > 0
) {
// fetch highlights for each item
libraryItem.highlights = await findHighlightsByLibraryItemId(
libraryItem.id,
uid
)
}
})
)
const edges = libraryItems.map((libraryItem) => {
if (libraryItem.siteIcon && !isBase64Image(libraryItem.siteIcon)) {
libraryItem.siteIcon = createImageProxyUrl(libraryItem.siteIcon, 128, 128)
@ -792,6 +807,12 @@ export const bulkActionResolver = authorized<
},
})
// parse query
const searchQuery = parseSearchQuery(query)
if (searchQuery.ids.length > 100) {
return { errorCodes: [BulkActionErrorCode.BadRequest] }
}
// get labels if needed
let labels = undefined
if (action === BulkActionType.AddLabels) {
@ -802,10 +823,7 @@ export const bulkActionResolver = authorized<
labels = await findLabelsByIds(labelIds, uid)
}
// parse query
const searchQuery = parseSearchQuery(query)
await updateLibraryItems(action, searchQuery, labels)
await updateLibraryItems(action, searchQuery, uid, labels)
return { success: true }
} catch (error) {

View file

@ -6,18 +6,15 @@
import { Subscription } from '../entity/subscription'
import {
Article,
Highlight,
Label,
PageType,
Recommendation,
SearchItem,
} from '../generated/graphql'
import { findHighlightsByLibraryItemId } from '../services/highlights'
import { findLabelsByLibraryItemId } from '../services/labels'
import { findRecommendationsByLibraryItemId } from '../services/recommendation'
import { findUploadFileById } from '../services/upload_file'
import {
highlightDataToHighlight,
recommandationDataToRecommendation,
validatedDate,
wordsCount,
@ -486,16 +483,6 @@ export const functionResolvers = {
if (item.wordCount) return item.wordCount
return item.content ? wordsCount(item.content) : undefined
},
async highlights(
item: { id: string; highlights?: Highlight[] },
_: unknown,
ctx: WithDataSourcesContext
) {
if (item.highlights) return item.highlights
const highlights = await findHighlightsByLibraryItemId(item.id, ctx.uid)
return highlights.map(highlightDataToHighlight)
},
async labels(
item: { id: string; labels?: Label[] },
_: unknown,

View file

@ -47,7 +47,9 @@ export const createHighlightResolver = authorized<
...input,
user: { id: uid },
libraryItem: { id: input.articleId },
highlightType: input.type as HighlightType,
highlightType: input.type || HighlightType.Highlight,
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex || 0,
highlightPositionPercent: input.highlightPositionPercent || 0,
},
input.articleId,
uid,
@ -125,6 +127,8 @@ export const mergeHighlightResolver = authorized<
color,
user: { id: uid },
libraryItem: { id: input.articleId },
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex || 0,
highlightPositionPercent: input.highlightPositionPercent || 0,
}
const newHighlight = await mergeHighlights(

View file

@ -16,7 +16,7 @@ import {
generateUploadSignedUrl,
} from '../../utils/uploads'
const MAX_DAILY_UPLOADS = 4
const MAX_DAILY_UPLOADS = 1
const VALID_CONTENT_TYPES = ['text/csv', 'application/zip']
const extensionForContentType = (contentType: string) => {
@ -61,7 +61,7 @@ export const uploadImportFileResolver = authorized<
const dirPath = `imports/${uid}/${dateStr}/`
const fileCount = await countOfFilesWithPrefix(dirPath)
if (fileCount > MAX_DAILY_UPLOADS) {
if (fileCount >= MAX_DAILY_UPLOADS) {
return {
errorCodes: [UploadImportFileErrorCode.UploadDailyLimitExceeded],
}

View file

@ -54,7 +54,7 @@ export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
user: { id: uid },
},
order: {
position: 'ASC',
name: 'ASC',
},
})
})

View file

@ -1,6 +1,10 @@
import express from 'express'
import { DeepPartial } from 'typeorm'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import {
ContentReaderType,
LibraryItem,
LibraryItemState,
} from '../../entity/library_item'
import { UploadFile } from '../../entity/upload_file'
import { env } from '../../env'
import { PageType, UploadFileStatus } from '../../generated/graphql'
@ -61,10 +65,10 @@ export function emailAttachmentRouter() {
(tx) =>
tx.getRepository(UploadFile).save({
url: '',
userId: user.id,
fileName: fileName,
fileName,
status: UploadFileStatus.Initialized,
contentType: contentType,
contentType,
user: { id: user.id },
}),
undefined,
user.id
@ -150,7 +154,7 @@ export function emailAttachmentRouter() {
? PageType.File
: PageType.Book
const title = subject || uploadFileData.fileName
const articleToSave: DeepPartial<LibraryItem> = {
const itemToCreate: DeepPartial<LibraryItem> = {
originalUrl: uploadFileUrlOverride,
itemType,
textContentHash: uploadFileHash,
@ -159,14 +163,19 @@ export function emailAttachmentRouter() {
readableContent: '',
slug: generateSlug(title),
state: LibraryItemState.Succeeded,
user: { id: user.id },
contentReader:
itemType === PageType.File
? ContentReaderType.PDF
: ContentReaderType.EPUB,
}
const pageId = await createLibraryItem(articleToSave, user.id)
const item = await createLibraryItem(itemToCreate, user.id)
// update received email type
await updateReceivedEmail(receivedEmailId, 'article', user.id)
res.send({ id: pageId })
res.send({ id: item.id })
} catch (err) {
logger.info(err)
res.status(500).send(err)

View file

@ -1,13 +1,4 @@
import {
Between,
DeepPartial,
In,
IsNull,
LessThan,
MoreThan,
Not,
SelectQueryBuilder,
} from 'typeorm'
import { Brackets, DeepPartial, SelectQueryBuilder } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { EntityLabel } from '../entity/entity_label'
import { Highlight } from '../entity/highlight'
@ -115,14 +106,10 @@ const buildWhereClause = (
if (args.inFilter !== InFilter.ALL) {
switch (args.inFilter) {
case InFilter.INBOX:
queryBuilder.andWhere({
archivedAt: IsNull(),
})
queryBuilder.andWhere('library_item.archived_at IS NULL')
break
case InFilter.ARCHIVE:
queryBuilder.andWhere({
archivedAt: Not(IsNull()),
})
queryBuilder.andWhere('library_item.archived_at IS NOT NULL')
break
case InFilter.TRASH:
// return only deleted pages within 14 days
@ -133,19 +120,15 @@ const buildWhereClause = (
case InFilter.SUBSCRIPTION:
queryBuilder
.andWhere("NOT ('library' ILIKE ANY (library_item.label_names))")
.andWhere({
subscription: Not(IsNull()),
archivedAt: IsNull(),
})
.andWhere('library_item.archived_at IS NULL')
.andWhere('library_item.subscription IS NOT NULL')
break
case InFilter.LIBRARY:
queryBuilder
.andWhere(
"(library_item.subscription IS NULL OR 'library' ILIKE ANY (library_item.label_names))"
)
.andWhere({
archivedAt: IsNull(),
})
.andWhere('library_item.archived_at IS NULL')
break
}
}
@ -153,17 +136,19 @@ const buildWhereClause = (
if (args.readFilter !== ReadFilter.ALL) {
switch (args.readFilter) {
case ReadFilter.READ:
queryBuilder.andWhere({
readingProgressBottomPercent: MoreThan(98),
})
queryBuilder.andWhere(
'library_item.reading_progress_bottom_percent > 98'
)
break
case ReadFilter.READING:
queryBuilder.andWhere({ readingProgressBottomPercent: Between(2, 98) })
queryBuilder.andWhere(
'library_item.reading_progress_bottom_percent BETWEEN 2 AND 98'
)
break
case ReadFilter.UNREAD:
queryBuilder.andWhere({
readingProgressBottomPercent: LessThan(2),
})
queryBuilder.andWhere(
'library_item.reading_progress_bottom_percent < 2'
)
break
}
}
@ -238,29 +223,35 @@ const buildWhereClause = (
args.matchFilters.forEach((filter) => {
const param = `match_${filter.field}`
queryBuilder.andWhere(
`websearch_to_tsquery('english', :${param}) @@ library_item.${filter.field}_tsv`,
{
[param]: filter.value,
}
new Brackets((qb) => {
qb.andWhere(
`websearch_to_tsquery('english', :${param}) @@ library_item.${filter.field}_tsv`,
{
[param]: filter.value,
}
).orWhere(`${filter.field} ILIKE :value`, {
value: `%${filter.value}%`,
})
})
)
})
}
if (args.ids && args.ids.length > 0) {
queryBuilder.andWhere({
id: In(args.ids),
queryBuilder.andWhere('library_item.id = ANY(:ids)', {
ids: args.ids,
})
}
if (!args.includePending) {
queryBuilder.andWhere({
state: Not(LibraryItemState.Processing),
queryBuilder.andWhere('library_item.state <> :state', {
state: LibraryItemState.Processing,
})
}
if (!args.includeDeleted && args.inFilter !== InFilter.TRASH) {
queryBuilder.andWhere({
state: Not(LibraryItemState.Deleted),
queryBuilder.andWhere('library_item.state <> :state', {
state: LibraryItemState.Deleted,
})
}
@ -528,6 +519,7 @@ export const countByCreatedAt = async (
export const updateLibraryItems = async (
action: BulkActionType,
args: SearchArgs,
userId: string,
labels?: Label[]
) => {
// build the script
@ -561,7 +553,9 @@ export const updateLibraryItems = async (
}
await authTrx(async (tx) => {
const queryBuilder = tx.createQueryBuilder(LibraryItem, 'library_item')
const queryBuilder = tx
.createQueryBuilder(LibraryItem, 'library_item')
.where('library_item.user_id = :userId', { userId })
// build the where clause
buildWhereClause(queryBuilder, args)

View file

@ -332,18 +332,13 @@ export const parsePreparedContent = async (
DOMPurify.addHook('uponSanitizeElement', domPurifySanitizeHook)
const clean = DOMPurify.sanitize(article?.content || '', DOM_PURIFY_CONFIG)
const jsonLdLinkMetadata = (async () => {
return getJSONLdLinkMetadata(dom)
})()
Object.assign(article || {}, {
content: clean,
title: article?.title || (await jsonLdLinkMetadata).title,
previewImage:
article?.previewImage || (await jsonLdLinkMetadata).previewImage,
siteName: article?.siteName || (await jsonLdLinkMetadata).siteName,
title: article?.title,
previewImage: article?.previewImage,
siteName: article?.siteName,
siteIcon: article?.siteIcon,
byline: article?.byline || (await jsonLdLinkMetadata).byline,
byline: article?.byline,
language: article?.language,
})
logRecord.parseSuccess = true

View file

@ -325,6 +325,11 @@ const parseFieldFilter = (
field: 'subscription',
value,
}
case 'SITE':
return {
field: 'site_name',
value,
}
}
return {

View file

@ -38,6 +38,22 @@ class MockFile {
createWriteStream() {
return new MockWriteStream(this)
}
getSignedUrl() {
return ['https://signed-url.upload.omnivore.app']
}
getMetadata() {
return [{ md5Hash: 'md5Hash' }]
}
publicUrl() {
return 'https://public-url.upload.omnivore.app'
}
makePublic() {
return
}
}
class MockWriteStream extends Writable {

View file

@ -1016,6 +1016,89 @@ describe('Article API', () => {
})
})
context('when site is in the query', () => {
let items: LibraryItem[] = []
before(async () => {
keyword = 'site:yes-app.com'
items = await createLibraryItems(
[
{
user,
title: 'test title 1',
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
state: LibraryItemState.Succeeded,
siteName: 'yes-app.com',
},
{
user,
title: 'test title 2',
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
state: LibraryItemState.Succeeded,
siteName: 'no-app.com',
},
],
user.id
)
})
after(async () => {
await deleteLibraryItems(items, user.id)
})
it('returns item with matching site', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.search.pageInfo.totalCount).to.eq(1)
expect(res.body.data.search.edges[0].node.id).to.eq(items[0].id)
})
})
context('when wildcard site is in the query', () => {
let items: LibraryItem[] = []
before(async () => {
keyword = 'site:app.com'
items = await createLibraryItems(
[
{
user,
title: 'test title 1',
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
state: LibraryItemState.Succeeded,
siteName: 'yes-app.com',
},
{
user,
title: 'test title 2',
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
state: LibraryItemState.Succeeded,
siteName: 'no-app.com',
},
],
user.id
)
})
after(async () => {
await deleteLibraryItems(items, user.id)
})
it('returns item with matching search query', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.search.pageInfo.totalCount).to.eq(2)
})
})
context("when in:library label:test' is in the query", () => {
let items: LibraryItem[] = []
let label: Label
@ -1322,54 +1405,57 @@ describe('Article API', () => {
})
})
context('when wordsCount:>=10000 wordsCount:<=20000 is in the query', () => {
let items: LibraryItem[] = []
context(
'when wordsCount:>=10000 wordsCount:<=20000 is in the query',
() => {
let items: LibraryItem[] = []
before(async () => {
keyword = 'wordsCount:>=10000 wordsCount:<=20000'
// Create some test items
items = await createLibraryItems(
[
{
user,
title: 'test title 1',
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
wordCount: 10000,
},
{
user,
title: 'test title 2',
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
wordCount: 8000,
},
{
user,
title: 'test title 3',
readableContent: '<p>test 3</p>',
slug: 'test slug 3',
originalUrl: `${url}/test3`,
wordCount: 100000,
},
],
user.id
)
})
before(async () => {
keyword = 'wordsCount:>=10000 wordsCount:<=20000'
// Create some test items
items = await createLibraryItems(
[
{
user,
title: 'test title 1',
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
wordCount: 10000,
},
{
user,
title: 'test title 2',
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
wordCount: 8000,
},
{
user,
title: 'test title 3',
readableContent: '<p>test 3</p>',
slug: 'test slug 3',
originalUrl: `${url}/test3`,
wordCount: 100000,
},
],
user.id
)
})
after(async () => {
await deleteLibraryItems(items, user.id)
})
after(async () => {
await deleteLibraryItems(items, user.id)
})
it('returns items with words count between 10000 and 20000 inclusively', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
it('returns items with words count between 10000 and 20000 inclusively', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.search.pageInfo.totalCount).to.eq(1)
expect(res.body.data.search.edges[0].node.id).to.eq(items[0].id)
})
})
expect(res.body.data.search.pageInfo.totalCount).to.eq(1)
expect(res.body.data.search.edges[0].node.id).to.eq(items[0].id)
})
}
)
})
describe('TypeaheadSearch API', () => {
@ -1545,6 +1631,22 @@ describe('Article API', () => {
await deleteLibraryItemsByUserId(user.id)
})
context('when action is MarkAsRead and query is in:unread', () => {
it('marks unread items as read', async () => {
const res = await graphqlRequest(
bulkActionQuery(BulkActionType.MarkAsRead, 'is:unread'),
authToken
).expect(200)
expect(res.body.data.bulkAction.success).to.be.true
const items = await graphqlRequest(
searchQuery('is:unread'),
authToken
).expect(200)
expect(items.body.data.search.pageInfo.totalCount).to.eql(0)
})
})
context('when action is Archive', () => {
it('archives all items', async () => {
const res = await graphqlRequest(

View file

@ -3,8 +3,10 @@ import { expect } from 'chai'
import chaiString from 'chai-string'
import 'mocha'
import { User } from '../../src/entity/user'
import { createHighlight } from '../../src/services/highlights'
import { updateLibraryItem } from '../../src/services/library_item'
import {
createHighlight,
deleteHighlightById,
} from '../../src/services/highlights'
import { deleteUser } from '../../src/services/user'
import { createTestLibraryItem, createTestUser } from '../db'
import { generateFakeUuid, graphqlRequest, request } from '../util'
@ -15,8 +17,8 @@ const createHighlightQuery = (
linkId: string,
highlightId: string,
shortHighlightId: string,
highlightPositionPercent = 0.0,
highlightPositionAnchorIndex = 0,
highlightPositionPercent: number | null = null,
highlightPositionAnchorIndex: number | null = null,
annotation = '_annotation',
html: string | null = null,
prefix = '_prefix',
@ -182,6 +184,24 @@ describe('Highlights API', () => {
expect(res.body.data.createHighlight.highlight.html).to.eq(html)
})
context('when highlight position is null', () => {
it('sets highlight position = 0', async () => {
const newHighlightId = generateFakeUuid()
const newShortHighlightId = '_short_id_5'
const query = createHighlightQuery(
itemId,
newHighlightId,
newShortHighlightId
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(
res.body.data.createHighlight.highlight.highlightPositionPercent
).to.eq(0)
await deleteHighlightById(newHighlightId)
})
})
context('when the annotation has HTML reserved characters', () => {
it('unescapes the annotation and creates', async () => {
const newHighlightId = generateFakeUuid()

View file

@ -1,15 +1,19 @@
import { Storage } from '@google-cloud/storage'
import { expect } from 'chai'
import * as jwt from 'jsonwebtoken'
import 'mocha'
import sinon from 'sinon'
import { NewsletterEmail } from '../../src/entity/newsletter_email'
import { User } from '../../src/entity/user'
import { getRepository } from '../../src/repository'
import { findLibraryItemById } from '../../src/services/library_item'
import { createNewsletterEmail } from '../../src/services/newsletters'
import { deleteUser } from '../../src/services/user'
import { createTestUser } from '../db'
import { MockBucket } from '../mock_storage'
import { request } from '../util'
describe('PDF attachments Router', () => {
const newsletterEmail = 'fakeEmail@omnivore.app'
describe('Email attachments Router', () => {
const newsletterEmailAddress = 'fakeEmail@omnivore.app'
let user: User
let authToken: string
@ -18,25 +22,38 @@ describe('PDF attachments Router', () => {
// create test user and login
user = await createTestUser('fakeUser')
await createNewsletterEmail(user.id, newsletterEmail)
authToken = jwt.sign(newsletterEmail, process.env.JWT_SECRET || '')
await getRepository(NewsletterEmail).save({
address: newsletterEmailAddress,
user: { id: user.id },
})
authToken = jwt.sign(newsletterEmailAddress, process.env.JWT_SECRET || '')
// mock cloud storage
const mockBucket = new MockBucket('test')
sinon.replace(
Storage.prototype,
'bucket',
sinon.fake.returns(mockBucket as never)
)
})
after(async () => {
// clean up
await deleteUser(user.id)
sinon.restore()
})
describe('upload', () => {
xit('create upload file request and return id and url', async () => {
it('create upload file request and return id and url', async () => {
const testFile = 'testFile.pdf'
const res = await request
.post('/svc/pdf-attachments/upload')
.post('/svc/email-attachment/upload')
.set('Authorization', `${authToken}`)
.send({
email: newsletterEmail,
email: newsletterEmailAddress,
fileName: testFile,
contentType: 'application/pdf',
})
.expect(200)
@ -52,22 +69,23 @@ describe('PDF attachments Router', () => {
// upload file first
const testFile = 'testFile.pdf'
const res = await request
.post('/svc/pdf-attachments/upload')
.post('/svc/email-attachment/upload')
.set('Authorization', `${authToken}`)
.send({
email: newsletterEmail,
email: newsletterEmailAddress,
fileName: testFile,
contentType: 'application/pdf',
})
uploadFileId = res.body.id
})
xit('create article with uploaded file id and url', async () => {
it('create article with uploaded file id and url', async () => {
// create article
const res2 = await request
.post('/svc/pdf-attachments/create-article')
.post('/svc/email-attachment/create-article')
.send({
email: newsletterEmail,
uploadFileId: uploadFileId,
email: newsletterEmailAddress,
uploadFileId,
})
.set('Authorization', `${authToken}`)
.expect(200)
@ -76,6 +94,7 @@ describe('PDF attachments Router', () => {
const item = await findLibraryItemById(res2.body.id, user.id)
expect(item).to.exist
expect(item?.contentReader).to.eq('PDF')
})
})
})

View file

@ -78,32 +78,32 @@ describe('parsePreparedContent', () => {
})
})
describe('parsePreparedContent', () => {
nock('https://oembeddata').get('/').reply(200, {
version: '1.0',
provider_name: 'Hippocratic Adventures',
provider_url: 'https://www.hippocraticadventures.com',
title:
'The Ultimate Guide to Practicing Medicine in Singapore &#8211; Part 2',
})
// describe('parsePreparedContent', () => {
// nock('https://oembeddata').get('/').reply(200, {
// version: '1.0',
// provider_name: 'Hippocratic Adventures',
// provider_url: 'https://www.hippocraticadventures.com',
// title:
// 'The Ultimate Guide to Practicing Medicine in Singapore &#8211; Part 2',
// })
it('gets metadata from external JSONLD if available', async () => {
const html = `<html>
<head>
<link rel="alternate" type="application/json+oembed" href="https://oembeddata">
</link
</head>
<body>body</body>
</html>`
const result = await parsePreparedContent('https://blog.omnivore.app/', {
document: html,
pageInfo: {},
})
expect(result.parsedContent?.title).to.equal(
'The Ultimate Guide to Practicing Medicine in Singapore Part 2'
)
})
})
// it('gets metadata from external JSONLD if available', async () => {
// const html = `<html>
// <head>
// <link rel="alternate" type="application/json+oembed" href="https://oembeddata">
// </link
// </head>
// <body>body</body>
// </html>`
// const result = await parsePreparedContent('https://blog.omnivore.app/', {
// document: html,
// pageInfo: {},
// })
// expect(result.parsedContent?.title).to.equal(
// 'The Ultimate Guide to Practicing Medicine in Singapore Part 2'
// )
// })
// })
describe('isProbablyArticle', () => {
let user: User

View file

@ -159,3 +159,10 @@ describe('query with author set', () => {
})
})
describe('query with site set', () => {
it('adds site_name to the match filters', () => {
const result = parseSearchQuery('site:omnivore.app')
expect(result.matchFilters[0].field).to.equal('site_name')
expect(result.matchFilters[0].value).to.equal('omnivore.app')
})
})

View file

@ -0,0 +1,10 @@
-- Type: DO
-- Name: drop_position_trigger_ob_labels
-- Description: Drop increment_label_position and decrement_label_position trigger on omnivore.labels table
BEGIN;
DROP TRIGGER IF EXISTS increment_label_position ON omnivore.labels;
DROP TRIGGER IF EXISTS decrement_label_position ON omnivore.labels;
COMMIT;

View file

@ -0,0 +1,17 @@
-- Type: UNDO
-- Name: drop_position_trigger_ob_labels
-- Description: Drop increment_label_position and decrement_label_position trigger on omnivore.labels table
BEGIN;
CREATE TRIGGER decrement_label_position
AFTER DELETE ON omnivore.labels
FOR EACH ROW
EXECUTE FUNCTION update_label_position();
CREATE TRIGGER increment_label_position
BEFORE INSERT ON omnivore.labels
FOR EACH ROW
EXECUTE FUNCTION update_label_position();
COMMIT;

View file

@ -0,0 +1,33 @@
-- Type: DO
-- Name: Rename site to site_name
-- Description: Rename the site_tsv column to site_name_tsv to make it more consistent
BEGIN;
ALTER TABLE omnivore.library_item RENAME COLUMN site_tsv TO site_name_tsv ;
CREATE OR REPLACE FUNCTION update_library_item_tsv() RETURNS trigger AS $$
begin
new.content_tsv := to_tsvector('pg_catalog.english', coalesce(new.readable_content, ''));
new.site_name_tsv := to_tsvector('pg_catalog.english', coalesce(new.site_name, ''));
new.title_tsv := to_tsvector('pg_catalog.english', coalesce(new.title, ''));
new.author_tsv := to_tsvector('pg_catalog.english', coalesce(new.author, ''));
new.description_tsv := to_tsvector('pg_catalog.english', coalesce(new.description, ''));
-- note_tsv is generated by both note and highlight_annotations
new.note_tsv := to_tsvector('pg_catalog.english', coalesce(new.note, '') || ' ' || array_to_string(new.highlight_annotations, ' '));
new.search_tsv :=
setweight(new.title_tsv, 'A') ||
setweight(new.author_tsv, 'A') ||
setweight(new.site_name_tsv, 'A') ||
setweight(new.description_tsv, 'A') ||
-- full hostname (eg www.omnivore.app)
setweight(to_tsvector('pg_catalog.english', coalesce(regexp_replace(new.original_url, '^((http[s]?):\/)?\/?([^:\/\s]+)((\/\w+)*\/)([\w\-\.]+[^#?\s]+)(.*)?(#[\w\-]+)?$', '\3'), '')), 'A') ||
-- secondary hostname (eg omnivore)
setweight(to_tsvector('pg_catalog.english', coalesce(regexp_replace(new.original_url, '^((http[s]?):\/)?\/?(.*\.)?([^:\/\s]+)(\..*)((\/+)*\/)?([\w\-\.]+[^#?\s]+)(.*)?(#[\w\-]+)?$', '\4'), '')), 'A') ||
setweight(new.note_tsv, 'A') ||
setweight(new.content_tsv, 'B');
return new;
end
$$ LANGUAGE plpgsql;
COMMIT;

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: Rename site to site_name
-- Description: Rename the site_tsv column to site_name_tsv to make it more consistent
BEGIN;
ALTER TABLE omnivore.library_item RENAME COLUMN site_name_tsv TO site_tsv ;
COMMIT;

View file

@ -289,6 +289,10 @@ const sendSavePageMutation = async (userId, input) => {
if (response.data.data.savePage.errorCodes && response.data.data.savePage.errorCodes.length > 0) {
console.error('error while saving page', response.data.data.savePage.errorCodes[0]);
if (response.data.data.savePage.errorCodes[0] === 'UNAUTHORIZED') {
return { error: 'UNAUTHORIZED' };
}
return null;
}
@ -473,6 +477,9 @@ async function fetchContent(req, res) {
if (!apiResponse) {
logRecord.error = 'error while saving page';
statusCode = 500;
} else if (apiResponse.error === 'UNAUTHORIZED') {
console.info('user is deleted, do not retry', logRecord);
return res.sendStatus(200);
} else {
importStatus = readabilityResult ? 'imported' : 'failed';
}

View file

@ -122,7 +122,7 @@ export function UploadModal(props: UploadModalProps): JSX.Element {
const uploadSignedUrlForFile = async (
file: UploadingFile
): Promise<UploadInfo> => {
let { contentType } = file;
let { contentType } = file
if (
contentType == 'application/vnd.ms-excel' &&
file.name.endsWith('.csv')
@ -135,6 +135,12 @@ export function UploadModal(props: UploadModalProps): JSX.Element {
try {
const csvData = await validateCsvFile(file.file)
urlCount = csvData.data.length
if (urlCount > 500) {
return {
message:
'Due to an increase in traffic we are limiting CSV imports to 500 items.',
}
}
if (csvData.inValidData.length > 0) {
return {
message: csvData.inValidData[0].message,
@ -185,7 +191,7 @@ export function UploadModal(props: UploadModalProps): JSX.Element {
}
}
return {
message: `Invalid content type: ${contentType}`
message: `Invalid content type: ${contentType}`,
}
}
@ -215,8 +221,9 @@ export function UploadModal(props: UploadModalProps): JSX.Element {
const uploadInfo = await uploadSignedUrlForFile(file)
if (!uploadInfo.uploadSignedUrl) {
const message = uploadInfo.message || 'No upload URL available'
// close after 5 seconds
showErrorToast(message, { duration: 5000 })
showErrorToast(message, { duration: 10000 })
file.status = 'error'
setUploadFiles([...allFiles])
return
}

View file

@ -180,7 +180,7 @@ export function ArticleActionsMenu(
</Button>
) : (
<Button
title="Unarchive (u)"
title="Unarchive (e)"
style="articleActionIcon"
onClick={() => props.articleActionHandler('unarchive')}
>

View file

@ -191,6 +191,15 @@ export function HomeFeedContainer(): JSX.Element {
return items
}, [itemsPages, performActionOnItem])
useEffect(() => {
if (localStorage) {
localStorage.setItem(
'library-slug-list',
JSON.stringify(libraryItems.map((li) => li.node.slug))
)
}
}, [libraryItems])
useEffect(() => {
const timeout: NodeJS.Timeout[] = []

View file

@ -78,6 +78,34 @@ export default function Home(): JSX.Element {
})
}, [articleData?.article.article])
const goNextOrHome = useCallback(() => {
// const listStr = localStorage.getItem('library-slug-list')
// if (article && listStr && viewerData?.me) {
// const libraryList = JSON.parse(listStr) as string[]
// const idx = libraryList.findIndex((slug) => slug == article.slug)
// if (idx != -1 && idx < libraryList.length - 1) {
// const nextSlug = libraryList[idx + 1] as string
// router.push(`/${viewerData?.me.profile.username}/${nextSlug}`)
// return
// }
// }
router.push(`/home`)
}, [router, viewerData, article])
const goPreviousOrHome = useCallback(() => {
// const listStr = localStorage.getItem('library-slug-list')
// if (article && listStr && viewerData?.me) {
// const libraryList = JSON.parse(listStr) as string[]
// const idx = libraryList.findIndex((slug) => slug == article.slug)
// if (idx > 0) {
// const previousSlug = libraryList[idx - 1] as string
// router.push(`/${viewerData?.me.profile.username}/${previousSlug}`)
// return
// }
// }
router.push(`/home`)
}, [router, viewerData, article])
const actionHandler = useCallback(
async (action: string, arg?: unknown) => {
switch (action) {
@ -99,8 +127,7 @@ export default function Home(): JSX.Element {
})
}
})
router.push(`/home`)
goNextOrHome()
}
break
case 'archive':
@ -116,7 +143,7 @@ export default function Home(): JSX.Element {
position: 'bottom-right',
})
} else {
router.push(`/home`)
goNextOrHome()
showSuccessToast('Page archived', {
position: 'bottom-right',
})
@ -138,7 +165,7 @@ export default function Home(): JSX.Element {
position: 'bottom-right',
})
} else {
router.push(`/home`)
goNextOrHome()
}
})
}
@ -169,7 +196,7 @@ export default function Home(): JSX.Element {
break
}
},
[article, cache, mutate, router, readerSettings]
[article, viewerData, cache, mutate, router, readerSettings]
)
useEffect(() => {
@ -197,14 +224,19 @@ export default function Home(): JSX.Element {
document.addEventListener('openOriginalArticle', openOriginalArticle)
document.addEventListener('showEditModal', showEditModal)
document.addEventListener('goNextOrHome', goNextOrHome)
document.addEventListener('goPreviousOrHome', goPreviousOrHome)
return () => {
document.removeEventListener('archive', archive)
document.removeEventListener('mark-read', markRead)
document.removeEventListener('delete', deletePage)
document.removeEventListener('openOriginalArticle', openOriginalArticle)
document.removeEventListener('showEditModal', showEditModal)
document.removeEventListener('goNextOrHome', goNextOrHome)
document.removeEventListener('goPreviousOrHome', goPreviousOrHome)
}
}, [actionHandler])
}, [actionHandler, goNextOrHome, goPreviousOrHome])
useEffect(() => {
if (article && viewerData?.me) {
@ -386,6 +418,24 @@ export default function Home(): JSX.Element {
shortcut: ['i'],
perform: () => setShowEditModal(true),
},
// {
// id: 'go_previous',
// section: 'Article',
// name: 'Go to Previous',
// shortcut: ['g', 'p'],
// perform: () => {
// document.dispatchEvent(new Event('goPreviousOrHome'))
// },
// },
// {
// id: 'go_next',
// section: 'Article',
// name: 'Go to Next',
// shortcut: ['g', 'n'],
// perform: () => {
// document.dispatchEvent(new Event('goNextOrHome'))
// },
// },
],
[readerSettings, showHighlightsModal]
)