import { WebApplication } from '@/Application/Application' import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction' import { AppPaneId } from '@/Components/ResponsivePane/AppPaneMetadata' import { PrefDefaults } from '@/Constants/PrefDefaults' import { ApplicationEvent, ContentType, DecryptedItemInterface, FileItem, IconType, InternalEventBus, ItemContent, naturalSort, NoteViewController, PrefKey, SNNote, SNTag, isFile, isNote, } from '@standardnotes/snjs' import { action, computed, makeObservable, observable } from 'mobx' import { AbstractViewController } from './Abstract/AbstractViewController' import { FilesController } from './FilesController' import { ItemListController } from './ItemList/ItemListController' import { NavigationController } from './Navigation/NavigationController' import { SelectedItemsController } from './SelectedItemsController' import { SubscriptionController } from './Subscription/SubscriptionController' export type LinkableItem = DecryptedItemInterface export type ItemLink = { id: string item: ItemType type: 'linked' | 'linked-by' } export class LinkingController extends AbstractViewController { tags: ItemLink[] = [] linkedFiles: ItemLink[] = [] filesLinkingToActiveItem: ItemLink[] = [] notesLinkedToItem: ItemLink[] = [] notesLinkingToActiveItem: ItemLink[] = [] shouldLinkToParentFolders: boolean isLinkingPanelOpen = false private itemListController!: ItemListController private filesController!: FilesController private subscriptionController!: SubscriptionController constructor( application: WebApplication, private navigationController: NavigationController, private selectionController: SelectedItemsController, eventBus: InternalEventBus, ) { super(application, eventBus) makeObservable(this, { tags: observable, linkedFiles: observable, filesLinkingToActiveItem: observable, notesLinkedToItem: observable, notesLinkingToActiveItem: observable, isLinkingPanelOpen: observable, allItemLinks: computed, isEntitledToNoteLinking: computed, selectedItemTitle: computed, setIsLinkingPanelOpen: action, reloadLinkedFiles: action, reloadLinkedTags: action, reloadLinkedNotes: action, reloadNotesLinkingToItem: action, }) this.shouldLinkToParentFolders = application.getPreference( PrefKey.NoteAddToParentFolders, PrefDefaults[PrefKey.NoteAddToParentFolders], ) this.disposers.push( this.application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => { this.shouldLinkToParentFolders = this.application.getPreference( PrefKey.NoteAddToParentFolders, PrefDefaults[PrefKey.NoteAddToParentFolders], ) }), ) } public setServicesPostConstruction( itemListController: ItemListController, filesController: FilesController, subscriptionController: SubscriptionController, ) { this.itemListController = itemListController this.filesController = filesController this.subscriptionController = subscriptionController this.disposers.push( this.application.streamItems(ContentType.File, () => { this.reloadLinkedFiles() }), this.application.streamItems(ContentType.Tag, () => { this.reloadLinkedTags() }), this.application.streamItems(ContentType.Note, () => { this.reloadLinkedNotes() this.reloadNotesLinkingToItem() }), ) } get isEntitledToNoteLinking() { return !!this.subscriptionController.userSubscription } setIsLinkingPanelOpen = (open: boolean) => { this.isLinkingPanelOpen = open } 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() this.reloadLinkedNotes() this.reloadNotesLinkingToItem() } createLinkFromItem = (itemA: I, type: 'linked' | 'linked-by'): ItemLink => { return { id: `${itemA.uuid}-${type}`, item: itemA, type, } } reloadLinkedFiles() { if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) { return } const referencesOfActiveItem = naturalSort( this.application.items.referencesForItem(this.activeItem).filter(isFile), 'title', ) const referencingActiveItem = naturalSort( this.application.items.itemsReferencingItem(this.activeItem).filter(isFile), 'title', ) if (this.activeItem.content_type === ContentType.File) { this.linkedFiles = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked')) this.filesLinkingToActiveItem = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by')) } else { this.linkedFiles = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked')) this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by')) } } reloadLinkedTags() { if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) { return } this.tags = this.application.items .getSortedTagsForItem(this.activeItem) .map((item) => this.createLinkFromItem(item, 'linked')) } reloadLinkedNotes() { if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) { return } this.notesLinkedToItem = naturalSort( this.application.items.referencesForItem(this.activeItem).filter(isNote), 'title', ).map((item) => this.createLinkFromItem(item, 'linked')) } reloadNotesLinkingToItem() { if (!this.activeItem) { return } this.notesLinkingToActiveItem = naturalSort( this.application.items.itemsReferencingItem(this.activeItem).filter(isNote), 'title', ).map((item) => this.createLinkFromItem(item, 'linked-by')) } getTitleForLinkedTag = (item: LinkableItem) => { const isTag = item instanceof SNTag if (!isTag) { return } const titlePrefix = this.application.items.getTagPrefixTitle(item) const longTitle = this.application.items.getTagLongTitle(item) return { titlePrefix, longTitle, } } getLinkedItemIcon = (item: LinkableItem): [IconType, string] => { if (item instanceof SNNote) { const editorForNote = this.application.componentManager.editorForNote(item) const [icon, tint] = this.application.iconsController.getIconAndTintForNoteType( editorForNote?.package_info.note_type, ) const className = `text-accessory-tint-${tint}` return [icon, className] } else if (item instanceof FileItem) { const icon = this.application.iconsController.getIconForFileType(item.mimeType) return [icon, 'text-info'] } return ['hashtag', 'text-info'] } activateItem = async (item: LinkableItem): Promise => { this.setIsLinkingPanelOpen(false) if (item instanceof SNTag) { await this.navigationController.setSelectedTag(item) return AppPaneId.Items } else if (item instanceof SNNote) { await this.navigationController.selectHomeNavigationView() const { didSelect } = await this.selectionController.selectItem(item.uuid, true) if (didSelect) { return AppPaneId.Editor } } else if (item instanceof FileItem) { await this.filesController.handleFileAction({ type: PopoverFileItemActionType.PreviewFile, payload: { file: item, otherFiles: [], }, }) } return undefined } unlinkItemFromSelectedItem = async (itemToUnlink: ItemLink) => { const selectedItem = this.selectionController.firstSelectedItem if (!selectedItem) { return } await this.application.items.unlinkItems(selectedItem, itemToUnlink.item) void this.application.sync.sync() this.reloadAllLinks() } ensureActiveItemIsInserted = async () => { const activeItemController = this.itemListController.getActiveItemController() if (activeItemController instanceof NoteViewController && activeItemController.isTemplateNote) { await activeItemController.insertTemplatedNote() } } linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => { if (item instanceof SNNote) { if (itemToLink instanceof FileItem) { await this.application.items.associateFileWithNote(itemToLink, item) } else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) { await this.application.items.linkNoteToNote(item, itemToLink) } else if (itemToLink instanceof SNTag) { await this.addTagToItem(itemToLink, item) } } else if (item instanceof FileItem) { if (itemToLink instanceof SNNote) { await this.application.items.associateFileWithNote(item, itemToLink) } else if (itemToLink instanceof FileItem) { await this.application.items.linkFileToFile(item, itemToLink) } else if (itemToLink instanceof SNTag) { await this.addTagToItem(itemToLink, item) } } void this.application.sync.sync() this.reloadAllLinks() } linkItemToSelectedItem = async (itemToLink: LinkableItem) => { await this.ensureActiveItemIsInserted() const activeItem = this.activeItem if (!activeItem) { return } await this.linkItems(activeItem, itemToLink) } createAndAddNewTag = async (title: string) => { await this.ensureActiveItemIsInserted() const activeItem = this.activeItem const newTag = await this.application.mutator.findOrCreateTag(title) if (activeItem) { await this.addTagToItem(newTag, activeItem) } } 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() this.application.sync.sync().catch(console.error) } getSearchResults = (searchQuery: string) => { if (!searchQuery.length) { return { linkedResults: [], unlinkedResults: [], shouldShowCreateTag: false, } } 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 }), 'title', ) const isAlreadyLinked = (item: DecryptedItemInterface) => { 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) if (this.activeItem.content_type === item.content_type) { return isItemReferencedByActiveItem } return isActiveItemReferencedByItem || isItemReferencedByActiveItem } const prioritizeTagResult = ( itemA: DecryptedItemInterface, itemB: DecryptedItemInterface, ) => { if (itemA.content_type === ContentType.Tag && itemB.content_type !== ContentType.Tag) { return -1 } if (itemB.content_type === ContentType.Tag && itemA.content_type !== ContentType.Tag) { return 1 } 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')) const isResultExistingTag = (result: DecryptedItemInterface) => result.content_type === ContentType.Tag && result.title === searchQuery const shouldShowCreateTag = !linkedResults.find((link) => isResultExistingTag(link.item)) && !unlinkedResults.find(isResultExistingTag) return { unlinkedResults, linkedResults, shouldShowCreateTag, } } }