Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ module.exports = {
'^.+\\.(ts|tsx)?$': 'ts-jest',
'\\.svg$': 'svg-jest',
},
testEnvironment: 'jsdom',
}
221 changes: 221 additions & 0 deletions packages/web/src/javascripts/Controllers/LinkingController.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { WebApplication } from '@/Application/Application'
import {
AnonymousReference,
ContentReferenceType,
ContentType,
FileItem,
FileToNoteReference,
InternalEventBus,
SNNote,
} from '@standardnotes/snjs'
import { FilesController } from './FilesController'
import { ItemListController } from './ItemList/ItemListController'
import { LinkingController } from './LinkingController'
import { NavigationController } from './Navigation/NavigationController'
import { SelectedItemsController } from './SelectedItemsController'
import { SubscriptionController } from './Subscription/SubscriptionController'

const createNote = (name: string, options?: Partial<SNNote>) => {
return {
title: name,
archived: false,
trashed: false,
uuid: String(Math.random()),

Check failure

Code scanning / CodeQL

Insecure randomness

This security context depends on a cryptographically insecure random number at [Math.random()](1).
content_type: ContentType.Note,
...options,
} as jest.Mocked<SNNote>
}

const createFile = (name: string, options?: Partial<FileItem>) => {
return {
title: name,
archived: false,
trashed: false,
uuid: String(Math.random()),

Check failure

Code scanning / CodeQL

Insecure randomness

This security context depends on a cryptographically insecure random number at [Math.random()](1).
content_type: ContentType.File,
...options,
} as jest.Mocked<FileItem>
}

describe('LinkingController', () => {
let linkingController: LinkingController
let application: WebApplication
let navigationController: NavigationController
let selectionController: SelectedItemsController
let eventBus: InternalEventBus

let itemListController: ItemListController
let filesController: FilesController
let subscriptionController: SubscriptionController

beforeEach(() => {
application = {} as jest.Mocked<WebApplication>
application.getPreference = jest.fn()
application.addSingleEventObserver = jest.fn()
application.streamItems = jest.fn()

navigationController = {} as jest.Mocked<NavigationController>

selectionController = {} as jest.Mocked<SelectedItemsController>

eventBus = {} as jest.Mocked<InternalEventBus>

itemListController = {} as jest.Mocked<ItemListController>
filesController = {} as jest.Mocked<FilesController>
subscriptionController = {} as jest.Mocked<SubscriptionController>

linkingController = new LinkingController(application, navigationController, selectionController, eventBus)
linkingController.setServicesPostConstruction(itemListController, filesController, subscriptionController)
})

describe('isValidSearchResult', () => {
it("should not be valid result if it doesn't match query", () => {
const searchQuery = 'test'

const file = createFile('anotherFile')

const isFileValidResult = linkingController.isValidSearchResult(file, searchQuery)

expect(isFileValidResult).toBeFalsy()
})

it('should not be valid result if item is archived or trashed', () => {
const searchQuery = 'test'

const archived = createFile('test', { archived: true })

const trashed = createFile('test', { trashed: true })

const isArchivedFileValidResult = linkingController.isValidSearchResult(archived, searchQuery)
expect(isArchivedFileValidResult).toBeFalsy()

const isTrashedFileValidResult = linkingController.isValidSearchResult(trashed, searchQuery)
expect(isTrashedFileValidResult).toBeFalsy()
})

it('should not be valid result if result is active item', () => {
const searchQuery = 'test'

const activeItem = createFile('test', { uuid: 'same-uuid' })

Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })

const result = createFile('test', { uuid: 'same-uuid' })

const isFileValidResult = linkingController.isValidSearchResult(result, searchQuery)
expect(isFileValidResult).toBeFalsy()
})

it('should be valid result if it matches query even case insensitive', () => {
const searchQuery = 'test'

const file = createFile('TeSt')

const isFileValidResult = linkingController.isValidSearchResult(file, searchQuery)

expect(isFileValidResult).toBeTruthy()
})
})

