413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
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<ItemContent>
|
|
|
|
export type ItemLink<ItemType extends LinkableItem = LinkableItem> = {
|
|
id: string
|
|
item: ItemType
|
|
type: 'linked' | 'linked-by'
|
|
}
|
|
|
|
export class LinkingController extends AbstractViewController {
|
|
tags: ItemLink<SNTag>[] = []
|
|
linkedFiles: ItemLink<FileItem>[] = []
|
|
filesLinkingToActiveItem: ItemLink<FileItem>[] = []
|
|
notesLinkedToItem: ItemLink<SNNote>[] = []
|
|
notesLinkingToActiveItem: ItemLink<SNNote>[] = []
|
|
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 = <I extends LinkableItem = LinkableItem>(itemA: I, type: 'linked' | 'linked-by'): ItemLink<I> => {
|
|
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<AppPaneId | undefined> => {
|
|
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<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)
|
|
|
|
if (this.activeItem.content_type === item.content_type) {
|
|
return isItemReferencedByActiveItem
|
|
}
|
|
|
|
return isActiveItemReferencedByItem || isItemReferencedByActiveItem
|
|
}
|
|
|
|
const prioritizeTagResult = (
|
|
itemA: DecryptedItemInterface<ItemContent>,
|
|
itemB: DecryptedItemInterface<ItemContent>,
|
|
) => {
|
|
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<ItemContent>) =>
|
|
result.content_type === ContentType.Tag && result.title === searchQuery
|
|
|
|
const shouldShowCreateTag =
|
|
!linkedResults.find((link) => isResultExistingTag(link.item)) && !unlinkedResults.find(isResultExistingTag)
|
|
|
|
return {
|
|
unlinkedResults,
|
|
linkedResults,
|
|
shouldShowCreateTag,
|
|
}
|
|
}
|
|
}
|