Cleaned up and added options to passage ref query.

This commit is contained in:
Caleb Campbell
2025-07-11 15:26:26 +10:00
parent 342eedaa43
commit eb6869738c
10 changed files with 2423 additions and 562 deletions

14
main.ts
View File

@@ -1,7 +1,8 @@
import { Plugin } from 'obsidian';
import LocalBibleRefSettingTab from 'src/LocalBibleRefSettingTab';
import { PassageSuggester } from 'src/PassageSuggester';
import { LocalBibleRefSettings } from 'src/config/settings';
import LocalBibleRefSettingTab from 'src/local-bible-ref-setting-tab';
import { PassageFormat } from 'src/passage-reference';
import { PassageSuggest } from 'src/passage-suggest';
import { LocalBibleRefSettings } from 'src/settings';
export default class LocalBibleRefPlugin extends Plugin {
settings: LocalBibleRefSettings;
@@ -9,13 +10,18 @@ export default class LocalBibleRefPlugin extends Plugin {
async onload() {
await this.loadSettings();
this.addSettingTab(new LocalBibleRefSettingTab(this.app, this));
this.registerEditorSuggest(new PassageSuggester(this.app, this.settings));
this.registerEditorSuggest(new PassageSuggest(this.app, this.settings));
}
onunload() {}
async loadSettings() {
this.settings = await this.loadData();
this.settings ??= {
biblesPath: '',
defaultVersionShorthand: '',
defaultPassageFormat: PassageFormat.Callout
};
}
async saveSettings() {

View File

@@ -3,8 +3,8 @@
"name": "Local Bible Ref",
"version": "1.0.1",
"minAppVersion": "0.15.0",
"description": "Quickly and easily reference Bible verses stored locally in your Obsidian vault.",
"author": "calebcampbell",
"description": "Quickly and easily reference Bible passages stored locally in your Obsidian vault.",
"author": "Caleb Campbell",
"authorUrl": "https://mastodon.social/@calebcampbell",
"fundingUrl": "",
"isDesktopOnly": false

1985
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "local-bible-ref",
"version": "1.0.1",
"description": "Quickly and easily reference Bible verses stored locally in your Obsidian vault.",
"description": "Quickly and easily reference Bible passages stored locally in your Obsidian vault.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
@@ -9,7 +9,7 @@
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"author": "Caleb Campbell",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",

View File

@@ -1,356 +0,0 @@
import {
App,
Editor,
EditorPosition,
EditorSuggest,
EditorSuggestContext,
Notice,
TFile
} from "obsidian";
import { BOOKS, Book } from "./data/books";
import { LocalBibleRefSettings } from "./config/settings";
interface ChapterReference {
startChapter: number;
startVerse: number;
endChapter: number;
endVerse: number;
}
interface PassageReference extends ChapterReference {
book: Book;
}
interface PassageSuggestion {
passageRef: string;
suggestText: string;
fullText: string;
}
export class PassageSuggester extends EditorSuggest<PassageSuggestion> {
private settings: LocalBibleRefSettings;
private passageRegex: RegExp;
constructor(app: App, settings: LocalBibleRefSettings) {
super(app);
this.settings = settings;
// builds the book matching regex
let regexString = BOOKS.reduce((string, book) => {
return `${string}${book.name}|${book.aliases.join("|")}|`;
}, "^\\-\\- ?(");
regexString = regexString.slice(0, -1);
regexString += ") ?(\\d{1,3}(?::\\d{1,3})?(?: ?\\- ?\\d{1,3}(?::\\d{1,3})?)?)$";
this.passageRegex = new RegExp(regexString, "i");
}
onTrigger(cursor: EditorPosition, editor: Editor, _: TFile | null) {
// min ref length is 6 ("--gen1")
if (cursor.ch < 6) return null;
// line must start with "--"
const line = editor.getLine(cursor.line);
if (!line.startsWith("--")) return null;
// must be a passage ref
const isPassage = this.passageRegex.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[]> {
const passageRef = this.parsePassageRef(context.query);
if (!passageRef) return [];
// grab all chapters in the range
let chapterTexts = await this.getChapterTexts(passageRef);
console.log(passageRef);
console.log(BOOKS);
if (!chapterTexts) return [];
// split first chapter by start verse
const chapterFromVerse = this.getChapterFromStartVerse(chapterTexts[0], passageRef);
if (!chapterFromVerse) return [];
chapterTexts[0] = chapterFromVerse;
// split last chapter by end verse
const lastIndex = chapterTexts.length - 1;
const chapterToVerse = this.getChapterToEndVerse(chapterTexts[lastIndex], passageRef);
if (!chapterToVerse) return [];
chapterTexts[lastIndex] = chapterToVerse;
// clean up chapter texts
chapterTexts = chapterTexts.map((text, i) => {
text = this.removeHeadings(text);
text = this.removeFootnoteRefs(text);
text = this.removeEOF(text);
const chapter = passageRef.startChapter + i;
if (text.startsWith("> ")) {
const quoteMd = text.match(/^(?:> )+/)![0];
return text.replace(quoteMd, `${quoteMd}**${chapter}.** `);
}
if (text.startsWith("- ")) {
const listMd = text.match(/^(?:- )+/)![0];
return text.replace(listMd, `${listMd}**${chapter}.** `);
}
return text = `**${chapter}.** ${text}`;
});
// suggest
return [{
passageRef: this.encodePassageRef(passageRef),
suggestText: this.generateSuggestText(chapterTexts[0]),
fullText: chapterTexts.join("\n\n")
}];
}
renderSuggestion(item: PassageSuggestion, el: HTMLElement): void {
el.innerText = item.suggestText;
}
selectSuggestion(item: PassageSuggestion, _: MouseEvent | KeyboardEvent): void {
if (!this.context) return;
let text = item.fullText.replace(/\n/gm, "\n> ");
text = `> [!bible]+ ${item.passageRef}\n> ${text}`;
this.context.editor.replaceRange(text, this.context.start, this.context.end);
}
/**
* Parses a passage reference from the given text.
*
* @param text the text to parse.
* @returns a parsed PassageReference or null.
*/
private parsePassageRef(text: string): PassageReference | null {
const passageRefMatch = text.match(this.passageRegex);
if (!passageRefMatch) return null;
let chapterRef = this.parseMultiChapterRef(passageRefMatch[2]);
if (!chapterRef) chapterRef = this.parseMultiPartChapterRef(passageRefMatch[2]);
if (!chapterRef) chapterRef = this.parsePartChapterRef(passageRefMatch[2]);
if (!chapterRef) return null;
const book = this.getBook(passageRefMatch[1]);
if (!book) return null;
return { book, ...chapterRef };
}
/**
* Parses a multi-chapter reference from the given text.
* Reference is in the format: `startChapter[ - endChapter]`.
*
* @param text the text to parse.
* @returns a parsed ChapterReference or null.
*/
private parseMultiChapterRef(text: string): ChapterReference | null {
const regex = /^(\d{1,3})(?: ?- ?(\d{1,3}))?$/i;
const match = text.match(regex);
if (!match) return null;
const startChapter = parseInt(match[1]);
return {
startChapter,
startVerse: 1,
endChapter: match[2] ? parseInt(match[2]) : startChapter,
endVerse: -1,
};
}
/**
* Parses a multi-(part)-chapter reference from the given text.
* Reference is in the format: `startChapter:startVerse - endChapter:endVerse`.
*
* @param text the text to parse.
* @returns a parsed ChapterReference or null.
*/
private parseMultiPartChapterRef(text: string): ChapterReference | null {
const regex = /^(\d{1,3}):(\d{1,3}) ?- ?(\d{1,3}):(\d{1,3})$/i;
const match = text.match(regex);
if (!match) return null;
return {
startChapter: parseInt(match[1]),
startVerse: parseInt(match[2]),
endChapter: parseInt(match[3]),
endVerse: parseInt(match[4]),
};
}
/**
* Parses a part-chapter reference from the given text.
* Reference is in the format: `startChapter:startVerse[-endVerse]`.
*
* @param text the text to parse.
* @returns a parsed ChapterReference or null.
*/
private parsePartChapterRef(text: string): ChapterReference | null {
const regex = /^(\d{1,3}):(\d{1,3})(?: ?- ?(\d{1,3}))?$/i;
const match = text.match(regex);
if (!match) return null;
const startChapter = parseInt(match[1]);
const startVerse = parseInt(match[2]);
return {
startChapter,
startVerse,
endChapter: startChapter,
endVerse: match[3] ? parseInt(match[3]) : startVerse,
};
}
/**
* Retrieves a book based on it's alias.
* @param alias the alias of the book.
* @returns the book object if found, otherwise undefined.
*/
private getBook(alias: string): Book | undefined {
alias = alias.toLowerCase();
return BOOKS.find((book) => {
if (book.name.toLowerCase() === alias) return book;
if (book.aliases.includes(alias)) return book;
})
}
/**
* Retrieves the texts of the chapters within the specified passage reference.
*
* @param ref the passage reference.
* @returns a promise - an array of chapter texts or null.
*/
private async getChapterTexts(ref: PassageReference): Promise<string[] | null> {
let basePath = "";
for (let alias of [ref.book.name, ...ref.book.aliases]) {
console.log(alias);
basePath = [
this.settings.biblesPath,
this.settings.defaultVersionShorthand,
alias
].join("/");
console.log(basePath);
if (await this.app.vault.adapter.exists(basePath)) {
ref.book.aliases.push(ref.book.name);
ref.book.aliases.remove(alias);
ref.book.name = alias;
console.log(ref);
break;
}
}
const chapterTexts: string[] = [];
for (let ch = ref.startChapter; ch <= ref.endChapter; ch++) {
const path = basePath + `/${ref.book.name} ${ch}.md`;
const file = this.app.vault.getFileByPath(path);
if (!file) {
new Notice(`Could not find chapter at: ${path}`);
return null;
}
chapterTexts.push(await this.app.vault.cachedRead(file));
}
return chapterTexts;
}
/**
* Retrieves the text in the chapter from the start verse to the end.
*
* @param text the text to cut.
* @param ref the passage reference.
* @returns the text from the start verse or null.
*/
private getChapterFromStartVerse(text: string, ref: PassageReference): string | null {
const regex = new RegExp(`(?:> |- )*<sup>${ref.startVerse}</sup>`);
const match = text.match(regex);
if (!match) {
const verse = ref.startVerse;
const chapter = ref.startChapter;
const book = ref.book.name;
new Notice(`Could not find verse ${verse} in ${book} ${chapter}.`);
return null;
}
const verseLabel = match[0];
const parts = text.split(regex);
return verseLabel + parts[1];
};
/**
* Retrieves the text in the chapter from the start to the end verse.
*
* @param text the text to cut.
* @param ref the passage reference.
* @returns the text up to the end verse or null.
*/
private getChapterToEndVerse(text: string, ref: PassageReference): string | null {
if (ref.endVerse === -1) return text;
const regex = new RegExp(`(?:^(?:> |- )*)?<sup>${ref.endVerse + 1}</sup>`, "m");
return text.split(regex, 1)[0].trim();
};
/** 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(/ \[\^\d{1,4}\]/g, "");
}
/** 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();
}
/** Generates suggestion text. */
private generateSuggestText(text: string): string {
text = text.split(/<\/sup>/, 2)[1];
text = text.replace(/(?:<sup>\d+<\/sup>|> |- )/g, "");
text = text.replace(/<span style="font-variant: small-caps;">Lord<\/span>/g, "Lord");
text = text.replace(/\n/g, " ");
text = text.replace(/ /g, " ");
return text.slice(0, 45) + "...";
}
/**
* Encodes a PassageReference into a string representation.
*
* @param ref the PassageReference to encode.
* @returns the encoded string representation.
*/
private encodePassageRef(ref: PassageReference): string {
// whole chapter/s ref
if (ref.startVerse === 1 && ref.endVerse === -1) {
if (ref.startChapter === ref.endChapter) {
return `${ref.book.name} ${ref.startChapter}`;
}
return `${ref.book.name} ${ref.startChapter}-${ref.endChapter}`;
}
// part chapter ref
if (ref.startChapter === ref.endChapter) {
if (ref.startVerse === ref.endVerse) {
return `${ref.book.name} ${ref.startChapter}:${ref.startVerse}`;
}
return `${ref.book.name} ${ref.startChapter}:${ref.startVerse}-${ref.endVerse}`;
}
// part chapters ref
const a = `${ref.startChapter}:${ref.startVerse}`;
const b = `${ref.endChapter}:${ref.endVerse}`;
return `${ref.book.name} ${a}-${b}`;
}
}

View File

@@ -12,12 +12,12 @@ export const BOOKS: Book[] = [
{ name: "Joshua", aliases: ["jos"] },
{ name: "Judges", aliases: ["jdg"] },
{ name: "Ruth", aliases: ["rut"] },
{ name: "1 Samuel", aliases: ["1sa"] },
{ name: "2 Samuel", aliases: ["2sa"] },
{ name: "1 Kings", aliases: ["1ki"] },
{ name: "2 Kings", aliases: ["2ki"] },
{ name: "1 Chronicles", aliases: ["1ch"] },
{ name: "2 Chronicles", aliases: ["2ch"] },
{ name: "1 Samuel", aliases: ["1sa", "1samuel"] },
{ name: "2 Samuel", aliases: ["2sa", "2samuel"] },
{ name: "1 Kings", aliases: ["1ki", "1kings"] },
{ name: "2 Kings", aliases: ["2ki", "2kings"] },
{ name: "1 Chronicles", aliases: ["1ch", "1chronicles"] },
{ name: "2 Chronicles", aliases: ["2ch", "2chronicles"] },
{ name: "Ezra", aliases: ["ezr"] },
{ name: "Nehemiah", aliases: ["neh"] },
{ name: "Esther", aliases: ["est"] },
@@ -49,25 +49,25 @@ export const BOOKS: Book[] = [
{ name: "John", aliases: ["jhn"] },
{ name: "Acts", aliases: ["act"] },
{ name: "Romans", aliases: ["rom"] },
{ name: "1 Corinthians", aliases: ["1co"] },
{ name: "2 Corinthians", aliases: ["2co"] },
{ name: "1 Corinthians", aliases: ["1co", "1corinthians"] },
{ name: "2 Corinthians", aliases: ["2co", "2corinthians"] },
{ name: "Galatians", aliases: ["gal"] },
{ name: "Ephesians", aliases: ["eph"] },
{ name: "Philippians", aliases: ["php"] },
{ name: "Colossians", aliases: ["col"] },
{ name: "1 Thessalonians", aliases: ["1th"] },
{ name: "2 Thessalonians", aliases: ["2th"] },
{ name: "1 Timothy", aliases: ["1ti"] },
{ name: "2 Timothy", aliases: ["2ti"] },
{ name: "1 Thessalonians", aliases: ["1th", "1thessalonians"] },
{ name: "2 Thessalonians", aliases: ["2th", "2thessalonians"] },
{ name: "1 Timothy", aliases: ["1ti", "1timothy"] },
{ name: "2 Timothy", aliases: ["2ti", "2timothy"] },
{ name: "Titus", aliases: ["tit"] },
{ name: "Philemon", aliases: ["phm"] },
{ name: "Hebrews", aliases: ["heb"] },
{ name: "James", aliases: ["jas"] },
{ name: "1 Peter", aliases: ["1pe"] },
{ name: "2 Peter", aliases: ["2pe"] },
{ name: "1 John", aliases: ["1jn"] },
{ name: "2 John", aliases: ["2jn"] },
{ name: "3 John", aliases: ["3jn"] },
{ name: "1 Peter", aliases: ["1pe", "1peter"] },
{ name: "2 Peter", aliases: ["2pe", "2peter"] },
{ name: "1 John", aliases: ["1jn", "1john"] },
{ name: "2 John", aliases: ["2jn", "2john"] },
{ name: "3 John", aliases: ["3jn", "3john"] },
{ name: "Jude", aliases: ["jud"] },
{ name: "Revelation", aliases: ["rev"] },
];

View File

@@ -1,5 +1,6 @@
import LocalBibleRefPlugin from "main";
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
import LocalBibleRefPlugin from 'main';
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
import { PassageFormat } from './passage-reference';
export default class LocalBibleRefSettingTab extends PluginSettingTab {
plugin: LocalBibleRefPlugin;
@@ -13,14 +14,13 @@ export default class LocalBibleRefSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
this.containerEl.createEl('h1', { text: 'Local Bible Ref' });
let biblesPathTimeout: NodeJS.Timeout;
new Setting(containerEl)
.setName("Bibles Path")
.setDesc("The path to the folder containing your bibles.")
.setName('Bibles Path')
.setDesc('The path to the folder containing your bibles.')
.addText(text => text
.setPlaceholder("e.g. Data/Bibles")
.setPlaceholder('e.g. Data/Bibles')
.setValue(this.plugin.settings.biblesPath)
.onChange(async (value) => {
this.plugin.settings.biblesPath = value;
await this.plugin.saveSettings();
@@ -29,15 +29,16 @@ export default class LocalBibleRefSettingTab extends PluginSettingTab {
biblesPathTimeout = setTimeout(async () => {
const exists = await this.app.vault.adapter.exists(value);
if (!exists) new Notice(`Bibles folder doesn't exist at path: ${value}.`);
}, 500);
}, 1000);
}));
let defaultVersionTimeout: NodeJS.Timeout;
new Setting(containerEl)
.setName("Default Version Shorthand")
.setDesc("The version to use by default - shorthand.")
.setName('Default Version Shorthand')
.setDesc('The version to use by default - shorthand.')
.addText(text => text
.setPlaceholder("e.g. ESV")
.setPlaceholder('e.g. NIV')
.setValue(this.plugin.settings.defaultVersionShorthand)
.onChange(async (value) => {
this.plugin.settings.defaultVersionShorthand = value;
await this.plugin.saveSettings();
@@ -47,7 +48,22 @@ export default class LocalBibleRefSettingTab extends PluginSettingTab {
const path = `${this.plugin.settings.biblesPath}/${value}`;
const exists = await this.app.vault.adapter.exists(path);
if (!exists) new Notice(`Version folder doesn't exist at path: ${path}.`);
}, 500);
}, 1000);
}));
new Setting(containerEl)
.setName('Default Passage Format')
.setDesc('The markdown format to use for passages by default.')
.addDropdown(dropdown => dropdown
.addOptions({
paragraph: 'Paragraph',
quote: 'Quote',
callout: 'Callout',
})
.setValue(this.plugin.settings.defaultPassageFormat)
.onChange(async (value) => {
this.plugin.settings.defaultPassageFormat = value as PassageFormat;
await this.plugin.saveSettings();
}));
}
}