describe('isSearchResultAlreadyLinked', () => {
it('should be true if active item & result are same content type & active item references result', () => {
const activeItem = createFile('test', {
uuid: 'active-item',
references: [
{
reference_type: ContentReferenceType.FileToFile,
uuid: 'result',
} as AnonymousReference,
],
})
const result = createFile('test', { uuid: 'result', references: [] })

Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })

const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
expect(isFileAlreadyLinked).toBeTruthy()
})

it('should be false if active item & result are same content type & result references active item', () => {
const activeItem = createFile('test', {
uuid: 'active-item',
references: [],
})
const result = createFile('test', {
uuid: 'result',
references: [
{
reference_type: ContentReferenceType.FileToFile,
uuid: 'active-item',
} as AnonymousReference,
],
})

Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })

const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
expect(isFileAlreadyLinked).toBeFalsy()
})

it('should be true if active item & result are different content type & result references active item', () => {
const activeNote = createNote('test', {
uuid: 'active-note',
references: [],
})

const fileResult = createFile('test', {
uuid: 'file-result',
references: [
{
reference_type: ContentReferenceType.FileToNote,
uuid: 'active-note',
} as FileToNoteReference,
],
})

Object.defineProperty(itemListController, 'activeControllerItem', { value: activeNote })

const isFileResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(fileResult)
expect(isFileResultAlreadyLinked).toBeTruthy()
})

it('should be true if active item & result are different content type & active item references result', () => {
const activeFile = createNote('test', {
uuid: 'active-file',
references: [
{
reference_type: ContentReferenceType.FileToNote,
uuid: 'note-result',
} as FileToNoteReference,
],
})

const noteResult = createFile('test', {
uuid: 'note-result',
references: [],
})

Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile })

const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
expect(isNoteResultAlreadyLinked).toBeTruthy()
})

it('should be false if active item & result are different content type & neither references the other', () => {
const activeFile = createNote('test', {
uuid: 'active-file',
references: [],
})

const noteResult = createFile('test', {
uuid: 'note-result',
references: [],
})

Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile })

const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
expect(isNoteResultAlreadyLinked).toBeFalsy()
})
})
})
130 changes: 81 additions & 49 deletions packages/web/src/javascripts/Controllers/LinkingController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,72 +342,104 @@ export class LinkingController extends AbstractViewController {
this.application.sync.sync().catch(console.error)
}

isValidSearchResult = (item: LinkableItem, searchQuery: string) => {
const title = item instanceof SNTag ? this.application.items.getTagLongTitle(item) : item.title ?? ''

const matchesQuery = title.toLowerCase().includes(searchQuery.toLowerCase())

const isActiveItem = this.activeItem?.uuid === item.uuid
const isArchivedOrTrashed = item.archived || item.trashed

const isValidSearchResult = matchesQuery && !isActiveItem && !isArchivedOrTrashed

return isValidSearchResult
}

isSearchResultAlreadyLinked = (item: LinkableItem) => {
if (!this.activeItem) {
return false
}

let isAlreadyLinked = false

const isItemReferencedByActiveItem = this.activeItem.references.some((ref) => ref.uuid === item.uuid)
const isActiveItemReferencedByItem = item.references.some((ref) => ref.uuid === this.activeItem?.uuid)

if (this.activeItem.content_type === item.content_type) {
isAlreadyLinked = isItemReferencedByActiveItem
} else {
isAlreadyLinked = isActiveItemReferencedByItem || isItemReferencedByActiveItem
}

return isAlreadyLinked
}

isSearchResultExistingTag = (result: DecryptedItemInterface<ItemContent>, searchQuery: string) =>
result.content_type === ContentType.Tag && result.title === searchQuery

