mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #2944 from omnivore-app/main
Web production deployment
This commit is contained in:
commit
3be7709d0b
35 changed files with 546 additions and 212 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ fragment HighlightFields on Highlight {
|
|||
updatedAt
|
||||
sharedAt
|
||||
color
|
||||
highlightPositionPercent
|
||||
highlightPositionAnchorIndex
|
||||
}
|
||||
|
||||
fragment LabelFields on Label {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
|
|||
user: { id: uid },
|
||||
},
|
||||
order: {
|
||||
position: 'ASC',
|
||||
name: 'ASC',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -325,6 +325,11 @@ const parseFieldFilter = (
|
|||
field: 'subscription',
|
||||
value,
|
||||
}
|
||||
case 'SITE':
|
||||
return {
|
||||
field: 'site_name',
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 – 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 – 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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
10
packages/db/migrations/0133.do.drop_position_trigger_ob_labels.sql
Executable file
10
packages/db/migrations/0133.do.drop_position_trigger_ob_labels.sql
Executable 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;
|
||||
17
packages/db/migrations/0133.undo.drop_position_trigger_ob_labels.sql
Executable file
17
packages/db/migrations/0133.undo.drop_position_trigger_ob_labels.sql
Executable 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;
|
||||
33
packages/db/migrations/0134.do.rename_site_tsv_to_site_name_tsv.sql
Executable file
33
packages/db/migrations/0134.do.rename_site_tsv_to_site_name_tsv.sql
Executable 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;
|
||||
9
packages/db/migrations/0134.undo.rename_site_tsv_to_site_name_tsv.sql
Executable file
9
packages/db/migrations/0134.undo.rename_site_tsv_to_site_name_tsv.sql
Executable 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;
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export function ArticleActionsMenu(
|
|||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
title="Unarchive (u)"
|
||||
title="Unarchive (e)"
|
||||
style="articleActionIcon"
|
||||
onClick={() => props.articleActionHandler('unarchive')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue