diff --git a/README.md b/README.md index 4d760e9..eb867fc 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This [Obsidian.md](https://obsidian.md) plugin fixes some problems of the core [ - [Heading/block links are not updated properly](https://forum.obsidian.md/t/note-composer-links-to-blocks-and-headers-should-be-updated-when-extracting/37534) - Cannot undo extraction in the destination file -Currently, it only supports extraction of the current selection or heading. +Currently, it only supports extraction of the current selection or heading (with and without extracting sub-headings). Use the core plugin for merging notes. ## Installation diff --git a/manifest.json b/manifest.json index 3eb41e9..039fb8b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "better-note-composer", "name": "Better Note Composer", - "version": "0.1.2", + "version": "0.2.1", "minAppVersion": "1.3.5", "description": "", "author": "Ryota Ushio", diff --git a/src/extract.ts b/src/extract.ts index 334fb20..01dab81 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -1,9 +1,10 @@ -import { BlockSubpathResult, CachedMetadata, Editor, FrontmatterLinkCache, HeadingSubpathResult, LinkCache, Loc, MarkdownView, PaneType, Reference, ReferenceCache, TFile, getLinkpath, parseLinktext, resolveSubpath } from 'obsidian'; +import { BlockSubpathResult, CachedMetadata, Editor, FrontmatterLinkCache, HeadingSubpathResult, LinkCache, Loc, MarkdownView, Notice, PaneType, Reference, ReferenceCache, TFile, getLinkpath, parseLinktext, resolveSubpath } from 'obsidian'; import { TransactionSpec, Line } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; import BetterNoteComposerPlugin from 'main'; -import { BetterNoteComposerComponent, contains, getDisplayText, isEmbed, isFrontmatterLinkCache, isHeading, isReferenceCache, offsetToLoc, replaceSubstringByPos } from 'utils'; +import { BetterNoteComposerComponent, contains, getDisplayText, getHeadingLevel, isEmbed, isFrontmatterLinkCache, isHeading, isReferenceCache, offsetToLoc, replaceSubstringByPos } from 'utils'; +import { get } from 'http'; export interface InFileRange { @@ -71,6 +72,60 @@ export class Extractor extends BetterNoteComposerComponent { return this.extract(srcRange, dstFile, cm, paneType); } + + // Extract heading and all its subheadings + async extractHeadingRecursive(srcFile: TFile, editor: Editor, dstFile: TFile, paneType: PaneType | boolean): Promise { + // @ts-ignore + const cm: EditorView = editor.cm; + const currentLine = cm.state.doc.lineAt(cm.state.selection.main.anchor); + + let currentHeadingLine: Line | null = null; + let currentHeaderLevel: number | null = null; + + currentHeadingLine = cm.state.doc.line(currentLine.number); + currentHeaderLevel = getHeadingLevel(currentHeadingLine.text); + + if (!currentHeaderLevel) { + new Notice(`${this.plugin.manifest.name}: Cannot extract non-heading line recursively`); + throw Error(`${this.plugin.manifest.name}: Cannot extract non-heading line recursively`); + } + + let nextHeadingLine: Line | null = null; + let inCodeBlock = false; + for (let i = currentLine.number + 1; i <= cm.state.doc.lines; i++) { + const line = cm.state.doc.line(i); + // Avoid matching with comments in a code block + if (line.text.startsWith('```')) { + inCodeBlock = !inCodeBlock; + } + + if (inCodeBlock) continue; + if (!isHeading(line.text)) continue; + + if (getHeadingLevel(line.text) <= currentHeaderLevel) { + nextHeadingLine = line; + break; + } + } + + if (inCodeBlock) { + new Notice(`${this.plugin.manifest.name}: Cannot extract heading recursively inside a code block`); + throw Error(`${this.plugin.manifest.name}: Cannot extract heading recursively inside a code block`); + } + + const numLines = cm.state.doc.lines; + const srcRange = { + file: srcFile, + start: currentHeadingLine + ? offsetToLoc(editor, currentHeadingLine.from) + : { line: 0, col: 0, offset: 0 }, + end: nextHeadingLine + ? offsetToLoc(editor, nextHeadingLine.from - 1) + : { line: numLines - 1, col: cm.state.doc.line(numLines).length, offset: cm.state.doc.length } + }; + + return this.extract(srcRange, dstFile, cm, paneType); + } } @@ -177,12 +232,33 @@ export class ExtractionTask extends BetterNoteComposerComponent { }; let replacement = ''; + const heading = cache.headings?.find((heading) => contains(this.extraction.srcRange, heading.position)); + const option = this.plugin.getReplacementText(); if (option !== 'none') { - const linkToExtraction = this.app.fileManager.generateMarkdownLink(this.extraction.dstFile, sourcePath); + let subpath = ''; + let alias = ''; + if (heading) { + if (this.plugin.getLinkToDestHeading()) { + subpath = '#' + heading.heading; + } + + if (this.plugin.getUseHeadingAsAlias()) { + alias = heading.heading; + } + } + + const linkToExtraction = this.app.fileManager.generateMarkdownLink(this.extraction.dstFile, sourcePath, subpath, alias); replacement = option === 'link' ? linkToExtraction : '!' + linkToExtraction; } + if (this.plugin.getKeepHeading()) { + if (heading) { + const headingText = "#".repeat(heading.level) + ' ' + heading.heading; + replacement = headingText + '\n\n' + replacement; + } + } + const replaceSrcRangeTransactionSpec: TransactionSpec = { changes: { from: this.extraction.srcRange.start.offset, @@ -320,5 +396,22 @@ export class ExtractionTask extends BetterNoteComposerComponent { return data; }); } + + if (this.plugin.getStayOnSourceFile()) { + await leaf.openFile(this.extraction.srcRange.file); + // Set cursor and scroll to the extracted position + // @ts-ignore + if (leaf.view instanceof MarkdownView) { + // @ts-ignore + const editor = leaf.view.editor; + const extractedLine = this.extraction.srcRange.start.line; + console.log(this.extraction) + console.log(extractedLine) + editor.setCursor({ line: extractedLine, ch: 0 }); + editor.scrollIntoView( { + from: { line: extractedLine, ch: 0 }, to: { line: extractedLine, ch: 0 } + }, true); + } + } } } diff --git a/src/main.ts b/src/main.ts index 298b24d..ce66e12 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { Extractor } from 'extract'; import { MarkdownFileChooserModal } from 'modals'; import { BetterNoteComposerEditorCommand } from 'commands'; import { BetterNoteComposerSettings, DEFAULT_SETTINGS, BetterNoteComposerSettingTab } from 'settings'; - +import { isHeading } from 'utils'; export default class BetterNoteComposerPlugin extends Plugin { settings: BetterNoteComposerSettings; @@ -63,6 +63,25 @@ export default class BetterNoteComposerPlugin extends Plugin { }); } }), + new BetterNoteComposerEditorCommand({ + id: 'extract-heading-recursive', + name: 'Extract this heading recursively...', + checker: (editor, info) => { + // @ts-ignore + const cm: EditorView = editor.cm; + const currentLine = cm.state.doc.lineAt(cm.state.selection.main.anchor); + let currentHeadingLine = cm.state.doc.line(currentLine.number); + + // Must click on the heading line to avoid matching with comments in a code block + return !!info.file && isHeading(currentHeadingLine.text); + }, + executor: (editor, info) => { + const srcFile = info.file!; + showModalAndRun(srcFile, (dstFile, evt) => { + this.extractor.extractHeadingRecursive(srcFile, editor, dstFile, Keymap.isModEvent(evt)) + }); + } + }), ]; commands.forEach((command) => this.addCommand(command.toCommand())); @@ -77,4 +96,20 @@ export default class BetterNoteComposerPlugin extends Plugin { ? (this.app.internalPlugins.plugins['note-composer'].instance.options.replacementText ?? 'link') : this.settings.replacementText; } + + getStayOnSourceFile(): boolean { + return this.settings.stayOnSourceFile; + } + + getKeepHeading(): boolean { + return this.settings.keepHeading; + } + + getLinkToDestHeading(): boolean { + return this.settings.linkToDestHeading; + } + + getUseHeadingAsAlias(): boolean { + return this.settings.useHeadingAsAlias; + } } diff --git a/src/settings.ts b/src/settings.ts index 0382cc6..c907a7d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -11,10 +11,18 @@ const REPLACEMENT_TEXT = { export interface BetterNoteComposerSettings { replacementText: keyof typeof REPLACEMENT_TEXT; + stayOnSourceFile: boolean; + keepHeading: boolean; + linkToDestHeading: boolean; + useHeadingAsAlias: boolean; } export const DEFAULT_SETTINGS: BetterNoteComposerSettings = { replacementText: 'same', + stayOnSourceFile: true, + keepHeading: true, + linkToDestHeading: true, + useHeadingAsAlias: true, }; export class BetterNoteComposerSettingTab extends PluginSettingTab { @@ -36,5 +44,49 @@ export class BetterNoteComposerSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); + + new Setting(this.containerEl) + .setName('Stay on source file') + .setDesc('Stay on the source file after extraction.') + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.stayOnSourceFile) + .onChange(async (value) => { + this.plugin.settings.stayOnSourceFile = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(this.containerEl) + .setName('Keep heading') + .setDesc('Keep the heading of the source file after extraction.') + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.keepHeading) + .onChange(async (value) => { + this.plugin.settings.keepHeading = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(this.containerEl) + .setName('Link to destination heading') + .setDesc('Link to the destination heading after extraction if using the "Link" option.') + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.linkToDestHeading) + .onChange(async (value) => { + this.plugin.settings.linkToDestHeading = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(this.containerEl) + .setName('Use heading as alias') + .setDesc('Use the heading as the alias if using the "Link" option.') + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.useHeadingAsAlias) + .onChange(async (value) => { + this.plugin.settings.useHeadingAsAlias = value; + await this.plugin.saveSettings(); + }); + }); } } diff --git a/src/utils.ts b/src/utils.ts index 613daeb..a542699 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -54,6 +54,10 @@ export function isHeading(line: string) { return /^#{1,6} /.test(line); } +export function getHeadingLevel(line: string) { + return line.match(/^(#{1,6}) /)?.[1].length ?? 0; +} + export function offsetToLoc(editor: Editor, offset: number): Loc { const pos = editor.offsetToPos(offset); return { line: pos.line, col: pos.ch, offset };