refactor: item linking (#1781)

This commit is contained in:
Aman Harwara
2022-10-12 21:57:51 +05:30
committed by GitHub
parent 2b89ad488f
commit 81532f2f20
12 changed files with 292 additions and 134 deletions

View File

@@ -68,7 +68,7 @@ export const useFiles = ({ note }: Props) => {
const filesService = application.getFilesService() const filesService = application.getFilesService()
const reloadAttachedFiles = useCallback(() => { const reloadAttachedFiles = useCallback(() => {
setAttachedFiles(application.items.getSortedFilesForItem(note).sort(filesService.sortByName)) setAttachedFiles(application.items.getSortedFilesLinkingToItem(note).sort(filesService.sortByName))
}, [application.items, filesService.sortByName, note]) }, [application.items, filesService.sortByName, note])
const reloadAllFiles = useCallback(() => { const reloadAllFiles = useCallback(() => {

View File

@@ -139,7 +139,7 @@ export const NoteSideMenu = React.memo((props: Props) => {
setAttachedFilesLength(0) setAttachedFilesLength(0)
return return
} }
setAttachedFilesLength(application.items.getSortedFilesForItem(note).length) setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
}, [application, note]) }, [application, note])
useEffect(() => { useEffect(() => {
@@ -147,7 +147,7 @@ export const NoteSideMenu = React.memo((props: Props) => {
return return
} }
const removeFilesObserver = application.streamItems(ContentType.File, () => { const removeFilesObserver = application.streamItems(ContentType.File, () => {
setAttachedFilesLength(application.items.getSortedFilesForItem(note).length) setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
}) })
return () => { return () => {
removeFilesObserver() removeFilesObserver()

View File

@@ -114,7 +114,9 @@ export interface ItemsClientInterface {
* @returns Array containing tags associated with an item * @returns Array containing tags associated with an item
*/ */
getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): SNTag[] 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[] getSortedLinkedNotesForItem(item: DecryptedItemInterface<ItemContent>): SNNote[]
getSortedNotesLinkingToItem(item: DecryptedItemInterface<ItemContent>): SNNote[] getSortedNotesLinkingToItem(item: DecryptedItemInterface<ItemContent>): SNNote[]

View File

@@ -805,7 +805,7 @@ describe('itemManager', () => {
await itemManager.associateFileWithNote(file, note) await itemManager.associateFileWithNote(file, note)
const filesAssociatedWithNote = itemManager.getSortedFilesForItem(note) const filesAssociatedWithNote = itemManager.getSortedFilesLinkingToItem(note)
expect(filesAssociatedWithNote).toHaveLength(1) expect(filesAssociatedWithNote).toHaveLength(1)
expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid) expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid)
@@ -873,20 +873,38 @@ describe('itemManager', () => {
it('should get all linked files for item', async () => { it('should get all linked files for item', async () => {
itemManager = createService() itemManager = createService()
const note = createNote('note')
const file = createFile('A1') const file = createFile('A1')
const file2 = createFile('B2') 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.linkFileToFile(file, file3)
await itemManager.associateFileWithNote(file, note) await itemManager.linkFileToFile(file, file2)
const sortedFilesForItem = itemManager.getSortedFilesForItem(note) const sortedFilesForItem = itemManager.getSortedLinkedFilesForItem(file)
expect(sortedFilesForItem).toHaveLength(2) expect(sortedFilesForItem).toHaveLength(2)
expect(sortedFilesForItem[0].uuid).toEqual(file.uuid) expect(sortedFilesForItem[0].uuid).toEqual(file2.uuid)
expect(sortedFilesForItem[1].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 () => { it('should get all linked notes for item', async () => {

View File

@@ -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)) { if (this.isTemplateItem(item)) {
return [] return []
} }
@@ -1200,11 +1212,8 @@ export class ItemManager
const filesReferencingItem = this.itemsReferencingItem(item).filter( const filesReferencingItem = this.itemsReferencingItem(item).filter(
(ref) => ref.content_type === ContentType.File, (ref) => ref.content_type === ContentType.File,
) as Models.FileItem[] ) 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[] { public getSortedLinkedNotesForItem(item: DecryptedItemInterface<ItemContent>): Models.SNNote[] {

View File

@@ -32,7 +32,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
const editorForNote = application.componentManager.editorForNote(item as SNNote) const editorForNote = application.componentManager.editorForNote(item as SNNote)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type) 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) => { const openNoteContextMenu = (posX: number, posY: number) => {
notesController.setContextMenuOpen(false) notesController.setContextMenuOpen(false)

View File

@@ -5,6 +5,7 @@ import * as icons from '@standardnotes/icons'
export const ICONS = { export const ICONS = {
'account-circle': icons.AccountCircleIcon, 'account-circle': icons.AccountCircleIcon,
'arrow-left': icons.ArrowLeftIcon, 'arrow-left': icons.ArrowLeftIcon,
'arrow-right': icons.ArrowRightIcon,
'arrows-sort-down': icons.ArrowsSortDownIcon, 'arrows-sort-down': icons.ArrowsSortDownIcon,
'arrows-sort-up': icons.ArrowsSortUpIcon, 'arrows-sort-up': icons.ArrowsSortUpIcon,
'attachment-file': icons.AttachmentFileIcon, 'attachment-file': icons.AttachmentFileIcon,

View File

@@ -1,24 +1,26 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController' import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react' import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'
import { ContentType } from '@standardnotes/snjs'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
type Props = { type Props = {
item: LinkableItem link: ItemLink
getItemIcon: LinkingController['getLinkedItemIcon'] getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag'] getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
activateItem: (item: LinkableItem) => Promise<void> activateItem: (item: LinkableItem) => Promise<void>
unlinkItem: (item: LinkableItem) => void unlinkItem: LinkingController['unlinkItemFromSelectedItem']
focusPreviousItem: () => void focusPreviousItem: () => void
focusNextItem: () => void focusNextItem: () => void
focusedId: string | undefined focusedId: string | undefined
setFocusedId: (id: string) => void setFocusedId: (id: string) => void
isBidirectional: boolean
} }
const LinkedItemBubble = ({ const LinkedItemBubble = ({
item, link,
getItemIcon, getItemIcon,
getTitleForLinkedTag, getTitleForLinkedTag,
activateItem, activateItem,
@@ -27,6 +29,7 @@ const LinkedItemBubble = ({
focusNextItem, focusNextItem,
focusedId, focusedId,
setFocusedId, setFocusedId,
isBidirectional,
}: Props) => { }: Props) => {
const ref = useRef<HTMLButtonElement>(null) const ref = useRef<HTMLButtonElement>(null)
@@ -36,8 +39,8 @@ const LinkedItemBubble = ({
const [wasClicked, setWasClicked] = useState(false) const [wasClicked, setWasClicked] = useState(false)
const handleFocus = () => { const handleFocus = () => {
if (focusedId !== item.uuid) { if (focusedId !== link.id) {
setFocusedId(item.uuid) setFocusedId(link.id)
} }
setShowUnlinkButton(true) setShowUnlinkButton(true)
} }
@@ -50,7 +53,7 @@ const LinkedItemBubble = ({
const onClick: MouseEventHandler = (event) => { const onClick: MouseEventHandler = (event) => {
if (wasClicked && event.target !== unlinkButtonRef.current) { if (wasClicked && event.target !== unlinkButtonRef.current) {
setWasClicked(false) setWasClicked(false)
void activateItem(item) void activateItem(link.item)
} else { } else {
setWasClicked(true) setWasClicked(true)
} }
@@ -58,14 +61,14 @@ const LinkedItemBubble = ({
const onUnlinkClick: MouseEventHandler = (event) => { const onUnlinkClick: MouseEventHandler = (event) => {
event.stopPropagation() event.stopPropagation()
unlinkItem(item) unlinkItem(link)
} }
const onKeyDown: KeyboardEventHandler = (event) => { const onKeyDown: KeyboardEventHandler = (event) => {
switch (event.key) { switch (event.key) {
case KeyboardKey.Backspace: { case KeyboardKey.Backspace: {
focusPreviousItem() focusPreviousItem()
unlinkItem(item) unlinkItem(link)
break break
} }
case KeyboardKey.Left: case KeyboardKey.Left:
@@ -77,29 +80,34 @@ const LinkedItemBubble = ({
} }
} }
const [icon, iconClassName] = getItemIcon(item) const [icon, iconClassName] = getItemIcon(link.item)
const tagTitle = getTitleForLinkedTag(item) const tagTitle = getTitleForLinkedTag(link.item)
useEffect(() => { useEffect(() => {
if (item.uuid === focusedId) { if (link.id === focusedId) {
ref.current?.focus() ref.current?.focus()
} }
}, [focusedId, item.uuid]) }, [focusedId, link.id])
return ( return (
<button <button
ref={ref} 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} onFocus={handleFocus}
onBlur={onBlur} onBlur={onBlur}
onClick={onClick} onClick={onClick}
title={tagTitle ? tagTitle.longTitle : item.title} title={tagTitle ? tagTitle.longTitle : link.item.title}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
> >
<Icon type={icon} className={classNames('mr-1 flex-shrink-0', iconClassName)} size="small" /> <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>} {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> </span>
{showUnlinkButton && ( {showUnlinkButton && (
<a <a

View File

@@ -1,6 +1,6 @@
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput' import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController' import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import LinkedItemBubble from './LinkedItemBubble' import LinkedItemBubble from './LinkedItemBubble'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider' import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
@@ -14,8 +14,9 @@ type Props = {
const LinkedItemBubblesContainer = ({ linkingController }: Props) => { const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
const { toggleAppPane } = useResponsiveAppPane() const { toggleAppPane } = useResponsiveAppPane()
const { const {
allLinkedItems, allItemLinks,
notesLinkingToItem, notesLinkingToActiveItem,
filesLinkingToActiveItem,
unlinkItemFromSelectedItem: unlinkItem, unlinkItemFromSelectedItem: unlinkItem,
getTitleForLinkedTag, getTitleForLinkedTag,
getLinkedItemIcon: getItemIcon, getLinkedItemIcon: getItemIcon,
@@ -23,7 +24,13 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
} = linkingController } = linkingController
const [focusedId, setFocusedId] = useState<string>() 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 focusPreviousItem = useCallback(() => {
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId) const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
@@ -53,27 +60,38 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
[activateItem, toggleAppPane], [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 ( return (
<div <div
className={classNames( className={classNames(
'hidden min-w-80 max-w-full flex-wrap items-center gap-2 bg-transparent md:-mr-2 md:flex', '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) => ( {allItemLinks
<LinkedItemBubble .concat(notesLinkingToActiveItem)
item={item} .concat(filesLinkingToActiveItem)
key={item.uuid} .map((link) => (
getItemIcon={getItemIcon} <LinkedItemBubble
getTitleForLinkedTag={getTitleForLinkedTag} link={link}
activateItem={activateItemAndTogglePane} key={link.id}
unlinkItem={unlinkItem} getItemIcon={getItemIcon}
focusPreviousItem={focusPreviousItem} getTitleForLinkedTag={getTitleForLinkedTag}
focusNextItem={focusNextItem} activateItem={activateItemAndTogglePane}
focusedId={focusedId} unlinkItem={unlinkItem}
setFocusedId={setFocusedId} focusPreviousItem={focusPreviousItem}
/> focusNextItem={focusNextItem}
))} focusedId={focusedId}
setFocusedId={setFocusedId}
isBidirectional={isItemBidirectionallyLinked(link)}
/>
))}
<ItemLinkAutocompleteInput <ItemLinkAutocompleteInput
focusedId={focusedId} focusedId={focusedId}
linkingController={linkingController} linkingController={linkingController}

View File

@@ -33,7 +33,7 @@ const LinkedItemsSectionItem = ({
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag'] getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
item: LinkableItem item: LinkableItem
searchQuery?: string searchQuery?: string
unlinkItem: LinkingController['unlinkItemFromSelectedItem'] unlinkItem: () => void
handleFileAction: FilesController['handleFileAction'] handleFileAction: FilesController['handleFileAction']
}) => { }) => {
const menuButtonRef = useRef<HTMLButtonElement>(null) const menuButtonRef = useRef<HTMLButtonElement>(null)
@@ -85,7 +85,7 @@ const LinkedItemsSectionItem = ({
</div> </div>
) : ( ) : (
<button <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)} onClick={() => activateItem(item)}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
@@ -118,7 +118,7 @@ const LinkedItemsSectionItem = ({
<MenuItem <MenuItem
type={MenuItemType.IconButton} type={MenuItemType.IconButton}
onClick={() => { onClick={() => {
unlinkItem(item) unlinkItem()
toggleMenu() toggleMenu()
}} }}
> >
@@ -166,10 +166,11 @@ const LinkedItemsPanel = ({
}) => { }) => {
const { const {
tags, tags,
files, linkedFiles,
filesLinkingToActiveItem,
notesLinkedToItem, notesLinkedToItem,
notesLinkingToItem, notesLinkingToActiveItem,
allLinkedItems, allItemLinks: allLinkedItems,
getTitleForLinkedTag, getTitleForLinkedTag,
getLinkedItemIcon, getLinkedItemIcon,
getSearchResults, getSearchResults,
@@ -196,7 +197,7 @@ const LinkedItemsPanel = ({
<form <form
className={classNames( className={classNames(
'sticky top-0 z-10 bg-default px-2.5 pt-2.5', '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' ? 'border-b border-border pb-2.5'
: 'pb-1', : 'pb-1',
)} )}
@@ -246,14 +247,14 @@ const LinkedItemsPanel = ({
<div> <div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked</div> <div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked</div>
<div className="my-1"> <div className="my-1">
{linkedResults.map((item) => ( {linkedResults.map((link) => (
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={item.uuid} key={link.id}
item={item} item={link.item}
getItemIcon={getLinkedItemIcon} getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag} getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
handleFileAction={filesController.handleFileAction} handleFileAction={filesController.handleFileAction}
/> />
@@ -268,14 +269,14 @@ const LinkedItemsPanel = ({
<div> <div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Tags</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"> <div className="my-1">
{tags.map((item) => ( {tags.map((link) => (
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={item.uuid} key={link.id}
item={item} item={link.item}
getItemIcon={getLinkedItemIcon} getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag} getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
handleFileAction={filesController.handleFileAction} handleFileAction={filesController.handleFileAction}
/> />
@@ -283,18 +284,39 @@ const LinkedItemsPanel = ({
</div> </div>
</div> </div>
)} )}
{!!files.length && ( {!!linkedFiles.length && (
<div> <div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</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"> <div className="my-1">
{files.map((item) => ( {linkedFiles.map((link) => (
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={item.uuid} key={link.id}
item={item} item={link.item}
getItemIcon={getLinkedItemIcon} getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag} getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} 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} activateItem={activateItem}
handleFileAction={filesController.handleFileAction} handleFileAction={filesController.handleFileAction}
/> />
@@ -306,14 +328,14 @@ const LinkedItemsPanel = ({
<div> <div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Notes</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"> <div className="my-1">
{notesLinkedToItem.map((item) => ( {notesLinkedToItem.map((link) => (
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={item.uuid} key={link.id}
item={item} item={link.item}
getItemIcon={getLinkedItemIcon} getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag} getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
handleFileAction={filesController.handleFileAction} handleFileAction={filesController.handleFileAction}
/> />
@@ -321,20 +343,20 @@ const LinkedItemsPanel = ({
</div> </div>
</div> </div>
)} )}
{!!notesLinkingToItem.length && ( {!!notesLinkingToActiveItem.length && (
<div> <div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0"> <div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
Notes Linking To This Note Notes Linking To This Note
</div> </div>
<div className="my-1"> <div className="my-1">
{notesLinkingToItem.map((item) => ( {notesLinkingToActiveItem.map((link) => (
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={item.uuid} key={link.id}
item={item} item={link.item}
getItemIcon={getLinkedItemIcon} getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag} getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
handleFileAction={filesController.handleFileAction} handleFileAction={filesController.handleFileAction}
/> />

View File

@@ -99,7 +99,7 @@ export class FilesController extends AbstractViewController {
reloadAttachedFiles = () => { reloadAttachedFiles = () => {
const note = this.notesController.firstSelectedNote const note = this.notesController.firstSelectedNote
if (note) { if (note) {
this.attachedFiles = this.application.items.getSortedFilesForItem(note) this.attachedFiles = this.application.items.getSortedFilesLinkingToItem(note)
} }
} }

View File

@@ -10,7 +10,9 @@ import {
IconType, IconType,
InternalEventBus, InternalEventBus,
ItemContent, ItemContent,
ItemsClientInterface,
naturalSort, naturalSort,
NoteViewController,
PrefKey, PrefKey,
SNNote, SNNote,
SNTag, SNTag,
@@ -25,11 +27,20 @@ import { SubscriptionController } from './Subscription/SubscriptionController'
export type LinkableItem = DecryptedItemInterface<ItemContent> 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 { export class LinkingController extends AbstractViewController {
tags: SNTag[] = [] tags: ItemLink<SNTag>[] = []
files: FileItem[] = [] linkedFiles: ItemLink<FileItem>[] = []
notesLinkedToItem: SNNote[] = [] filesLinkingToActiveItem: ItemLink<FileItem>[] = []
notesLinkingToItem: SNNote[] = [] notesLinkedToItem: ItemLink<SNNote>[] = []
notesLinkingToActiveItem: ItemLink<SNNote>[] = []
shouldLinkToParentFolders: boolean shouldLinkToParentFolders: boolean
isLinkingPanelOpen = false isLinkingPanelOpen = false
private itemListController!: ItemListController private itemListController!: ItemListController
@@ -46,13 +57,15 @@ export class LinkingController extends AbstractViewController {
makeObservable(this, { makeObservable(this, {
tags: observable, tags: observable,
files: observable, linkedFiles: observable,
filesLinkingToActiveItem: observable,
notesLinkedToItem: observable, notesLinkedToItem: observable,
notesLinkingToItem: observable, notesLinkingToActiveItem: observable,
isLinkingPanelOpen: observable, isLinkingPanelOpen: observable,
allLinkedItems: computed, allItemLinks: computed,
isEntitledToNoteLinking: computed, isEntitledToNoteLinking: computed,
selectedItemTitle: computed,
setIsLinkingPanelOpen: action, setIsLinkingPanelOpen: action,
reloadLinkedFiles: action, reloadLinkedFiles: action,
@@ -107,14 +120,22 @@ export class LinkingController extends AbstractViewController {
this.isLinkingPanelOpen = open this.isLinkingPanelOpen = open
} }
get allLinkedItems() { get allItemLinks() {
return [...this.tags, ...this.files, ...this.notesLinkedToItem] return [...this.tags, ...this.linkedFiles, ...this.notesLinkedToItem]
} }
get activeItem() { get activeItem() {
return this.itemListController.activeControllerItem return this.itemListController.activeControllerItem
} }
get selectedItemTitle() {
return this.selectionController.firstSelectedItem
? this.selectionController.firstSelectedItem.title
: this.activeItem
? this.activeItem.title
: ''
}
reloadAllLinks() { reloadAllLinks() {
this.reloadLinkedFiles() this.reloadLinkedFiles()
this.reloadLinkedTags() this.reloadLinkedTags()
@@ -122,31 +143,67 @@ export class LinkingController extends AbstractViewController {
this.reloadNotesLinkingToItem() 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() { reloadLinkedFiles() {
if (this.activeItem) { if (!this.activeItem) {
const files = this.application.items.getSortedFilesForItem(this.activeItem) return
this.files = files }
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() { reloadLinkedTags() {
if (this.activeItem) { 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 this.tags = tags
} }
} }
reloadLinkedNotes() { reloadLinkedNotes() {
if (this.activeItem) { 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 this.notesLinkedToItem = notes
} }
} }
reloadNotesLinkingToItem() { reloadNotesLinkingToItem() {
if (this.activeItem) { if (this.activeItem) {
const notes = this.application.items.getSortedNotesLinkingToItem(this.activeItem) const notes = this.application.items
this.notesLinkingToItem = notes .getSortedNotesLinkingToItem(this.activeItem)
.map((item) => this.createLinkFromItem(item, 'indirect'))
this.notesLinkingToActiveItem = notes
} }
} }
@@ -206,44 +263,61 @@ export class LinkingController extends AbstractViewController {
return undefined 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 const selectedItem = this.selectionController.firstSelectedItem
if (!selectedItem) { if (!selectedItem) {
return return
} }
const selectedItemReferencesItemToUnlink = const selectedItemReferencesItemToUnlink = itemToUnlink.relationWithSelectedItem === 'direct'
this.application.items.relationshipTypeForItems(selectedItem, itemToUnlink) === 'direct'
if (selectedItemReferencesItemToUnlink) { if (selectedItemReferencesItemToUnlink) {
await this.application.items.unlinkItem(selectedItem, itemToUnlink) await this.application.items.unlinkItem(selectedItem, itemToUnlink.item)
} else { } else {
await this.application.items.unlinkItem(itemToUnlink, selectedItem) await this.application.items.unlinkItem(itemToUnlink.item, selectedItem)
} }
void this.application.sync.sync() void this.application.sync.sync()
this.reloadAllLinks() this.reloadAllLinks()
} }
linkItemToSelectedItem = async (itemToLink: LinkableItem) => { ensureActiveItemIsInserted = async () => {
const selectedItem = this.selectionController.firstSelectedItem const activeItemController = this.itemListController.getActiveItemController()
if (activeItemController instanceof NoteViewController && activeItemController.isTemplateNote) {
await activeItemController.insertTemplatedNote()
}
}
if (itemToLink instanceof SNTag) { linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
await this.addTagToActiveItem(itemToLink) 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) { 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) { } 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) { if (itemToLink instanceof SNNote) {
await this.application.items.associateFileWithNote(selectedItem, itemToLink) await this.application.items.associateFileWithNote(activeItem, itemToLink)
} else if (itemToLink instanceof FileItem) { } 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) => { createAndAddNewTag = async (title: string) => {
await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem
const newTag = await this.application.mutator.findOrCreateTag(title) const newTag = await this.application.mutator.findOrCreateTag(title)
await this.addTagToActiveItem(newTag) if (activeItem) {
await this.addTagToItem(newTag, activeItem)
}
} }
addTagToActiveItem = async (tag: SNTag) => { addTagToItem = async (tag: SNTag, item: FileItem | SNNote) => {
const activeItem = this.itemListController.activeControllerItem if (item instanceof SNNote) {
await this.application.items.addTagToNote(item, tag, this.shouldLinkToParentFolders)
if (!activeItem) { } else if (item instanceof FileItem) {
return await this.application.items.addTagToFile(item, tag, this.shouldLinkToParentFolders)
}
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)
} }
this.reloadLinkedTags() this.reloadLinkedTags()
@@ -293,19 +365,23 @@ export class LinkingController extends AbstractViewController {
'title', 'title',
) )
const isAlreadyLinked = (item: LinkableItem) => { const isAlreadyLinked = (item: DecryptedItemInterface<ItemContent>) => {
if (!this.activeItem) { if (!this.activeItem) {
return false return false
} }
const isItemReferencedByActiveItem = this.application.items const isItemReferencedByActiveItem = this.application.items
.itemsReferencingItem(item) .itemsReferencingItem(item)
.some((linkedItem) => linkedItem.uuid === this.activeItem?.uuid) .some((linkedItem) => linkedItem.uuid === this.activeItem?.uuid)
const isActiveItemReferencedByItem = this.application.items const isActiveItemReferencedByItem = this.application.items
.itemsReferencingItem(this.activeItem) .itemsReferencingItem(this.activeItem)
.some((linkedItem) => linkedItem.uuid === item.uuid) .some((linkedItem) => linkedItem.uuid === item.uuid)
const isAlreadyLinkedToItem =
isItemReferencedByActiveItem || (item.content_type !== ContentType.Note && isActiveItemReferencedByItem) if (this.activeItem.content_type === item.content_type) {
return isAlreadyLinkedToItem return isItemReferencedByActiveItem
}
return isActiveItemReferencedByItem || isItemReferencedByActiveItem
} }
const prioritizeTagResult = ( const prioritizeTagResult = (
@@ -325,10 +401,14 @@ export class LinkingController extends AbstractViewController {
.slice(0, 20) .slice(0, 20)
.filter((item) => !isAlreadyLinked(item)) .filter((item) => !isAlreadyLinked(item))
.sort(prioritizeTagResult) .sort(prioritizeTagResult)
const linkedResults = searchResults.filter(isAlreadyLinked).slice(0, 20) const linkedResults = searchResults
const isResultExistingTag = (result: LinkableItem) => .filter(isAlreadyLinked)
.slice(0, 20)
.map((item) => this.createLinkFromItem(item))
const isResultExistingTag = (result: DecryptedItemInterface<ItemContent>) =>
result.content_type === ContentType.Tag && result.title === searchQuery 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 { return {
unlinkedResults, unlinkedResults,