223
src/passage-reference.ts Normal file
View File

@@ -0,0 +1,223 @@
import { Book, BOOKS } from "./books";
export class PassageReference implements ChapterReference, PassageOptions {
startChapter: number;
startVerse: number;
endChapter: number;
endVerse: number;
book: Book;
version: string;
format: PassageFormat;
constructor(
chapterRef: ChapterReference,
book: Book,
passageOptions: PassageOptions
) {
this.startChapter = chapterRef.startChapter;
this.startVerse = chapterRef.startVerse;
this.endChapter = chapterRef.endChapter;
this.endVerse = chapterRef.endVerse;
this.book = book;
this.version = passageOptions.version;
this.format = passageOptions.format;
}
/** Parses a passage reference from the given text. */
static parse(
text: string,
defaultVersionShorthand: string,
defaultPassageFormat: PassageFormat
): PassageReference | null {
const match = text.match(this.regExp);
if (!match) return null;
let chapterRef = this.parseMultiChapterRef(match[2]);
if (!chapterRef) chapterRef = this.parseMultiChapterVerseRef(match[2]);
if (!chapterRef) chapterRef = this.parseMultiVerseRef(match[2]);
if (!chapterRef) return null;
const book = this.getBook(match[1]);
if (!book) return null;
const options = this.parseOptions(
match[3],
defaultVersionShorthand,
defaultPassageFormat
);
return new PassageReference(chapterRef, book, options);
}
/** Builds the passage matching regular expression. */
static get regExp(): RegExp {
let regExpString = "^\\-\\- ?(";
regExpString += BOOKS.map(
(b) => `${b.name}|${b.aliases.join("|")}`
).join("|");
regExpString += ") ?(\\d{1,3}(?::\\d{1,3})?" +
"(?: ?\\- ?\\d{1,3}(?::\\d{1,3})?)?)" +
"((?: ?\\+[a-z]+(?::[a-z]+)?){0,2})$";
return new RegExp(regExpString, "i");
}
/** Stringifies the passage reference back into text. */
stringify(): string {
// multi-chapter ref
if (this.startVerse === 1 && this.endVerse === -1) {
if (this.startChapter === this.endChapter)
return (
this.book.name + `${this.startChapter} - ${this.version}`
);
return (
`${this.book.name} ${this.startChapter}-` +
`${this.endChapter} - ${this.version}`
);
}
// multi-verse ref
if (this.startChapter === this.endChapter) {
if (this.startVerse === this.endVerse)
return (
this.book.name +
`${this.startChapter}:${this.startVerse} - ${this.version}`
);
return (
`${this.book.name} ${this.startChapter}:` +
`${this.startVerse}-${this.endVerse} - ${this.version}`
);
}
// multi-chapter-and-verse ref
const a = `${this.startChapter}:${this.startVerse}`;
const b = `${this.endChapter}:${this.endVerse}`;
return `${this.book.name} ${a}-${b} - ${this.version}`;
}
/**
* Parses a multi-chapter reference from the given text.
* Reference format: `startChapter[[ ]-[ ]endChapter]`.
*/
private static parseMultiChapterRef(text: string): ChapterReference | null {
const regExp = /^(\d{1,3})(?: ?- ?(\d{1,3}))?$/i;
const match = text.match(regExp);
if (!match) return null;
return {
startChapter: +match[1],
startVerse: 1,
endChapter: match[2] ? +match[2] : +match[1],
endVerse: -1,
};
}
/**
* Parses a multi-chapter-and-verse reference from the given text.
* Reference format: `startChapter:startVerse[ ]-[ ]endChapter:endVerse`.
*/
private static parseMultiChapterVerseRef(text: string): ChapterReference | null {
const regex = /^(\d{1,3}):(\d{1,3}) ?- ?(\d{1,3}):(\d{1,3})$/i;
const match = text.match(regex);
if (!match) return null;
return {
startChapter: +match[1],
startVerse: +match[2],
endChapter: +match[3],
endVerse: +match[4],
};
}
/**
* Parses a multi-verse reference from the given text.
* Reference format: `startChapter:startVerse[-endVerse]`.
*/
private static parseMultiVerseRef(text: string): ChapterReference | null {
const regex = /^(\d{1,3}):(\d{1,3})(?:-(\d{1,3}))?$/i;
const match = text.match(regex);
if (!match) return null;
return {
startChapter: +match[1],
startVerse: +match[2],
endChapter: +match[1],
endVerse: match[3] ? +match[3] : +match[2],
};
}
/** Retrieves a book based on its alias. */
private static getBook(alias: string): Book | undefined {
alias = alias.toLowerCase();
return BOOKS.find((book) => {
const aliases = book.aliases.map((a) => a.toLowerCase());
if (book.name.toLowerCase() === alias) return book;
if (aliases.includes(alias)) return book;
});
}
/** Parses passage options from the given text. */
private static parseOptions(
text: string,
defaultVersionShorthand: string,
defaultPassageFormat: PassageFormat
): PassageOptions {
const optionArgs = text
.toLowerCase()
.split("+")
.filter(Boolean)
.map((x) => x.trim());
const options: PassageOptions = {
version: defaultVersionShorthand,
format: defaultPassageFormat,
};
// there are special keywords for formatting (m or manuscript, for
// example) - anything else is treated as a Bible version code
for (let option of optionArgs) {
switch (option) {
case "m":
case "manuscript":
options.format = PassageFormat.Manuscript;
break;
case "p":
case "paragraph":
options.format = PassageFormat.Paragraph;
break;
case "q":
case "quote":
options.format = PassageFormat.Quote;
break;
case "c":
case "callout":
options.format = PassageFormat.Callout;
break;
default:
options.version = option.toUpperCase();
break;
}
}
return options;
}
}
export enum PassageFormat {
Manuscript = "manuscript",
Paragraph = "paragraph",
Quote = "quote",
Callout = "callout",
}
interface ChapterReference {
startChapter: number;
startVerse: number;
endChapter: number;
endVerse: number;
}
interface PassageOptions {
version: string;
format: PassageFormat;
}

