398 lines
11 KiB
TypeScript
398 lines
11 KiB
TypeScript
import {
|
|
App,
|
|
Editor,
|
|
EditorPosition,
|
|
EditorSuggest,
|
|
EditorSuggestContext,
|
|
EditorSuggestTriggerInfo,
|
|
getLanguage,
|
|
normalizePath,
|
|
Notice,
|
|
TFile,
|
|
TFolder,
|
|
} from 'obsidian';
|
|
import { BibleFormat } from './local-bible-ref-setting-tab';
|
|
import PassageReference, { PassageFormat } from './passage-reference';
|
|
import LocalBibleRefSettings, { QuoteReferencePosition } from './settings';
|
|
import { I18N } from './i18n';
|
|
|
|
export default class PassageSuggest extends EditorSuggest<PassageSuggestion> {
|
|
private readonly settings: LocalBibleRefSettings;
|
|
private readonly settingsNotConfiguredText: string;
|
|
|
|
private noSettingsNotice: Notice;
|
|
|
|
constructor(app: App, settings: LocalBibleRefSettings) {
|
|
super(app);
|
|
this.settings = settings;
|
|
|
|
switch (getLanguage()) {
|
|
case 'de':
|
|
this.settingsNotConfiguredText = I18N.DE.COMMON.settingsNotConfigured;
|
|
break;
|
|
case 'ko':
|
|
this.settingsNotConfiguredText = I18N.KO.COMMON.settingsNotConfigured;
|
|
break;
|
|
case 'en':
|
|
default:
|
|
this.settingsNotConfiguredText = I18N.EN.COMMON.settingsNotConfigured;
|
|
break;
|
|
}
|
|
}
|
|
|
|
onTrigger(
|
|
cursor: EditorPosition,
|
|
editor: Editor,
|
|
_: TFile | null
|
|
): EditorSuggestTriggerInfo | null {
|
|
// line must start with '--'
|
|
const line = editor.getLine(cursor.line);
|
|
if (!line.startsWith('--')) return null;
|
|
|
|
// if no settings, alert user
|
|
if (!this.settings.biblesPath) {
|
|
if (!this.noSettingsNotice?.messageEl.isShown()) {
|
|
const noticeText = this.settingsNotConfiguredText;
|
|
this.noSettingsNotice = new Notice(noticeText);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// min ref length is 5 ('--ex1')
|
|
if (cursor.ch < 5) return null;
|
|
|
|
// must be a passage ref
|
|
const isPassage = PassageReference.regExp.test(line);
|
|
if (!isPassage) return null;
|
|
|
|
// trigger info
|
|
return {
|
|
end: cursor,
|
|
query: line,
|
|
start: {
|
|
ch: 0,
|
|
line: cursor.line,
|
|
},
|
|
};
|
|
}
|
|
|
|
async getSuggestions(
|
|
context: EditorSuggestContext
|
|
): Promise<PassageSuggestion[]> {
|
|
let version = this.settings.defaultVersionShorthand;
|
|
if (!version) {
|
|
const folder = this.app.vault.getFolderByPath(this.settings.biblesPath);
|
|
version =
|
|
folder?.children?.filter((c) => c instanceof TFolder)?.first()?.name ??
|
|
'';
|
|
}
|
|
const passageRef = PassageReference.parse(
|
|
context.query,
|
|
version,
|
|
this.settings.defaultPassageFormat
|
|
);
|
|
if (!passageRef) return [];
|
|
|
|
// grab all chapters in the range
|
|
let texts = await this.getChapterTexts(passageRef);
|
|
if (!texts) return [];
|
|
|
|
// split first chapter by start verse
|
|
const textFromVerse = this.getTextFromStartVerse(texts[0], passageRef);
|
|
if (!textFromVerse) return [];
|
|
texts[0] = textFromVerse;
|
|
|
|
// split last chapter by end verse
|
|
const lastIndex = texts.length - 1;
|
|
const textToVerse = this.getTextToEndVerse(texts[lastIndex], passageRef);
|
|
if (!textToVerse) return [];
|
|
texts[lastIndex] = textToVerse;
|
|
|
|
// clean up chapter texts
|
|
const multipleChapters = texts.length > 1;
|
|
texts = texts.map((text, i) => {
|
|
const chapterNumber = passageRef.startChapter + i;
|
|
return this.cleanText(text, chapterNumber, multipleChapters);
|
|
});
|
|
|
|
// suggest
|
|
const excerpt = this.generateExcerpt(texts[0]);
|
|
const text = this.formatTexts(texts, passageRef, context);
|
|
return [{ excerpt, text }];
|
|
}
|
|
|
|
renderSuggestion(item: PassageSuggestion, el: HTMLElement): void {
|
|
el.setText(item.excerpt);
|
|
}
|
|
|
|
selectSuggestion(
|
|
item: PassageSuggestion,
|
|
_: MouseEvent | KeyboardEvent
|
|
): void {
|
|
if (!this.context) return;
|
|
this.context.editor.replaceRange(
|
|
item.text,
|
|
this.context.start,
|
|
this.context.end
|
|
);
|
|
}
|
|
|
|
/** Retrieves the texts of the chapters within a passage ref. */
|
|
private async getChapterTexts(
|
|
ref: PassageReference
|
|
): Promise<string[] | null> {
|
|
let basePath = '';
|
|
for (const alias of [ref.book.name, ...ref.book.aliases]) {
|
|
basePath = [this.settings.biblesPath, ref.version, alias].join('/');
|
|
basePath = normalizePath(basePath);
|
|
|
|
// if the book exists at this alias, use the alias instead
|
|
// and add the previous book name to the aliases
|
|
if (this.app.vault.getFolderByPath(basePath)) {
|
|
ref.book.aliases.push(ref.book.name);
|
|
ref.book.aliases.remove(alias);
|
|
ref.book.name = alias;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const texts: string[] = [];
|
|
|
|
// collect chapter texts
|
|
for (let ch = ref.startChapter; ch <= ref.endChapter; ch++) {
|
|
const path = basePath + `/${ref.book.name} ${ch}.md`;
|
|
const file = this.app.vault.getFileByPath(normalizePath(path));
|
|
if (!file) return null;
|
|
|
|
texts.push(await this.app.vault.cachedRead(file));
|
|
}
|
|
|
|
return texts;
|
|
}
|
|
|
|
/** Extracts the text in the chapter from the start verse to the end. */
|
|
private getTextFromStartVerse(
|
|
text: string,
|
|
ref: PassageReference
|
|
): string | null {
|
|
let pattern = '';
|
|
if (this.settings.bibleFormat === BibleFormat.BibleLinker) {
|
|
pattern = `#{1,6} [a-zA-Z]*${ref.startVerse}[a-zA-Z]*\\n\\w+`;
|
|
} else {
|
|
const quoteOrList = '(?:[>-] )*';
|
|
const chapterNum = '(?:\\*\\*\\d{1,3}\\*\\* )?';
|
|
const verseNum = `<sup>${ref.startVerse}</sup>`;
|
|
pattern = quoteOrList + chapterNum + verseNum;
|
|
}
|
|
|
|
const regExp = new RegExp(pattern);
|
|
const match = text.match(regExp);
|
|
if (!match) return null;
|
|
|
|
const verseLabel = match[0];
|
|
const parts = text.split(regExp);
|
|
return verseLabel + parts[1];
|
|
}
|
|
|
|
/** Extracts the text in the chapter from the start to the end verse. */
|
|
private getTextToEndVerse(
|
|
text: string,
|
|
ref: PassageReference
|
|
): string | null {
|
|
if (ref.endVerse === -1) return text;
|
|
|
|
let pattern = '';
|
|
if (this.settings.bibleFormat === BibleFormat.BibleLinker) {
|
|
pattern = `#{1,6} [a-zA-Z]*${ref.endVerse + 1}[a-zA-Z]*\\n\\w+`;
|
|
} else {
|
|
const quoteOrList = '(?:[>-] )*';
|
|
const verseNum = `<sup>${ref.endVerse + 1}</sup>`;
|
|
pattern = quoteOrList + verseNum;
|
|
}
|
|
|
|
const regex = new RegExp(pattern);
|
|
return text.split(regex, 1)[0].trim();
|
|
}
|
|
|
|
private cleanText(
|
|
text: string,
|
|
chapterNumber: number,
|
|
multipleChapters: boolean
|
|
): string {
|
|
if (this.settings.bibleFormat === BibleFormat.BibleLinker) {
|
|
text = this.formatBibleLinkerVerses(text);
|
|
}
|
|
|
|
text = this.removeChapterNumbers(text);
|
|
text = this.removeHeadings(text);
|
|
text = this.removeFootnoteRefs(text);
|
|
text = this.removeBOF(text);
|
|
text = this.removeEOF(text);
|
|
|
|
if (this.settings.bibleFormat === BibleFormat.BibleLinker) {
|
|
text = this.removeVerseSpacing(text);
|
|
}
|
|
|
|
const chapterMd = multipleChapters ? `**${chapterNumber}**` : '';
|
|
if (text.startsWith('> ')) {
|
|
const quoteMd = text.match(/^(?:> )+/)![0];
|
|
return text.replace(quoteMd, `${quoteMd}${chapterMd} `);
|
|
}
|
|
|
|
if (text.startsWith('- ')) {
|
|
const listMd = text.match(/^(?:- )+/)![0];
|
|
return text.replace(listMd, `${listMd}${chapterMd} `);
|
|
}
|
|
|
|
return (text = `${chapterMd} ${text}`);
|
|
}
|
|
|
|
/** Formats verses that use Bible Linker formatting. */
|
|
private formatBibleLinkerVerses(text: string): string {
|
|
return text.replace(
|
|
/#{1,6} [a-zA-Z]*(\d{1,3})[a-zA-Z]*\n(\w+)/g,
|
|
'<sup>$1</sup> $2'
|
|
);
|
|
}
|
|
|
|
/** Removes chapter numbers from the given text. */
|
|
private removeChapterNumbers(text: string): string {
|
|
return text.replace(/\*\*\d{1,3}\*\* /g, '');
|
|
}
|
|
|
|
/** Removes headings from the given text. */
|
|
private removeHeadings(text: string): string {
|
|
return text.replace(/^#.*[\n\r\f]*/gm, '');
|
|
}
|
|
|
|
/** Removes footnote refs from the given text. */
|
|
private removeFootnoteRefs(text: string): string {
|
|
return text.replace(/ \[\^\w{1,9}\]/g, '');
|
|
}
|
|
|
|
/** Removes the beginning-of-file content from the given text. */
|
|
private removeBOF(text: string): string {
|
|
if (!text.startsWith('---')) return text;
|
|
// split at YAML front matter
|
|
const split = text.split(/^---$/m);
|
|
return split[2].trim();
|
|
}
|
|
|
|
/** Removes the end-of-file content from the given text. */
|
|
private removeEOF(text: string): string {
|
|
// split at chapter divider
|
|
let split = text.split(/^---$/m);
|
|
// split at footnotes
|
|
split = split[0].split(/^\[\^\d+\]:/m, 1);
|
|
return split[0].trim();
|
|
}
|
|
|
|
/** Removes extra spacing between verses in Bible Linker formatting. */
|
|
private removeVerseSpacing(text: string): string {
|
|
return text.replace(/\n{2,}/g, ' ');
|
|
}
|
|
|
|
/** Generates an excerpt for the suggestion. */
|
|
private generateExcerpt(text: string): string {
|
|
text = text.split(/<\/sup>/, 2)[1];
|
|
text = text.replace(/<sup>\d+<\/sup>/g, '');
|
|
text = text.replace(/^(?:> |- )/gm, '');
|
|
text = text.replace(
|
|
/<span(?:\s+\w+=['"][^'"]+['"])*>([^<]+)<\/span>/g,
|
|
'$1'
|
|
);
|
|
text = text.replace(/\n/g, ' ');
|
|
text = text.replace(/ {2,}/g, ' ');
|
|
return text.slice(0, 45) + '...';
|
|
}
|
|
|
|
/** Formats the final text for suggestion. */
|
|
private formatTexts(
|
|
texts: string[],
|
|
passageRef: PassageReference,
|
|
context: EditorSuggestContext
|
|
): string {
|
|
let formatted = '';
|
|
switch (passageRef.format) {
|
|
case PassageFormat.Manuscript:
|
|
formatted = texts.join(' ').trim();
|
|
formatted = formatted.replace(/\n+/g, ' ');
|
|
formatted = formatted.replace(/\*\*\d{1,3}\*\*/g, '');
|
|
formatted = formatted.replace(/<sup>\d{1,3}<\/sup> /g, '');
|
|
formatted = formatted.replace(/(?:^[>-] | [>-] )/g, ' ');
|
|
formatted = formatted.trim() + '\n\n';
|
|
break;
|
|
case PassageFormat.Paragraph:
|
|
formatted = texts.join('\n\n').trim();
|
|
formatted += '\n\n';
|
|
break;
|
|
case PassageFormat.Quote: {
|
|
const { includeReference, referencePosition, linkToPassage } =
|
|
this.settings.quote;
|
|
|
|
let stringRef = '';
|
|
if (includeReference) {
|
|
if (linkToPassage)
|
|
stringRef = this.generatePassageLink(passageRef, context);
|
|
else stringRef = passageRef.stringify();
|
|
if (referencePosition === QuoteReferencePosition.Beginning)
|
|
stringRef += '\n';
|
|
else stringRef = `\n> ${stringRef}`;
|
|
}
|
|
|
|
formatted = '> ';
|
|
if (referencePosition === QuoteReferencePosition.Beginning)
|
|
formatted += stringRef;
|
|
formatted += texts.join('\n\n').trim();
|
|
formatted = formatted.replace(/\n/gm, '\n> ');
|
|
if (referencePosition === QuoteReferencePosition.End)
|
|
formatted += stringRef;
|
|
formatted += '\n\n';
|
|
break;
|
|
}
|
|
case PassageFormat.Callout: {
|
|
const { type, linkToPassage } = this.settings.callout;
|
|
|
|
let stringRef = '';
|
|
if (linkToPassage)
|
|
stringRef = this.generatePassageLink(passageRef, context);
|
|
else stringRef = passageRef.stringify();
|
|
|
|
formatted = `> [!${type}] ${stringRef}\n`;
|
|
formatted += texts.join('\n\n').trim();
|
|
formatted = formatted.replace(/\n/gm, '\n> ');
|
|
formatted += '\n\n';
|
|
break;
|
|
}
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
/** Generates a link to the passage within the vault. */
|
|
private generatePassageLink(
|
|
ref: PassageReference,
|
|
context: EditorSuggestContext
|
|
): string {
|
|
const { version, book, startChapter, startVerse } = ref;
|
|
const path = `${this.settings.biblesPath}/${version}/${book.name}/${book.name} ${startChapter}.md`;
|
|
const file = this.app.vault.getFileByPath(normalizePath(path));
|
|
if (!file) return ref.stringify();
|
|
|
|
return this.app.fileManager.generateMarkdownLink(
|
|
file,
|
|
context.file.path,
|
|
this.settings.bibleFormat === BibleFormat.BibleLinker
|
|
? `#${startVerse}`
|
|
: undefined,
|
|
ref.stringify()
|
|
);
|
|
}
|
|
}
|
|
|
|
interface PassageSuggestion {
|
|
excerpt: string;
|
|
text: string;
|
|
}
|