diff --git a/src/ImgurPlugin.ts b/src/ImgurPlugin.ts index 2280320..52d3996 100644 --- a/src/ImgurPlugin.ts +++ b/src/ImgurPlugin.ts @@ -6,6 +6,7 @@ import { Menu, Notice, Plugin, + ReferenceCache, TFile, parseLinktext, } from 'obsidian' @@ -26,6 +27,8 @@ import { fixImageTypeIfNeeded } from './utils/misc' import { createImgurCanvasPasteHandler } from './Canvas' import { IMGUR_POTENTIALLY_SUPPORTED_FILES_EXTENSIONS } from './imgur/constants' import { localEmbeddedImageExpectedBoundaries } from './utils/editor' +import UpdateLinksConfirmationModal from './ui/UpdateLinksConfirmationModal' +import InfoModal from './ui/InfoModal' declare module 'obsidian' { interface MarkdownSubView { @@ -219,11 +222,106 @@ export default class ImgurPlugin extends Plugin { file, localImageExpectedStart, localImageExpectedEnd, + ).then((imageUrl) => + this.proposeToReplaceOtherLocalLinksIfAny(file, imageUrl, { + path: view.file.path, + startPosition: localImageExpectedStart, + }), ), ) }) } + private proposeToReplaceOtherLocalLinksIfAny( + originalLocalFile: TFile, + remoteImageUrl: string, + originalReference: { path: string; startPosition: EditorPosition }, + ) { + const otherReferencesByNote = this.getAllCachedReferencesForFile(originalLocalFile) + removeReferenceToOriginalNoteIfPresent(otherReferencesByNote, originalReference) + + const notesWithSameLocalFile = Object.keys(otherReferencesByNote) + if (notesWithSameLocalFile.length === 0) return + + this.showLinksUpdateDialog(originalLocalFile, remoteImageUrl, otherReferencesByNote) + } + + private getAllCachedReferencesForFile(file: TFile) { + const allLinks = this.app.metadataCache.resolvedLinks + + const notesWithLinks = [] + for (const [notePath, noteLinks] of Object.entries(allLinks)) { + for (const [linkName] of Object.entries(noteLinks)) { + if (linkName === file.name) notesWithLinks.push(notePath) + } + } + + const linksByNote = notesWithLinks.reduce( + (acc, note) => { + const noteMetadata = this.app.metadataCache.getCache(note) + const noteLinks = noteMetadata.embeds + if (noteLinks) { + acc[note] = noteLinks.filter((l) => l.link === file.name) + } + return acc + }, + {} as Record, + ) + return linksByNote + } + + private showLinksUpdateDialog( + localFile: TFile, + remoteImageUrl: string, + otherReferencesByNote: Record, + ) { + const stats = getFilesAndLinksStats(otherReferencesByNote) + const dialogBox = new UpdateLinksConfirmationModal(this.app, localFile.path, stats) + dialogBox.onDoNotUpdateClick(() => dialogBox.close()) + dialogBox.onDoUpdateClick(() => { + dialogBox.disableButtons() + dialogBox.setContent('Working...') + this.replaceAllLocalReferencesWithRemoteOne(otherReferencesByNote, remoteImageUrl) + .catch((e) => { + new InfoModal( + this.app, + 'Error', + 'Unexpected error occurred, check Developer Tools console for details', + ).open() + console.error('Something bad happened during links update', e) + }) + .finally(() => dialogBox.close()) + new Notice(`Updated ${stats.linksCount} links in ${stats.filesCount} files`) + }) + dialogBox.open() + } + + private async replaceAllLocalReferencesWithRemoteOne( + referencesByNotes: Record, + remoteImageUrl: string, + ) { + for (const [notePath, refs] of Object.entries(referencesByNotes)) { + const noteFile = this.app.vault.getFileByPath(notePath) + const refsStartOffsetsSortedDescending = refs + .map((ref) => ({ + start: ref.position.start.offset, + end: ref.position.end.offset, + })) + .sort((ref1, ref2) => ref2.start - ref1.start) + + await this.app.vault.process(noteFile, (noteContent) => { + let updatedContent = noteContent + refsStartOffsetsSortedDescending.forEach((refPos) => { + updatedContent = + updatedContent.substring(0, refPos.start) + + `![](${remoteImageUrl})` + + updatedContent.substring(refPos.end) + }) + return updatedContent + }) + } + } + private async uploadLocalImageFromEditor( editor: Editor, file: TFile, @@ -343,6 +441,7 @@ export default class ImgurPlugin extends Plugin { throw e } this.embedMarkDownImage(pasteId, imgUrl) + return imgUrl } private insertTemporaryText(pasteId: string, atPos?: EditorPosition) { @@ -390,3 +489,34 @@ export default class ImgurPlugin extends Plugin { } } } + +function removeReferenceToOriginalNoteIfPresent( + otherReferencesByNote: Record, + originalNote: { path: string; startPosition: EditorPosition }, +) { + if (!Object.keys(otherReferencesByNote).includes(originalNote.path)) return + + const refsFromOriginalNote = otherReferencesByNote[originalNote.path] + const originalRefStart = originalNote.startPosition + const refForExclusion = refsFromOriginalNote.find( + (r) => + r.position.start.line === originalRefStart.line && + r.position.start.col === originalRefStart.ch, + ) + if (refForExclusion) { + refsFromOriginalNote.remove(refForExclusion) + if (refsFromOriginalNote.length === 0) { + delete otherReferencesByNote[originalNote.path] + } + } +} + +function getFilesAndLinksStats(otherReferencesByNote: Record) { + return { + filesCount: Object.keys(otherReferencesByNote).length, + linksCount: Object.values(otherReferencesByNote).reduce( + (count, refs) => count + refs.length, + 0, + ), + } +} diff --git a/src/ui/InfoModal.ts b/src/ui/InfoModal.ts new file mode 100644 index 0000000..44793db --- /dev/null +++ b/src/ui/InfoModal.ts @@ -0,0 +1,13 @@ +import { App, ButtonComponent, Modal } from 'obsidian' + +export default class InfoModal extends Modal { + constructor(app: App, title: string, message: string) { + super(app) + + this.setTitle(title) + this.contentEl.createEl('p', { text: message }) + + const buttonsDiv = this.modalEl.createDiv('modal-button-container') + new ButtonComponent(buttonsDiv).setButtonText('Ok') + } +} diff --git a/src/ui/UpdateLinksConfirmationModal.ts b/src/ui/UpdateLinksConfirmationModal.ts new file mode 100644 index 0000000..5dd880f --- /dev/null +++ b/src/ui/UpdateLinksConfirmationModal.ts @@ -0,0 +1,36 @@ +import { App, ButtonComponent, Modal } from 'obsidian' + +export default class UpdateLinksConfirmationModal extends Modal { + private updateOnceButton: ButtonComponent + private doNotUpdateButton: ButtonComponent + + constructor(app: App, localFileName: string, stats: { filesCount: number; linksCount: number }) { + super(app) + + this.setTitle('Replace links in vault') + + this.contentEl.createEl('p', { + text: `Do you want to replace internal links that link to original local file (${localFileName}) with remote link?`, + }) + this.contentEl.createEl('p', { + text: `This will affect ${stats.linksCount} links in ${stats.filesCount} files.`, + }) + + const buttonsDiv = this.modalEl.createDiv('modal-button-container') + this.updateOnceButton = new ButtonComponent(buttonsDiv).setButtonText('Yes') + this.doNotUpdateButton = new ButtonComponent(buttonsDiv).setButtonText('Do not update') + } + + onDoUpdateClick(callback: (evt: MouseEvent) => any) { + this.updateOnceButton.onClick(callback) + } + + onDoNotUpdateClick(callback: (evt: MouseEvent) => any) { + this.doNotUpdateButton.onClick(callback) + } + + disableButtons() { + this.updateOnceButton.setDisabled(true) + this.doNotUpdateButton.setDisabled(true) + } +}