300
src/passage-suggest.ts Normal file
View File

@@ -0,0 +1,300 @@
import {
App,
Editor,
EditorPosition,
EditorSuggest,
EditorSuggestContext,
EditorSuggestTriggerInfo,
TFolder,
} from "obsidian";
import { PassageFormat, PassageReference } from "./passage-reference";
import { LocalBibleRefSettings } from "./settings";
export class PassageSuggest extends EditorSuggest<PassageSuggestion> {
private settings: LocalBibleRefSettings;
constructor(app: App, settings: LocalBibleRefSettings) {
super(app);
this.settings = settings;
}
onTrigger(
cursor: EditorPosition,
editor: Editor,
_: any
): EditorSuggestTriggerInfo | null {
// min ref length is 6 ('--gen1')
if (cursor.ch < 6) return null;
// line must start with '--'
const line = editor.getLine(cursor.line);
if (!line.startsWith("--")) 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[]> {
const passageRef = PassageReference.parse(
context.query,
this.settings.defaultVersionShorthand,
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.innerText = 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 (let alias of [ref.book.name, ...ref.book.aliases]) {
basePath = [this.settings.biblesPath, ref.version, alias].join("/");
// if the book exists at this alias, use the alias instead
// and add the previous book name to the aliases
if (await this.app.vault.adapter.exists(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(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 {
const regExp = new RegExp(
`(?:[>-] )*(?:\\*\\*\\d{1,3}\\*\\* )?<sup>${ref.startVerse}</sup>`
);
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;
const regex = new RegExp(
`(?:^(?:> |- )*)?<sup>${ref.endVerse + 1}</sup>`,
"m"
);
return text.split(regex, 1)[0].trim();
}
private cleanText(
text: string,
chapterNumber: number,
multipleChapters: boolean
): string {
text = this.removeChapterNumbers(text);
text = this.removeHeadings(text);
text = this.removeFootnoteRefs(text);
text = this.removeBOF(text);
text = this.removeEOF(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}`);
}
/** 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
let 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();
}
/** 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(
/<span style='font-variant: small-caps;'>Lord<\/span>/g,
"Lord"
);
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:
formatted = "> ";
formatted += texts.join("\n\n").trim();
formatted = formatted.replace(/\n/gm, "\n> ");
formatted += "\n\n";
break;
case PassageFormat.Callout:
const passageReference = passageRef.stringify();
const passageLink = this.generatePassageLink(passageRef, context);
formatted = `> [!quote] [${passageReference}](${passageLink})\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 {
let link = "";
let folder: TFolder | null = context.file.parent;
while (folder?.parent) {
link += "../";
folder = folder.parent;
}
const { version, book, startChapter } = ref;
link += `${this.settings.biblesPath}/${version}/` +
`${book.name}/${book.name} ${startChapter}.md`;
return link.replace(/ /g, "%20");
}
}
interface PassageSuggestion {
excerpt: string;
text: string;
}

View File

@@ -1,4 +1,7 @@
import { PassageFormat } from "./passage-reference";
export interface LocalBibleRefSettings {
biblesPath: string;
defaultVersionShorthand: string;
defaultPassageFormat: PassageFormat;
}