getSearchResults = (searchQuery: string) => {
let unlinkedResults: LinkableItem[] = []
const linkedResults: ItemLink<LinkableItem>[] = []
let shouldShowCreateTag = false

const defaultReturnValue = {
linkedResults,
unlinkedResults,
shouldShowCreateTag,
}

if (!searchQuery.length) {
return {
linkedResults: [],
unlinkedResults: [],
shouldShowCreateTag: false,
}
return defaultReturnValue
}

const searchResults = naturalSort(
this.application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]).filter((item) => {
const title = item instanceof SNTag ? this.application.items.getTagLongTitle(item) : item.title
const matchesQuery = title?.toLowerCase().includes(searchQuery.toLowerCase())
const isNotActiveItem = this.activeItem?.uuid !== item.uuid
const isArchivedOrTrashed = item.archived || item.trashed
return matchesQuery && isNotActiveItem && !isArchivedOrTrashed
}),
if (!this.activeItem) {
return defaultReturnValue
}

const searchableItems = naturalSort(
this.application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]),
'title',
)

const isAlreadyLinked = (item: DecryptedItemInterface<ItemContent>) => {
if (!this.activeItem) {
return false
}
const unlinkedTags: LinkableItem[] = []
const unlinkedNotes: LinkableItem[] = []
const unlinkedFiles: LinkableItem[] = []

const isItemReferencedByActiveItem = this.application.items
.itemsReferencingItem(item)
.some((linkedItem) => linkedItem.uuid === this.activeItem?.uuid)
const isActiveItemReferencedByItem = this.application.items
.itemsReferencingItem(this.activeItem)
.some((linkedItem) => linkedItem.uuid === item.uuid)
for (let index = 0; index < searchableItems.length; index++) {
const item = searchableItems[index]

if (this.activeItem.content_type === item.content_type) {
return isItemReferencedByActiveItem
if (!this.isValidSearchResult(item, searchQuery)) {
continue
}

return isActiveItemReferencedByItem || isItemReferencedByActiveItem
}
if (this.isSearchResultAlreadyLinked(item)) {
if (linkedResults.length < 20) {
linkedResults.push(this.createLinkFromItem(item, 'linked'))
}
continue
}

const prioritizeTagResult = (
itemA: DecryptedItemInterface<ItemContent>,
itemB: DecryptedItemInterface<ItemContent>,
) => {
if (itemA.content_type === ContentType.Tag && itemB.content_type !== ContentType.Tag) {
return -1
if (unlinkedTags.length < 5 && item.content_type === ContentType.Tag) {
unlinkedTags.push(item)
continue
}
if (itemB.content_type === ContentType.Tag && itemA.content_type !== ContentType.Tag) {
return 1

if (unlinkedNotes.length < 5 && item.content_type === ContentType.Note) {
unlinkedNotes.push(item)
continue
}
return 0
}

const unlinkedResults = searchResults
.slice(0, 20)
.filter((item) => !isAlreadyLinked(item))
.sort(prioritizeTagResult)
const linkedResults = searchResults
.filter(isAlreadyLinked)
.slice(0, 20)
.map((item) => this.createLinkFromItem(item, 'linked'))
if (unlinkedFiles.length < 5 && item.content_type === ContentType.File) {
unlinkedFiles.push(item)
continue
}
}

const isResultExistingTag = (result: DecryptedItemInterface<ItemContent>) =>
result.content_type === ContentType.Tag && result.title === searchQuery
unlinkedResults = unlinkedTags.concat(unlinkedNotes).concat(unlinkedFiles)

const shouldShowCreateTag =
!linkedResults.find((link) => isResultExistingTag(link.item)) && !unlinkedResults.find(isResultExistingTag)
shouldShowCreateTag =
!linkedResults.find((link) => this.isSearchResultExistingTag(link.item, searchQuery)) &&
!unlinkedResults.find((item) => this.isSearchResultExistingTag(item, searchQuery))

return {
unlinkedResults,
Expand Down