refactor: item linking (#1781)
This commit is contained in:
@@ -68,7 +68,7 @@ export const useFiles = ({ note }: Props) => {
|
||||
const filesService = application.getFilesService()
|
||||
|
||||
const reloadAttachedFiles = useCallback(() => {
|
||||
setAttachedFiles(application.items.getSortedFilesForItem(note).sort(filesService.sortByName))
|
||||
setAttachedFiles(application.items.getSortedFilesLinkingToItem(note).sort(filesService.sortByName))
|
||||
}, [application.items, filesService.sortByName, note])
|
||||
|
||||
const reloadAllFiles = useCallback(() => {
|
||||
|
||||
@@ -139,7 +139,7 @@ export const NoteSideMenu = React.memo((props: Props) => {
|
||||
setAttachedFilesLength(0)
|
||||
return
|
||||
}
|
||||
setAttachedFilesLength(application.items.getSortedFilesForItem(note).length)
|
||||
setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
|
||||
}, [application, note])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -147,7 +147,7 @@ export const NoteSideMenu = React.memo((props: Props) => {
|
||||
return
|
||||
}
|
||||
const removeFilesObserver = application.streamItems(ContentType.File, () => {
|
||||
setAttachedFilesLength(application.items.getSortedFilesForItem(note).length)
|
||||
setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
|
||||
})
|
||||
return () => {
|
||||
removeFilesObserver()
|
||||
|
||||
@@ -114,7 +114,9 @@ export interface ItemsClientInterface {
|
||||
* @returns Array containing tags associated with an item
|
||||
*/
|
||||
getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): SNTag[]
|
||||
getSortedFilesForItem(item: DecryptedItemInterface<ItemContent>): FileItem[]
|
||||
|
||||
getSortedLinkedFilesForItem(item: DecryptedItemInterface<ItemContent>): FileItem[]
|
||||
getSortedFilesLinkingToItem(item: DecryptedItemInterface<ItemContent>): FileItem[]
|
||||
|
||||
getSortedLinkedNotesForItem(item: DecryptedItemInterface<ItemContent>): SNNote[]
|
||||
getSortedNotesLinkingToItem(item: DecryptedItemInterface<ItemContent>): SNNote[]
|
||||
|
||||
@@ -805,7 +805,7 @@ describe('itemManager', () => {
|
||||
|
||||
await itemManager.associateFileWithNote(file, note)
|
||||
|
||||
const filesAssociatedWithNote = itemManager.getSortedFilesForItem(note)
|
||||
const filesAssociatedWithNote = itemManager.getSortedFilesLinkingToItem(note)
|
||||
|
||||
expect(filesAssociatedWithNote).toHaveLength(1)
|
||||
expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid)
|
||||
@@ -873,20 +873,38 @@ describe('itemManager', () => {
|
||||
|
||||
it('should get all linked files for item', async () => {
|
||||
itemManager = createService()
|
||||
const note = createNote('note')
|
||||
const file = createFile('A1')
|
||||
const file2 = createFile('B2')
|
||||
const file3 = createFile('C3')
|
||||
|
||||
await itemManager.insertItems([note, file, file2])
|
||||
await itemManager.insertItems([file, file2, file3])
|
||||
|
||||
await itemManager.associateFileWithNote(file2, note)
|
||||
await itemManager.associateFileWithNote(file, note)
|
||||
await itemManager.linkFileToFile(file, file3)
|
||||
await itemManager.linkFileToFile(file, file2)
|
||||
|
||||
const sortedFilesForItem = itemManager.getSortedFilesForItem(note)
|
||||
const sortedFilesForItem = itemManager.getSortedLinkedFilesForItem(file)
|
||||
|
||||
expect(sortedFilesForItem).toHaveLength(2)
|
||||
expect(sortedFilesForItem[0].uuid).toEqual(file.uuid)
|
||||
expect(sortedFilesForItem[1].uuid).toEqual(file2.uuid)
|
||||
expect(sortedFilesForItem[0].uuid).toEqual(file2.uuid)
|
||||
expect(sortedFilesForItem[1].uuid).toEqual(file3.uuid)
|
||||
})
|
||||
|
||||
it('should get all files linking to item', async () => {
|
||||
itemManager = createService()
|
||||
const baseFile = createFile('file')
|
||||
const fileToLink1 = createFile('A1')
|
||||
const fileToLink2 = createFile('B2')
|
||||
|
||||
await itemManager.insertItems([baseFile, fileToLink1, fileToLink2])
|
||||
|
||||
await itemManager.linkFileToFile(fileToLink2, baseFile)
|
||||
await itemManager.linkFileToFile(fileToLink1, baseFile)
|
||||
|
||||
const sortedFilesForItem = itemManager.getSortedFilesLinkingToItem(baseFile)
|
||||
|
||||
expect(sortedFilesForItem).toHaveLength(2)
|
||||
expect(sortedFilesForItem[0].uuid).toEqual(fileToLink1.uuid)
|
||||
expect(sortedFilesForItem[1].uuid).toEqual(fileToLink2.uuid)
|
||||
})
|
||||
|
||||
it('should get all linked notes for item', async () => {
|
||||
|
||||
@@ -1192,7 +1192,19 @@ export class ItemManager
|
||||
)
|
||||
}
|
||||
|
||||
public getSortedFilesForItem(item: DecryptedItemInterface<ItemContent>): Models.FileItem[] {
|
||||
public getSortedLinkedFilesForItem(item: DecryptedItemInterface<ItemContent>): Models.FileItem[] {
|
||||
if (this.isTemplateItem(item)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const filesReferencedByItem = this.referencesForItem(item).filter(
|
||||
(ref) => ref.content_type === ContentType.File,
|
||||
) as Models.FileItem[]
|
||||
|
||||
return naturalSort(filesReferencedByItem, 'title')
|
||||
}
|
||||
|
||||
public getSortedFilesLinkingToItem(item: DecryptedItemInterface<ItemContent>): Models.FileItem[] {
|
||||
if (this.isTemplateItem(item)) {
|
||||
return []
|
||||
}
|
||||
@@ -1200,11 +1212,8 @@ export class ItemManager
|
||||
const filesReferencingItem = this.itemsReferencingItem(item).filter(
|
||||
(ref) => ref.content_type === ContentType.File,
|
||||
) as Models.FileItem[]
|
||||
const filesReferencedByItem = this.referencesForItem(item).filter(
|
||||
(ref) => ref.content_type === ContentType.File,
|
||||
) as Models.FileItem[]
|
||||
|
||||
return naturalSort(filesReferencingItem.concat(filesReferencedByItem), 'title')
|
||||
return naturalSort(filesReferencingItem, 'title')
|
||||
}
|
||||
|
||||
public getSortedLinkedNotesForItem(item: DecryptedItemInterface<ItemContent>): Models.SNNote[] {
|
||||
|
||||
@@ -32,7 +32,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
|
||||
const editorForNote = application.componentManager.editorForNote(item as SNNote)
|
||||
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
|
||||
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
|
||||
const hasFiles = application.items.getSortedFilesForItem(item).length > 0
|
||||
const hasFiles = application.items.getSortedFilesLinkingToItem(item).length > 0
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
notesController.setContextMenuOpen(false)
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as icons from '@standardnotes/icons'
|
||||
export const ICONS = {
|
||||
'account-circle': icons.AccountCircleIcon,
|
||||
'arrow-left': icons.ArrowLeftIcon,
|
||||
'arrow-right': icons.ArrowRightIcon,
|
||||
'arrows-sort-down': icons.ArrowsSortDownIcon,
|
||||
'arrows-sort-up': icons.ArrowsSortUpIcon,
|
||||
'attachment-file': icons.AttachmentFileIcon,
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'
|
||||
import { ContentType } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
item: LinkableItem
|
||||
link: ItemLink
|
||||
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
activateItem: (item: LinkableItem) => Promise<void>
|
||||
unlinkItem: (item: LinkableItem) => void
|
||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||
focusPreviousItem: () => void
|
||||
focusNextItem: () => void
|
||||
focusedId: string | undefined
|
||||
setFocusedId: (id: string) => void
|
||||
isBidirectional: boolean
|
||||
}
|
||||
|
||||
const LinkedItemBubble = ({
|
||||
item,
|
||||
link,
|
||||
getItemIcon,
|
||||
getTitleForLinkedTag,
|
||||
activateItem,
|
||||
@@ -27,6 +29,7 @@ const LinkedItemBubble = ({
|
||||
focusNextItem,
|
||||
focusedId,
|
||||
setFocusedId,
|
||||
isBidirectional,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -36,8 +39,8 @@ const LinkedItemBubble = ({
|
||||
const [wasClicked, setWasClicked] = useState(false)
|
||||
|
||||
const handleFocus = () => {
|
||||
if (focusedId !== item.uuid) {
|
||||
setFocusedId(item.uuid)
|
||||
if (focusedId !== link.id) {
|
||||
setFocusedId(link.id)
|
||||
}
|
||||
setShowUnlinkButton(true)
|
||||
}
|
||||
@@ -50,7 +53,7 @@ const LinkedItemBubble = ({
|
||||
const onClick: MouseEventHandler = (event) => {
|
||||
if (wasClicked && event.target !== unlinkButtonRef.current) {
|
||||
setWasClicked(false)
|
||||
void activateItem(item)
|
||||
void activateItem(link.item)
|
||||
} else {
|
||||
setWasClicked(true)
|
||||
}
|
||||
@@ -58,14 +61,14 @@ const LinkedItemBubble = ({
|
||||
|
||||
const onUnlinkClick: MouseEventHandler = (event) => {
|
||||
event.stopPropagation()
|
||||
unlinkItem(item)
|
||||
unlinkItem(link)
|
||||
}
|
||||
|
||||
const onKeyDown: KeyboardEventHandler = (event) => {
|
||||
switch (event.key) {
|
||||
case KeyboardKey.Backspace: {
|
||||
focusPreviousItem()
|
||||
unlinkItem(item)
|
||||
unlinkItem(link)
|
||||
break
|
||||
}
|
||||
case KeyboardKey.Left:
|
||||
@@ -77,29 +80,34 @@ const LinkedItemBubble = ({
|
||||
}
|
||||
}
|
||||
|
||||
const [icon, iconClassName] = getItemIcon(item)
|
||||
const tagTitle = getTitleForLinkedTag(item)
|
||||
const [icon, iconClassName] = getItemIcon(link.item)
|
||||
const tagTitle = getTitleForLinkedTag(link.item)
|
||||
|
||||
useEffect(() => {
|
||||
if (item.uuid === focusedId) {
|
||||
if (link.id === focusedId) {
|
||||
ref.current?.focus()
|
||||
}
|
||||
}, [focusedId, item.uuid])
|
||||
}, [focusedId, link.id])
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className="flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-xs text-text hover:bg-contrast focus:bg-contrast"
|
||||
className="group flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-xs text-text hover:bg-contrast focus:bg-contrast"
|
||||
onFocus={handleFocus}
|
||||
onBlur={onBlur}
|
||||
onClick={onClick}
|
||||
title={tagTitle ? tagTitle.longTitle : item.title}
|
||||
title={tagTitle ? tagTitle.longTitle : link.item.title}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<Icon type={icon} className={classNames('mr-1 flex-shrink-0', iconClassName)} size="small" />
|
||||
<span className="max-w-290px overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
<span className="max-w-290px flex items-center overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
|
||||
{item.title}
|
||||
<span className="flex items-center gap-1">
|
||||
{link.relationWithSelectedItem === 'indirect' && link.item.content_type !== ContentType.Tag && (
|
||||
<span className={!isBidirectional ? 'hidden group-focus:block' : ''}>Linked By:</span>
|
||||
)}
|
||||
{link.item.title}
|
||||
</span>
|
||||
</span>
|
||||
{showUnlinkButton && (
|
||||
<a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
|
||||
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import LinkedItemBubble from './LinkedItemBubble'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||
@@ -14,8 +14,9 @@ type Props = {
|
||||
const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
const {
|
||||
allLinkedItems,
|
||||
notesLinkingToItem,
|
||||
allItemLinks,
|
||||
notesLinkingToActiveItem,
|
||||
filesLinkingToActiveItem,
|
||||
unlinkItemFromSelectedItem: unlinkItem,
|
||||
getTitleForLinkedTag,
|
||||
getLinkedItemIcon: getItemIcon,
|
||||
@@ -23,7 +24,13 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
} = linkingController
|
||||
|
||||
const [focusedId, setFocusedId] = useState<string>()
|
||||
const focusableIds = allLinkedItems.map((item) => item.uuid).concat([ElementIds.ItemLinkAutocompleteInput])
|
||||
const focusableIds = allItemLinks
|
||||
.map((link) => link.id)
|
||||
.concat(
|
||||
notesLinkingToActiveItem.map((link) => link.id),
|
||||
filesLinkingToActiveItem.map((link) => link.id),
|
||||
[ElementIds.ItemLinkAutocompleteInput],
|
||||
)
|
||||
|
||||
const focusPreviousItem = useCallback(() => {
|
||||
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
|
||||
@@ -53,27 +60,38 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
[activateItem, toggleAppPane],
|
||||
)
|
||||
|
||||
const isItemBidirectionallyLinked = (link: ItemLink) => {
|
||||
const existsInAllItemLinks = !!allItemLinks.find((item) => link.item.uuid === item.item.uuid)
|
||||
const existsInNotesLinkingToItem = !!notesLinkingToActiveItem.find((item) => link.item.uuid === item.item.uuid)
|
||||
|
||||
return existsInAllItemLinks && existsInNotesLinkingToItem
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'hidden min-w-80 max-w-full flex-wrap items-center gap-2 bg-transparent md:-mr-2 md:flex',
|
||||
allLinkedItems.length || notesLinkingToItem.length ? 'mt-1' : 'mt-0.5',
|
||||
allItemLinks.length || notesLinkingToActiveItem.length ? 'mt-1' : 'mt-0.5',
|
||||
)}
|
||||
>
|
||||
{allLinkedItems.concat(notesLinkingToItem).map((item) => (
|
||||
<LinkedItemBubble
|
||||
item={item}
|
||||
key={item.uuid}
|
||||
getItemIcon={getItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
activateItem={activateItemAndTogglePane}
|
||||
unlinkItem={unlinkItem}
|
||||
focusPreviousItem={focusPreviousItem}
|
||||
focusNextItem={focusNextItem}
|
||||
focusedId={focusedId}
|
||||
setFocusedId={setFocusedId}
|
||||
/>
|
||||
))}
|
||||
{allItemLinks
|
||||
.concat(notesLinkingToActiveItem)
|
||||
.concat(filesLinkingToActiveItem)
|
||||
.map((link) => (
|
||||
<LinkedItemBubble
|
||||
link={link}
|
||||
key={link.id}
|
||||
getItemIcon={getItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
activateItem={activateItemAndTogglePane}
|
||||
unlinkItem={unlinkItem}
|
||||
focusPreviousItem={focusPreviousItem}
|
||||
focusNextItem={focusNextItem}
|
||||
focusedId={focusedId}
|
||||
setFocusedId={setFocusedId}
|
||||
isBidirectional={isItemBidirectionallyLinked(link)}
|
||||
/>
|
||||
))}
|
||||
<ItemLinkAutocompleteInput
|
||||
focusedId={focusedId}
|
||||
linkingController={linkingController}
|
||||
|
||||
@@ -33,7 +33,7 @@ const LinkedItemsSectionItem = ({
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
item: LinkableItem
|
||||
searchQuery?: string
|
||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||
unlinkItem: () => void
|
||||
handleFileAction: FilesController['handleFileAction']
|
||||
}) => {
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -85,7 +85,7 @@ const LinkedItemsSectionItem = ({
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="flex flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
|
||||
className="flex max-w-full flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
|
||||
onClick={() => activateItem(item)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -118,7 +118,7 @@ const LinkedItemsSectionItem = ({
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
unlinkItem(item)
|
||||
unlinkItem()
|
||||
toggleMenu()
|
||||
}}
|
||||
>
|
||||
@@ -166,10 +166,11 @@ const LinkedItemsPanel = ({
|
||||
}) => {
|
||||
const {
|
||||
tags,
|
||||
files,
|
||||
linkedFiles,
|
||||
filesLinkingToActiveItem,
|
||||
notesLinkedToItem,
|
||||
notesLinkingToItem,
|
||||
allLinkedItems,
|
||||
notesLinkingToActiveItem,
|
||||
allItemLinks: allLinkedItems,
|
||||
getTitleForLinkedTag,
|
||||
getLinkedItemIcon,
|
||||
getSearchResults,
|
||||
@@ -196,7 +197,7 @@ const LinkedItemsPanel = ({
|
||||
<form
|
||||
className={classNames(
|
||||
'sticky top-0 z-10 bg-default px-2.5 pt-2.5',
|
||||
allLinkedItems.length || linkedResults.length || unlinkedResults.length || notesLinkingToItem.length
|
||||
allLinkedItems.length || linkedResults.length || unlinkedResults.length || notesLinkingToActiveItem.length
|
||||
? 'border-b border-border pb-2.5'
|
||||
: 'pb-1',
|
||||
)}
|
||||
@@ -246,14 +247,14 @@ const LinkedItemsPanel = ({
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked</div>
|
||||
<div className="my-1">
|
||||
{linkedResults.map((item) => (
|
||||
{linkedResults.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -268,14 +269,14 @@ const LinkedItemsPanel = ({
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Tags</div>
|
||||
<div className="my-1">
|
||||
{tags.map((item) => (
|
||||
{tags.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -283,18 +284,39 @@ const LinkedItemsPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!files.length && (
|
||||
{!!linkedFiles.length && (
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</div>
|
||||
<div className="my-1">
|
||||
{files.map((item) => (
|
||||
{linkedFiles.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!filesLinkingToActiveItem.length && (
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
|
||||
Files Linking To Current File
|
||||
</div>
|
||||
<div className="my-1">
|
||||
{filesLinkingToActiveItem.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -306,14 +328,14 @@ const LinkedItemsPanel = ({
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Notes</div>
|
||||
<div className="my-1">
|
||||
{notesLinkedToItem.map((item) => (
|
||||
{notesLinkedToItem.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -321,20 +343,20 @@ const LinkedItemsPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!notesLinkingToItem.length && (
|
||||
{!!notesLinkingToActiveItem.length && (
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
|
||||
Notes Linking To This Note
|
||||
</div>
|
||||
<div className="my-1">
|
||||
{notesLinkingToItem.map((item) => (
|
||||
{notesLinkingToActiveItem.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
|
||||
@@ -99,7 +99,7 @@ export class FilesController extends AbstractViewController {
|
||||
reloadAttachedFiles = () => {
|
||||
const note = this.notesController.firstSelectedNote
|
||||
if (note) {
|
||||
this.attachedFiles = this.application.items.getSortedFilesForItem(note)
|
||||
this.attachedFiles = this.application.items.getSortedFilesLinkingToItem(note)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
IconType,
|
||||
InternalEventBus,
|
||||
ItemContent,
|
||||
ItemsClientInterface,
|
||||
naturalSort,
|
||||
NoteViewController,
|
||||
PrefKey,
|
||||
SNNote,
|
||||
SNTag,
|
||||
@@ -25,11 +27,20 @@ import { SubscriptionController } from './Subscription/SubscriptionController'
|
||||
|
||||
export type LinkableItem = DecryptedItemInterface<ItemContent>
|
||||
|
||||
type RelationWithSelectedItem = ReturnType<ItemsClientInterface['relationshipTypeForItems']>
|
||||
|
||||
export type ItemLink<ItemType extends LinkableItem = LinkableItem> = {
|
||||
id: string
|
||||
item: ItemType
|
||||
relationWithSelectedItem: RelationWithSelectedItem
|
||||
}
|
||||
|
||||
export class LinkingController extends AbstractViewController {
|
||||
tags: SNTag[] = []
|
||||
files: FileItem[] = []
|
||||
notesLinkedToItem: SNNote[] = []
|
||||
notesLinkingToItem: SNNote[] = []
|
||||
tags: ItemLink<SNTag>[] = []
|
||||
linkedFiles: ItemLink<FileItem>[] = []
|
||||
filesLinkingToActiveItem: ItemLink<FileItem>[] = []
|
||||
notesLinkedToItem: ItemLink<SNNote>[] = []
|
||||
notesLinkingToActiveItem: ItemLink<SNNote>[] = []
|
||||
shouldLinkToParentFolders: boolean
|
||||
isLinkingPanelOpen = false
|
||||
private itemListController!: ItemListController
|
||||
@@ -46,13 +57,15 @@ export class LinkingController extends AbstractViewController {
|
||||
|
||||
makeObservable(this, {
|
||||
tags: observable,
|
||||
files: observable,
|
||||
linkedFiles: observable,
|
||||
filesLinkingToActiveItem: observable,
|
||||
notesLinkedToItem: observable,
|
||||
notesLinkingToItem: observable,
|
||||
notesLinkingToActiveItem: observable,
|
||||
isLinkingPanelOpen: observable,
|
||||
|
||||
allLinkedItems: computed,
|
||||
allItemLinks: computed,
|
||||
isEntitledToNoteLinking: computed,
|
||||
selectedItemTitle: computed,
|
||||
|
||||
setIsLinkingPanelOpen: action,
|
||||
reloadLinkedFiles: action,
|
||||
@@ -107,14 +120,22 @@ export class LinkingController extends AbstractViewController {
|
||||
this.isLinkingPanelOpen = open
|
||||
}
|
||||
|
||||
get allLinkedItems() {
|
||||
return [...this.tags, ...this.files, ...this.notesLinkedToItem]
|
||||
get allItemLinks() {
|
||||
return [...this.tags, ...this.linkedFiles, ...this.notesLinkedToItem]
|
||||
}
|
||||
|
||||
get activeItem() {
|
||||
return this.itemListController.activeControllerItem
|
||||
}
|
||||
|
||||
get selectedItemTitle() {
|
||||
return this.selectionController.firstSelectedItem
|
||||
? this.selectionController.firstSelectedItem.title
|
||||
: this.activeItem
|
||||
? this.activeItem.title
|
||||
: ''
|
||||
}
|
||||
|
||||
reloadAllLinks() {
|
||||
this.reloadLinkedFiles()
|
||||
this.reloadLinkedTags()
|
||||
@@ -122,31 +143,67 @@ export class LinkingController extends AbstractViewController {
|
||||
this.reloadNotesLinkingToItem()
|
||||
}
|
||||
|
||||
createLinkFromItem = <ItemType extends LinkableItem = LinkableItem>(
|
||||
item: ItemType,
|
||||
relation?: RelationWithSelectedItem,
|
||||
): ItemLink<ItemType> => {
|
||||
const relationWithSelectedItem = relation ? relation : this.itemRelationshipWithSelectedItem(item)
|
||||
|
||||
return {
|
||||
id: `${item.uuid}-${relationWithSelectedItem}`,
|
||||
item,
|
||||
relationWithSelectedItem,
|
||||
}
|
||||
}
|
||||
|
||||
reloadLinkedFiles() {
|
||||
if (this.activeItem) {
|
||||
const files = this.application.items.getSortedFilesForItem(this.activeItem)
|
||||
this.files = files
|
||||
if (!this.activeItem) {
|
||||
return
|
||||
}
|
||||
|
||||
const isActiveItemAFile = this.activeItem instanceof FileItem
|
||||
|
||||
const linkedFiles = this.application.items
|
||||
.getSortedLinkedFilesForItem(this.activeItem)
|
||||
.map((item) => this.createLinkFromItem(item, isActiveItemAFile ? 'direct' : 'indirect'))
|
||||
|
||||
const filesLinkingToActiveItem = this.application.items
|
||||
.getSortedFilesLinkingToItem(this.activeItem)
|
||||
.map((item) => this.createLinkFromItem(item, isActiveItemAFile ? 'indirect' : 'direct'))
|
||||
|
||||
if (isActiveItemAFile) {
|
||||
this.linkedFiles = linkedFiles
|
||||
this.filesLinkingToActiveItem = filesLinkingToActiveItem
|
||||
} else {
|
||||
this.linkedFiles = filesLinkingToActiveItem
|
||||
this.filesLinkingToActiveItem = linkedFiles
|
||||
}
|
||||
}
|
||||
|
||||
reloadLinkedTags() {
|
||||
if (this.activeItem) {
|
||||
const tags = this.application.items.getSortedTagsForItem(this.activeItem)
|
||||
const tags = this.application.items
|
||||
.getSortedTagsForItem(this.activeItem)
|
||||
.map((item) => this.createLinkFromItem(item))
|
||||
this.tags = tags
|
||||
}
|
||||
}
|
||||
|
||||
reloadLinkedNotes() {
|
||||
if (this.activeItem) {
|
||||
const notes = this.application.items.getSortedLinkedNotesForItem(this.activeItem)
|
||||
const notes = this.application.items
|
||||
.getSortedLinkedNotesForItem(this.activeItem)
|
||||
.map((item) => this.createLinkFromItem(item, 'direct'))
|
||||
this.notesLinkedToItem = notes
|
||||
}
|
||||
}
|
||||
|
||||
reloadNotesLinkingToItem() {
|
||||
if (this.activeItem) {
|
||||
const notes = this.application.items.getSortedNotesLinkingToItem(this.activeItem)
|
||||
this.notesLinkingToItem = notes
|
||||
const notes = this.application.items
|
||||
.getSortedNotesLinkingToItem(this.activeItem)
|
||||
.map((item) => this.createLinkFromItem(item, 'indirect'))
|
||||
this.notesLinkingToActiveItem = notes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,44 +263,61 @@ export class LinkingController extends AbstractViewController {
|
||||
return undefined
|
||||
}
|
||||
|
||||
unlinkItemFromSelectedItem = async (itemToUnlink: LinkableItem) => {
|
||||
itemRelationshipWithSelectedItem = (item: LinkableItem) => {
|
||||
const activeItem = this.activeItem
|
||||
|
||||
if (!activeItem) {
|
||||
throw new Error('No active item available')
|
||||
}
|
||||
|
||||
return this.application.items.relationshipTypeForItems(activeItem, item)
|
||||
}
|
||||
|
||||
unlinkItemFromSelectedItem = async (itemToUnlink: ItemLink) => {
|
||||
const selectedItem = this.selectionController.firstSelectedItem
|
||||
|
||||
if (!selectedItem) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedItemReferencesItemToUnlink =
|
||||
this.application.items.relationshipTypeForItems(selectedItem, itemToUnlink) === 'direct'
|
||||
const selectedItemReferencesItemToUnlink = itemToUnlink.relationWithSelectedItem === 'direct'
|
||||
|
||||
if (selectedItemReferencesItemToUnlink) {
|
||||
await this.application.items.unlinkItem(selectedItem, itemToUnlink)
|
||||
await this.application.items.unlinkItem(selectedItem, itemToUnlink.item)
|
||||
} else {
|
||||
await this.application.items.unlinkItem(itemToUnlink, selectedItem)
|
||||
await this.application.items.unlinkItem(itemToUnlink.item, selectedItem)
|
||||
}
|
||||
|
||||
void this.application.sync.sync()
|
||||
this.reloadAllLinks()
|
||||
}
|
||||
|
||||
linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
|
||||
const selectedItem = this.selectionController.firstSelectedItem
|
||||
ensureActiveItemIsInserted = async () => {
|
||||
const activeItemController = this.itemListController.getActiveItemController()
|
||||
if (activeItemController instanceof NoteViewController && activeItemController.isTemplateNote) {
|
||||
await activeItemController.insertTemplatedNote()
|
||||
}
|
||||
}
|
||||
|
||||
if (itemToLink instanceof SNTag) {
|
||||
await this.addTagToActiveItem(itemToLink)
|
||||
linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
|
||||
await this.ensureActiveItemIsInserted()
|
||||
const activeItem = this.activeItem
|
||||
|
||||
if (activeItem && itemToLink instanceof SNTag) {
|
||||
await this.addTagToItem(itemToLink, activeItem)
|
||||
}
|
||||
|
||||
if (selectedItem instanceof SNNote) {
|
||||
if (activeItem instanceof SNNote) {
|
||||
if (itemToLink instanceof FileItem) {
|
||||
await this.application.items.associateFileWithNote(itemToLink, selectedItem)
|
||||
await this.application.items.associateFileWithNote(itemToLink, activeItem)
|
||||
} else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) {
|
||||
await this.application.items.linkNoteToNote(selectedItem, itemToLink)
|
||||
await this.application.items.linkNoteToNote(activeItem, itemToLink)
|
||||
}
|
||||
} else if (selectedItem instanceof FileItem) {
|
||||
} else if (activeItem instanceof FileItem) {
|
||||
if (itemToLink instanceof SNNote) {
|
||||
await this.application.items.associateFileWithNote(selectedItem, itemToLink)
|
||||
await this.application.items.associateFileWithNote(activeItem, itemToLink)
|
||||
} else if (itemToLink instanceof FileItem) {
|
||||
await this.application.items.linkFileToFile(itemToLink, selectedItem)
|
||||
await this.application.items.linkFileToFile(activeItem, itemToLink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,21 +326,19 @@ export class LinkingController extends AbstractViewController {
|
||||
}
|
||||
|
||||
createAndAddNewTag = async (title: string) => {
|
||||
await this.ensureActiveItemIsInserted()
|
||||
const activeItem = this.activeItem
|
||||
const newTag = await this.application.mutator.findOrCreateTag(title)
|
||||
await this.addTagToActiveItem(newTag)
|
||||
if (activeItem) {
|
||||
await this.addTagToItem(newTag, activeItem)
|
||||
}
|
||||
}
|
||||
|
||||
addTagToActiveItem = async (tag: SNTag) => {
|
||||
const activeItem = this.itemListController.activeControllerItem
|
||||
|
||||
if (!activeItem) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeItem instanceof SNNote) {
|
||||
await this.application.items.addTagToNote(activeItem, tag, this.shouldLinkToParentFolders)
|
||||
} else if (activeItem instanceof FileItem) {
|
||||
await this.application.items.addTagToFile(activeItem, tag, this.shouldLinkToParentFolders)
|
||||
addTagToItem = async (tag: SNTag, item: FileItem | SNNote) => {
|
||||
if (item instanceof SNNote) {
|
||||
await this.application.items.addTagToNote(item, tag, this.shouldLinkToParentFolders)
|
||||
} else if (item instanceof FileItem) {
|
||||
await this.application.items.addTagToFile(item, tag, this.shouldLinkToParentFolders)
|
||||
}
|
||||
|
||||
this.reloadLinkedTags()
|
||||
@@ -293,19 +365,23 @@ export class LinkingController extends AbstractViewController {
|
||||
'title',
|
||||
)
|
||||
|
||||
const isAlreadyLinked = (item: LinkableItem) => {
|
||||
const isAlreadyLinked = (item: DecryptedItemInterface<ItemContent>) => {
|
||||
if (!this.activeItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
const isAlreadyLinkedToItem =
|
||||
isItemReferencedByActiveItem || (item.content_type !== ContentType.Note && isActiveItemReferencedByItem)
|
||||
return isAlreadyLinkedToItem
|
||||
|
||||
if (this.activeItem.content_type === item.content_type) {
|
||||
return isItemReferencedByActiveItem
|
||||
}
|
||||
|
||||
return isActiveItemReferencedByItem || isItemReferencedByActiveItem
|
||||
}
|
||||
|
||||
const prioritizeTagResult = (
|
||||
@@ -325,10 +401,14 @@ export class LinkingController extends AbstractViewController {
|
||||
.slice(0, 20)
|
||||
.filter((item) => !isAlreadyLinked(item))
|
||||
.sort(prioritizeTagResult)
|
||||
const linkedResults = searchResults.filter(isAlreadyLinked).slice(0, 20)
|
||||
const isResultExistingTag = (result: LinkableItem) =>
|
||||
const linkedResults = searchResults
|
||||
.filter(isAlreadyLinked)
|
||||
.slice(0, 20)
|
||||
.map((item) => this.createLinkFromItem(item))
|
||||
const isResultExistingTag = (result: DecryptedItemInterface<ItemContent>) =>
|
||||
result.content_type === ContentType.Tag && result.title === searchQuery
|
||||
const shouldShowCreateTag = !linkedResults.find(isResultExistingTag) && !unlinkedResults.find(isResultExistingTag)
|
||||
const shouldShowCreateTag =
|
||||
!linkedResults.find((link) => isResultExistingTag(link.item)) && !unlinkedResults.find(isResultExistingTag)
|
||||
|
||||
return {
|
||||
unlinkedResults,
|
||||
|
||||
Reference in New Issue
Block a user