Compare commits

..

No commits in common. "master" and "0.13.3" have entirely different histories.

38 changed files with 81 additions and 3284 deletions

View file

@ -52,10 +52,6 @@ Source is organized under `src/` by responsibility:
display setting, then delegates to the renderer and handles errors.
- **`BibleEventHandlers.ts`** — the single, plugin-owned hover/popup lifecycle
(mouseover/mouseout, show/hide timing). Loaded as a child component.
- **`VerseSelection.ts`** — pure value object: a verse set within one book →
contiguous `BibleReference[]` runs + a display label (`Genesis 1:2-3, 5`).
- **`VerseSelectionService.ts`** — plugin-owned (`addChild`) holder of the *single*
active verse selection across panes + its owning controller; notifies subscribers.
### `services/` — content resolution and storage
@ -86,18 +82,6 @@ Source is organized under `src/` by responsibility:
window has its own `Document`, so styles are applied per-doc).
- **`BookSuggest.ts`**, **`OpenBibleModal.ts`** — the `Open Bible` modal and its
book autocomplete.
- **`VerseWrapper.ts`** — `wrapPassageVerses(passageEl)`: wraps each rendered verse in a
`.dj-verse` span (idempotent) so verses are selectable/highlightable.
- **`VerseSelectionController.ts`** — one per rendered passage: wraps verses, binds
gestures (desktop tap/shift, mobile long-press-drag), reflects the service's selection
(highlight) and owns the action bar.
- **`VerseActionBar.ts`** — floating bar shown while verses are selected; renders the
Copy / Insert (and optional Append) actions with the configurable `split`/`toggle`/
`submenu` format chooser.
- **`VerseActions.ts`** — turns a selection + format into the payload and performs Copy
(clipboard), Insert (Editor API), or Append (`Vault.process`); extracts verse text for
the blockquote format from the passage's own document.
- **`InsertTargetModal.ts`** — `FuzzySuggestModal<TFile>` note picker for "Append to note…".
### `utils/` — small helpers
@ -106,11 +90,9 @@ Source is organized under `src/` by responsibility:
- **`BiblePassage.ts`** — `{ reference, html }` pairing for resolved content.
- **`BibleApiResponse`/`BiblePassage`** are what flow back out of the content
service to the renderer.
- **`VerseId.ts`** — `parseVerseId(id)`: ESV marker id (`v01001002-1`) → `{chapter, verse}`.
- **`VerseFormatter.ts`** — pure builders for the three insert formats (inline reference,
`bible` code block, blockquote-with-text + citation).
- **`FrontmatterUtil.ts`** — apply user-configured custom frontmatter to Bible
notes (`getCustomFrontmatterForReference`, `applyCustomFrontmatter`).
- **`BibleCodeblockFormatter.ts`** — formatting helper for the `bible` code block.
### `settings/`
@ -133,20 +115,7 @@ note renders
```
Full passages follow the same resolution path through `getBibleContent`, but render
the whole passage inline (`processFullBiblePassage`) with navigation + heading. Non-
contiguous references (`Genesis 1:2-3, 5`) resolve via `getBibleContentList`, which parses
the list with `BibleReference.parseList` and concatenates each run's HTML.
## Verse selection
After a full passage renders, `processFullBiblePassage` wraps its verses
(`wrapPassageVerses` → `.dj-verse` spans) and attaches a `VerseSelectionController`.
Selecting verses (tap/shift on desktop, long-press-drag on mobile) updates a per-passage
`VerseSelection` and pushes it to the plugin-owned `VerseSelectionService`, which holds the
single active selection. The owning controller highlights its verses and shows a
`VerseActionBar`; Copy / Insert / Append (and the editor `Insert … here` menu + commands)
run through `VerseActions`, formatting via `VerseFormatter`. See
[docs/gotchas.md](docs/gotchas.md) for the wrapping/gesture/bar-lifecycle details.
the whole passage inline (`processFullBiblePassage`) with navigation + heading.
## Storage model
@ -160,9 +129,9 @@ reuses `toBibleApiResponse` on the stored data instead of re-hitting the API. Se
## Tests
`test/` holds the test suites (Node's built-in `node:test` runner via `tsx`), one
`*.test.ts` per unit — currently `BibleReference`, `BookNames`, `VerseFormatter`,
`VerseId`, and `VerseSelection`. Run with `npm test` (or `devbox run test`); they
also gate `npm run build` and `just release`. See [docs/testing.md](docs/testing.md).
`*.test.ts` per unit — currently `test/BibleReference.test.ts`. Run with `npm test`
(or `devbox run test`); they also gate `npm run build` and `just release`. See
[docs/testing.md](docs/testing.md).
## Build

View file

@ -1,27 +1,7 @@
# Disciples Journal Releases
New changes land under `## Unreleased`; at release time it is renamed by hand to the
version tag (see [docs/build-and-release.md](docs/build-and-release.md)). Each `## `
header text is **exactly** the release tag (no leading `v`).
# Changelog
## Unreleased
- The "Open Bible" command (and ribbon icon) now opens the chosen chapter in a new tab
instead of replacing the note you were reading
## 1.14.0
- Adds verse selection: tap verses in a rendered passage to select them (shift-click or
long-press-drag for a range), then copy or insert them as an inline reference, a `bible`
code block, or a blockquote of the verse text. A floating action bar (docked at the
bottom of the view, with configurable split / toggle / submenu format choosers) and a
desktop right-click "Insert … here" both drive it; an optional "Append to note…" action
can be enabled in settings. "Insert" targets the note you were last editing (generated
Bible notes are skipped), not the passage you selected from. Adds a *Verse selection*
settings section
- Adds support for non-contiguous references like `Genesis 1:2-3, 5` and `Genesis 1:2, 4, 8`
— in full `bible` blocks, inline `` `code` `` references, and Live Preview (parsed by the
new `BibleReference.parseList`)
- Fixes chapter-range references (e.g. `Genesis 1-2`): `BibleReference.parse` was
storing the end chapter in the `endVerse` field, so the range was dropped — it
round-tripped to `Genesis 1` and was fetched/stored as a single chapter. The end

View file

@ -10,14 +10,6 @@ passages inside notes (inline hover previews + full `bible` code blocks) and can
download passages on demand from the ESV API. See `README.md` for the user-facing
feature tour and `manifest.json` for plugin metadata.
## Design ethos
**Non-intrusive by default.** People use this plugin during devotion, prayer, and small
group. Features must never distract, frustrate, or shake the user out of an intimate time
with God. Default to quiet: nothing new appears, moves, or interrupts until the user
deliberately asks for it. No text shifting, no surprise popups, no persistent chrome. When
in doubt, do less.
## Where to look
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the plugin is wired: services,
@ -60,9 +52,5 @@ change the code, update the matching doc in the same change:
- **Add a new reference format or change parsing** → update `docs/reference-formats.md`.
- **Introduce a workaround, magic number, or non-obvious behavior** → give it a home:
a precise inline comment if local, a `docs/gotchas.md` entry if cross-cutting.
- **Ship a user-facing change** → add a bullet under `## Unreleased` in `CHANGELOG.md`.
- **Hit a small in-scope tangent** (worth fixing, but it'd derail the current task) →
add a numbered `## N.` section to `FOLLOWUPS.md`, and clear it before the next feature.
- **Plan a new feature or larger effort** → add a numbered `## N.` section to
`ROADMAP.md` (and a plan doc once work starts). `FOLLOWUPS.md` and `ROADMAP.md` serve
different lifespans — don't fold one into the other.
- **Defer something out of scope** → add a `FOLLOWUPS.md` entry; promote it to
`ROADMAP.md` (and a plan doc) if it grows into a real effort.

View file

@ -1,33 +1,27 @@
# Follow-ups
# Workspace Follow-Ups
In-scope tangents found while working — important to fix, but they'd derail the task
at hand. Add a numbered `## N.` section below instead of chasing them now, and
**clear these before starting a new feature.** New features and larger efforts go in
[ROADMAP.md](ROADMAP.md), not here.
Lightweight tracking for tasks identified during other work that weren't tackled in the original scope. These are intentionally deferred — captured here so they don't get lost.
Mark a finished item with a `**Status:** done` line rather than deleting it; completed
items get pruned and the rest renumbered on a periodic cleanup pass. Each entry
carries the repo's standard fields (Identified / What / Why / Context / Effort), where
Effort is sized XS (<1 h) · S (14 h) · M (1 day) · L (multi-day).
Add new entries at the top. Remove entries when done (commit message can reference them).
<!-- Template — copy for each item, numbering sequentially:
## N. Short title
**Status:** open
- **Identified:** YYYY-MM-DD, the work it came up in.
- **What:** brief description of the change.
- **Why:** the motivation / what value it adds.
- **Context:** file paths, gotchas, anything that saves the next person grep time.
- **Effort:** XS | S | M | L
-->
## Entry format
## 1. Finish pop-out window styling
Each entry should include:
**Status:** open
- **Identified:** YYYY-MM-DD and the work it came up in
- **What:** brief description of the change
- **Why:** the motivation / what value it adds
- **Context:** background, file paths, gotchas, anything that would save the next person 10 minutes of grepping
- **Effort:** rough sizing — XS (<1 h), S (14 h), M (1 day), L (multi-day)
---
## Finish pop-out window styling
- **Identified:** 2026-05-31, funky-logic sweep.
- **What:** Styles aren't fully ported into freshly created pop-out windows.
- **Why:** Passages render unstyled in pop-outs; a known rough edge.
- **Context:** `DisciplesJournalPlugin.updateBibleStyles` (`:219-246`) flags this in a
- **Context:** `DisciplesJournalPlugin.updateBibleStyles` (`:131-158`) flags this in a
comment; see [docs/gotchas.md](docs/gotchas.md). Tracked upstream in an Obsidian
Discord thread linked in the code.
- **Effort:** M

View file

@ -1,85 +1,20 @@
# Roadmap
New features and larger planned / in-flight efforts, tracked as numbered `## N.`
sections. For small in-scope tangents to clear before the next feature see
Larger planned or in-flight efforts. For small deferred findings see
[FOLLOWUPS.md](FOLLOWUPS.md); for released history see [CHANGELOG.md](CHANGELOG.md).
> Entries below are **observations** about likely directions, inferred from the
> code — not committed plans. When work actually starts, promote the item to a real
> plan/spec doc and add a `## Status` line here. Mark a shipped item
> `**Status:** done`; prune and renumber on a periodic cleanup pass.
> code — not committed plans. Promote one to a real plan/spec doc (and a
> `## Status` section) when work actually starts.
Each item carries a **Lens** (User-facing / Technical health / Growth & ecosystem)
and an **Effort** tag: XS (<1 h) · S (14 h) · M (1 day) · L (multi-day).
## No efforts currently in flight.
## 1. Multiple Bible versions
## Observed future directions
- **Lens:** User-facing · **Effort:** L
- The storage path scheme is already
- **Multiple Bible versions.** The storage path scheme is already
`<bibleContentVaultPath>/<preferredBibleVersion>/...` and settings expose a
"preferred version", but `BookNames`, `ESVApiService`, and rendering assume ESV
end-to-end. The headline feature; needs a version-agnostic content-source seam.
## 2. Bundled offline public-domain version (KJV/WEB)
- **Lens:** User-facing · **Effort:** M
- Today the plugin is inert without an ESV API token. Shipping a public-domain
translation bundled in the plugin means it works on install with zero setup — a big
onboarding win and a natural first consumer of the multi-version seam (#1).
## 3. Copy / export passage with attribution
- **Lens:** User-facing · **Effort:** S
- A "copy passage" action that emits clean markdown/text and auto-appends the required
ESV copyright line. Serves the journaling use case and respects the copyright
obligation noted in `README.md`.
## 4. Touch / mobile interaction
- **Lens:** User-facing · **Effort:** M
- `isDesktopOnly` is `false`, but the core interaction is hover-to-preview, which
doesn't exist on touch. Define a tap/long-press affordance so the plugin is actually
usable on mobile.
## 5. Reading plans / daily reading
- **Lens:** User-facing · **Effort:** M
- A code block or command that surfaces a day's passage from a plan (e.g. M'Cheyne,
chronological). Leans on existing passage rendering; turns the plugin from a
reference tool into a daily-habit tool.
## 6. Expand the test harness
- **Lens:** Technical health · **Effort:** M
- `BibleReference.parse` is covered; extend to `BookNames` normalization, `BibleFiles`
path/filename logic, and the `BibleContentService` resolution funnel
(cache → note → API). The highest-leverage safety net before the multi-version
refactor (#1). See [docs/testing.md](docs/testing.md).
## 7. Multi-source content seam
- **Lens:** Technical health · **Effort:** L
- Decouple "where content comes from" from ESV specifically (bible-api.com, local
USFM/USX import, etc.). Pairs with multiple versions (#1) but is the *source* axis
rather than the *version* axis; enables offline import workflows.
## 8. Rendering robustness: finish pop-out styling + large-passage perf
- **Lens:** Technical health · **Effort:** M
- Promote the pop-out styling gap tracked in [FOLLOWUPS.md](FOLLOWUPS.md), and address
rendering very long passages (lazy/virtualized render, cache eviction) so whole-book
blocks don't jank.
## 9. Community plugin store submission
- **Lens:** Growth & ecosystem · **Effort:** SM
- The repo already follows `eslint-plugin-obsidianmd` and gates on lint/tests. Getting
listed in Obsidian's community catalog is the single biggest distribution lever and
mostly a compliance/review task.
## 10. Clickable cross-references & footnotes
- **Lens:** Growth & ecosystem · **Effort:** M
- The ESV HTML already carries footnotes and cross-references (rendered today as static
text). Making cross-refs clickable to open/preview the target passage turns rendered
passages into a navigable study surface.
end-to-end. Generalizing would mean a version-agnostic content-source seam.
- **A test harness.** There are no automated tests. `BibleReference.parse` (pure,
pattern-heavy, highest-risk logic) is the natural first target. See
[FOLLOWUPS.md](FOLLOWUPS.md).

View file

@ -31,24 +31,15 @@ The repo follows `eslint-plugin-obsidianmd` (see `eslint.config.mjs`) and the
## Releasing
Releases go through the `justfile` (`just release <version>`). Accumulate user-facing
changes under `## Unreleased` in [../CHANGELOG.md](../CHANGELOG.md) as you work — the
recipe promotes that section to the release version for you (no manual rename needed).
The recipe:
Releases go through the `justfile` (`just release <version>`), which:
1. Normalizes the version (strips a leading `v`) and rejects anything that isn't semver
(e.g. `1.0.1` or `1.0.1-rc.1`).
2. Refuses to run if `git status` is not clean, or if the tag already exists.
3. Syncs the version into `manifest.json`, `package.json`, and `versions.json` (via
`jq`) — `versions.json` maps the new version to the current `minAppVersion`.
4. Promotes `## Unreleased``## <version>` in `CHANGELOG.md` and uses that section's
body as the GitHub release notes (falls back to `Release <version>` if empty).
5. Builds with `npm run build` (type-check + tests + production esbuild) — a failing
type-check or test aborts the release after the file edits but before commit/push.
6. Commits (`Prepares for release '<version>'`) the four metadata/changelog files and
pushes the current branch (`git push -u origin HEAD`).
7. Creates a GitHub release with `gh`, targeting the just-pushed branch and uploading
`main.js`, `manifest.json`, and `styles.css` as assets.
1. Refuses to run if `git status` is not clean.
2. Runs `npm test` — a failing test aborts the release before any files are mutated.
3. Sets `version` in both `manifest.json` and `package.json` (via `jq`).
5. Builds with `npm run build-no-check`.
6. Commits (`Prepares for release '<version>'`) and pushes.
7. Creates a GitHub release with `gh`, uploading `main.js`, `manifest.json`, and
`styles.css` as assets.
### gh token note

View file

@ -82,13 +82,3 @@ across existing notes.
All failures funnel through `BibleApiResponse.error(message, ErrorType)`
(`ApiAuthentication`, `BadApiResponse`, `RequestsForbidden`, …). User-visible cases
surface an Obsidian `Notice`; internal ones are logged to the console.
## Attribution when reproducing verse text
The verse-selection **blockquote** format (`VerseFormatter.formatBlockquote`) reproduces
ESV text into the user's note and appends a `— <ref> (ESV)` citation. The ESV API
[copyright/permissions terms](https://api.esv.org/) require attribution when ESV text is
quoted; the `(ESV)` suffix is the minimum, and larger reproductions carry a fuller
copyright-notice obligation. If the quoting story expands (e.g. exporting many passages),
revisit the notice requirement here. The other two formats (inline reference, `bible`
block) store only a reference, not text, so they don't trigger this.

View file

@ -12,7 +12,7 @@ frontmatter-as-cache model, `:`→`v` filename encoding) live in
loads its document listeners/timer and, crucially, tears them down on plugin
unload. A previous version leaked global `document` listeners; do **not** reintroduce
per-render or per-reference global listeners — route hover behavior through this one
handler. (`DisciplesJournalPlugin.ts:73-77`, `BibleEventHandlers.ts`.)
handler. (`DisciplesJournalPlugin.ts:60-65`, `BibleEventHandlers.ts`.)
## Pop-out windows: styles don't fully carry over (known-incomplete)
@ -20,7 +20,7 @@ Each Obsidian pop-out window is a separate `Document`, so `updateBibleStyles`
iterates all leaves and applies styles per-document. A code comment flags that this
**still doesn't fully work** — a freshly created pop-out doesn't get the style
vars ported over. If you touch styling and a pop-out looks unstyled, this is why,
not your change. (`DisciplesJournalPlugin.ts:219-246`.)
not your change. (`DisciplesJournalPlugin.ts:131-158`.)
## The passage cache is keyed by object identity — it effectively never hits
@ -30,7 +30,7 @@ up** with the inbound (freshly parsed) `ref`. Two equal-but-distinct
`BibleReference` objects are different map keys, so cross-call lookups essentially
always miss; the real cache is the saved note on disk. Harmless today, but if you
ever rely on this in-memory cache, key it by `ref.toString()` instead.
(`BibleContentService.ts:14,97-103`.)
(`BibleContentService.ts:14,72-80`.)
## scrollToVerse watches the DOM instead of guessing a delay
@ -39,44 +39,3 @@ uses a `MutationObserver` to scroll the instant the `.verse-N` element appears,
with a **5000 ms** fallback that disconnects the observer if it never does (wrong
ref, or an edit mode that produces no verse elements). Don't replace this with a
fixed `setTimeout`. (`BibleFiles.ts:118-145`.)
## Verse selection: wrapping, gestures, and bar lifecycle
Verse selection is layered onto rendered passages by `VerseSelectionController` (one per
full `bible` passage, attached in `BibleReferenceRenderer.processFullBiblePassage`). A few
non-obvious things:
- **Verses aren't pre-wrapped.** ESV HTML marks a verse only with a
`<b class="verse-num|chapter-num" id="vBBCCCVVV-N">`; the verse's text then runs as loose
inline nodes until the next marker, sometimes across `<p>` boundaries.
`wrapPassageVerses` (`VerseWrapper.ts`) walks each block and wraps each verse's run in a
`<span class="dj-verse" data-chapter data-verse>`. It's **idempotent** (bails if a
`.dj-verse` already exists) and a verse split across paragraphs yields two spans sharing
the same `data-verse` — both highlight together. `parseVerseId` ignores the book digits;
the book comes from the passage's canonical reference.
- **Mobile selection vs. scroll.** Touch uses a long-press threshold (`LONG_PRESS_MS`): a
quick swipe scrolls normally; only a deliberate long-press engages drag-select (which
then calls `preventDefault` on `touchmove`). A short tap toggles a single verse. Don't
remove the threshold or you'll hijack scrolling.
- **One selection, globally.** `VerseSelectionService` (plugin-owned, `addChild`) holds a
single active selection + its owning controller. Only the owner renders highlight and the
action bar, so selecting in one passage clears another.
- **The action bar is per-`Document` and bottom-center docked.** It's appended to the
passage's own `doc.body` (popout-safe). It's docked at the bottom-center of the viewport
via CSS — **not** anchored to the passage — because a full chapter is taller than the
viewport, so anchoring to the passage's `getBoundingClientRect().bottom` rendered the bar
off-screen. Blockquote text is extracted from the owner's `sourceEl` (its own document) so
popouts stay correct — never query the global `document`.
- **Selection clears when its passage unloads.** `controller.onunload` calls
`service.clearIfOwner(this)`, so a note re-render or pane close drops a stale selection
and its bar.
- **"Insert" doesn't target the active pane.** When you click a verse, the active pane
*is* the passage's pane — usually a generated note under the Bible content folder, which
is the last place you want the verse inserted. So the plugin tracks the last active
**editable, non-generated** markdown note (`DisciplesJournalPlugin.handleActiveLeafChange`
`lastEditableLeaf`, filtered by `isBibleContentFile`) and `resolveInsertTarget()`
returns it. There's no public "leaves in MRU order" API — this `active-leaf-change`
tracking is the stand-in. Insert does **not** steal focus; it drops the text in and shows
a `Passage … inserted into note …` notice (important when the target pane is in the
background). The right-click "Insert here" path is unaffected — it always uses the clicked
editor.

View file

@ -44,19 +44,3 @@ provides:
These fields also drive note filenames — see
[esv-api.md](esv-api.md) and `BibleFiles.pathForPassage`.
## Non-contiguous (comma) lists
Verse selection (see [gotchas.md](gotchas.md)) can produce non-contiguous references
like `Genesis 1:2-3, 5` or `Genesis 1:31, 2:1`. These are parsed by
`BibleReference.parseList`, **not** the single-range `parse`:
- The first comma-separated item is a full reference (any format above).
- Later items may be a bare verse (`5`), a bare verse range (`5-7`) — both inheriting the
previous item's book **and** chapter — or `chapter:verse[-end]`, inheriting only the book.
- The result is a `BibleReference[]` (one per contiguous run); any invalid item makes the
whole list `null`.
A rendered list resolves each run and concatenates the HTML
(`BibleContentService.getBibleContentList`); the passage heading/link uses the **first**
run.

File diff suppressed because it is too large Load diff

View file

@ -1,213 +0,0 @@
# Verse Selection & Insert-into-Note — Design
## Context
Today the plugin renders Bible passages (full `bible` code blocks) but offers no way
to *act* on the verses inside them. The user wants to select one or more verses from a
rendered passage and pull them into a note — fluidly, the way they journal (inline
reference mid-sentence), study (full code block), or share (blockquote with the actual
text). This must work across multiple open notes/windows, on desktop **and** mobile,
and must never feel intrusive: users are often in devotion, prayer, or small group, and
the feature should stay completely invisible until they choose to act.
This is also the **foundation** for a future feature ("select verses → find all notes
that reference them"), so verse selection is being designed as a clean, reusable
primitive with a small action registry, not a one-off.
## Design ethos (new, to be recorded in CLAUDE.md)
A `## Design ethos` section will be added inline to `CLAUDE.md`:
> **Non-intrusive by default.** People use this plugin during devotion, prayer, and
> small group. Features must never distract, frustrate, or shake the user out of an
> intimate time with God. Default to quiet: nothing new appears, moves, or interrupts
> until the user deliberately asks for it. No text shifting, no surprise popups, no
> persistent chrome. When in doubt, do less.
Every decision below follows from this: selection adds zero visible chrome until a verse
is tapped; the action bar exists only while a selection exists; insertion never happens
automatically.
## Interaction model
**Selection (inside a rendered `bible` passage):**
- **Desktop tap / click a verse** → toggles that verse (non-contiguous by default: tap
v2 then v5 selects exactly 2 and 5). Selected verses get a subtle inline highlight
that follows the text across line wraps (verses share lines, so the highlight is on an
inline run, not a line).
- **Desktop Shift+click a verse** → selects the contiguous range from the anchor (last
toggled verse) through the clicked verse.
- **Mobile short tap** → toggles a single verse.
- **Mobile long-press (≈400 ms) then drag** → initiates a range selection and extends it
as the finger moves. A press threshold guards against hijacking normal scrolling; a
quick swipe scrolls as usual, only a deliberate long-press enters selection-drag.
- Selection **persists** across panes/windows until explicitly cleared (the bar's ✕, the
`Esc` key, or a "Clear selection" command). Inserting does **not** auto-clear, so the
user can insert the same selection in several places. Selection clears if its passage
element is destroyed/re-rendered.
**Acting on a selection — same actions surfaced in three places:**
1. **Floating action bar** — appears (anchored near the selection; bottom-docked on
narrow/mobile layouts) only while ≥1 verse is selected. Carries the reference label,
a ✕, and the **Copy** and **Insert** actions (plus **Append to note…** *only when the
user enables it* — see below). Same on desktop and mobile (the bar is the primary
surface on mobile, which has no right-click). **How format is chosen on the bar is
configurable** (`formatChooserStyle` setting) — all three presentations are implemented
and the user picks one:
- **`split`** (default) — split buttons `[Copy ▾] [Insert ▾]`; the body uses the
default format, the ▾ chevron opens the other two.
- **`toggle`** — a segmented format toggle `[ Ref | Block | Quote ]` plus plain action
buttons `[Copy] [Insert]`; pick the format once, then an action.
- **`submenu`** — plain action buttons, each of which opens a format submenu.
2. **Desktop right-click on the passage** → context menu with the same Copy / Insert
actions (plus Append to note… when enabled), each with a format submenu.
3. **Desktop right-click inside any editor** (while a selection exists) → an
"Insert `<ref>` here" entry that inserts at the click point — the precise, reverse-flow
path that sidesteps the multi-window problem because the user placed their own cursor.
**Locations for insertion:**
- **Insert at cursor** — drops into the last-focused markdown editor at its cursor (bar
button + a hotkey-able command). Primary path.
- **Right-click in editor → "Insert `<ref>` here"** — inserts at the exact click point.
- **Append to note…** *(optional, off by default)* — a fuzzy note-picker (`FuzzySuggestModal`
over markdown `TFile`s) → appends to the end of the chosen note. This overlaps with
Insert-at-cursor, so it ships disabled and is enabled via `enableAppendToNote`; only then
does it appear on the bar / context menu and as a command.
**Three output formats (chosen per-insert; a configurable default sets the button body):**
- **Inline reference** — `` `Genesis 1:2-3, 5` `` (renders with the existing hover preview).
- **`bible` code block** — fenced block of the same reference (renders as the full passage).
- **Blockquote with text** — the actual verse text as a markdown blockquote ending in a
`— Genesis 1:2-3, 5 (ESV)` citation; self-contained / portable to other apps.
## Non-contiguous references
`BibleReference` (`src/core/BibleReference.ts`) models only a single contiguous range and
its `parse` rejects comma lists. Because non-contiguous is the default selection behavior,
we need to represent and render `Genesis 1:2-3, 5`:
- **Selection state** is a set of `{chapter, verse}` within the passage. A new
`VerseSelection` value object collapses the set into **contiguous runs**, each a normal
`BibleReference`, plus a combined display label (`"Genesis 1:2-3, 5"`,
cross-chapter-aware). This keeps `BibleReference` single-range — a multi-run selection is
just `BibleReference[]`.
- **Blockquote** needs no parser change: verse text is extracted from the already-rendered
DOM verse-spans (see wrapping below); citation uses the combined label.
- **Inline reference & code block** must round-trip render. Extend the parse/resolve path
to accept a comma-separated verse list within a chapter → `BibleReference[]`, and have
the content service resolve each run (ESV API `q=` accepts comma lists; runs can also be
resolved/concatenated individually from cache). Documented in `docs/reference-formats.md`.
## Architecture
**Verse wrapping (the enabling primitive).** ESV HTML marks verses only with
`<b class="verse-num" id="v01001002-1">` (and `chapter-num` for the first verse); a verse's
text runs as loose inline nodes until the next marker, sometimes across `<p>` boundaries.
After `processFullBiblePassage` builds `passageEl`
(`src/components/BibleReferenceRenderer.ts:134`), a new pass walks each paragraph's child
nodes, opens a new `<span class="dj-verse" data-book data-chapter data-verse>` at each
verse-num/chapter-num marker, and collects following siblings until the next marker. The id
(`v BB CCC VVV -N`) is parsed to book/chapter/verse (ignore the `-N` suffix). A verse split
across paragraphs yields two spans sharing the same `data-verse`; both highlight together.
This same `data-*` tagging is what the future "find referencing notes" feature will key on.
**New modules (`src/`):**
- `core/VerseSelectionService.ts` — plugin-owned (added via `addChild`), holds the current
`VerseSelection`, emits change events, exposes clear(). Single source of truth across panes.
- `core/VerseSelection.ts` — value object: verse set → contiguous `BibleReference[]` runs +
combined label; pure, unit-tested.
- `components/VerseSelectionController.ts` — attached per rendered passage: does the verse
wrapping, binds pointer/click/keyboard gestures (desktop tap/shift, mobile long-press+drag
with scroll guard), applies/removes highlight classes, reflects the service's selection.
- `components/VerseActionBar.ts` — the floating bar; shown/hidden by the service's
selection state; per-`Document` aware (popout windows each have their own doc, like
`BibleStyles`). Renders its format chooser in whichever mode `formatChooserStyle`
selects (`split` / `toggle` / `submenu`) — all three implemented.
- `components/InsertTargetModal.ts``FuzzySuggestModal<TFile>` note picker for the
optional "Append to note…" action (only used when `enableAppendToNote` is on).
- `utils/VerseFormatter.ts` — turns a `VerseSelection` into each of the three output strings
(inline ref, code block, blockquote+citation); pure, unit-tested.
**Touch points (existing):**
- `components/BibleReferenceRenderer.ts` — invoke wrapping + attach a
`VerseSelectionController` in `processFullBiblePassage`.
- `core/DisciplesJournalPlugin.ts` (`onload`) — construct `VerseSelectionService`
(`addChild`); `registerEvent(workspace.on('editor-menu', …))` for "Insert `<ref>` here";
add commands: *Insert selected verses at cursor*, *Copy selected verses*,
*Clear verse selection*; pass the service into the renderer.
- `settings/DisciplesJournalSettings.ts` — new settings (below); **reorganize the settings
tab into clearly headed sections** for navigation (e.g. *Display*, *Typography & colors*,
*Bible content & storage*, *ESV API*, *Verse selection*) using section headings
(`new Setting(containerEl).setHeading()`), grouping the existing settings under them.
- `components/BibleStyles.ts` — styles for `.dj-verse`, `.dj-verse-selected`, the action bar.
## Settings
New settings (grouped under a *Verse selection* heading in the reorganized tab):
- `enableVerseSelection: boolean` (default `true`).
- `defaultInsertFormat: 'inline' | 'codeblock' | 'blockquote'` (default `'inline'`); also
remembers the last-used format per session for the split-button body.
- `formatChooserStyle: 'split' | 'toggle' | 'submenu'` (default `'split'`) — which of the
three (all-implemented) format-chooser presentations the action bar uses.
- `enableAppendToNote: boolean` (default `false`) — surfaces the optional "Append to note…"
action on the bar / context menu / commands.
The settings tab itself is reorganized into headed sections (see touch points) so the
growing list stays navigable.
## Docs to update (per the repo's working agreements)
- **CLAUDE.md** — add the `## Design ethos` section (above).
- **ARCHITECTURE.md** — add the new modules to the module map + a short "verse selection"
data-flow note.
- **docs/reference-formats.md** — document comma-list (non-contiguous) references.
- **docs/gotchas.md** — the verse-wrapping approach, mobile long-press-vs-scroll handling,
per-Document action bar in popouts, selection lifecycle/clear-on-rerender.
- **docs/esv-api.md** — ESV attribution requirement note for reproduced verse text
(blockquote includes `(ESV)`; document the copyright-notice obligation).
- **CHANGELOG.md** — a bullet under `## Unreleased`.
## Extensibility
The action surfaces (bar, passage menu) are populated from a small in-memory list of
"verse-selection actions" `{ id, label, run(selection), enabled? }`. Copy / Insert (and the
optional Append-to-note) are the first registrations; the future "find notes referencing
these verses" is added by registering one more action — no UI rework.
## Risks / things to watch
- **Mobile long-press vs. scroll** — the highest-risk UX. Press threshold + cancel-on-move
before threshold. Fallback if janky: mobile uses tap-toggle only and relies on the bar.
- **Non-contiguous round-trip rendering** — the comma-list parser/resolve extension is the
largest code change; blockquote works without it, so it can land slightly later than the
text format.
- **Live Preview** — enable selection on rendered passages in reading mode and the
live-preview rendered widget; do not interfere with source editing.
- **Popout windows** — wrap/bar/styles are per-`Document` (follow the `BibleStyles` pattern).
- **ESV licensing** — honor attribution on reproduced text.
## Implementation phases
1. **Wrapping + selection state**: `VerseSelection` (+ tests), verse-id parsing & DOM
wrapping in the renderer, `VerseSelectionService`, highlight styles. Desktop tap-toggle
+ shift-range working; selection persists.
2. **Action surfaces**: `VerseActionBar` (all three format-chooser styles +
`formatChooserStyle` setting), passage right-click menu, editor `editor-menu`
"Insert here", and the three commands.
3. **Formats + locations**: `VerseFormatter` (+ tests) for all three formats; Copy
(clipboard) and Insert-at-cursor (last-focused editor). Optional `InsertTargetModal`
append behind `enableAppendToNote`.
4. **Non-contiguous round-trip**: extend parse/resolve for comma lists; reference-formats doc.
5. **Mobile gestures**: long-press-to-initiate + drag-to-extend with scroll guard.
6. **Settings (incl. tab reorganization into headed sections), docs, CHANGELOG, ethos
section**; build + lint gating.
## Verification
- `npm run build` (tsc + `npm test` + esbuild) and `npx eslint .` pass clean.
- Unit tests: `VerseSelection` run-collapsing (contiguous, non-contiguous, cross-chapter),
`VerseFormatter` output for all three formats, comma-list `BibleReference` parse round-trip.
- Manual in the demo vault (`disciples-journal-demo`):
- Open a note with a `bible` block (e.g. Genesis 1). Tap v2 and v5 → both highlight, bar
shows "Genesis 1:2, 5". Shift+click → contiguous range. ✕/Esc clears.
- Each action × each format → correct Copy (paste-check) and Insert-at-cursor output,
including the non-contiguous case. With `enableAppendToNote` on, the optional
"Append to note…" picker appends to the chosen note.
- Right-click in a second note → "Insert Genesis 1:2, 5 here" inserts at cursor.
- Inserted inline ref and code block render correctly (incl. non-contiguous).
- Mobile emulation: long-press initiates selection, drag extends, normal swipe scrolls.
- Popout window: selection + bar work in the popped-out doc.

View file

@ -26,15 +26,8 @@ below) — a failing test blocks both.
- `BibleReference``parse` (the full format matrix from
[reference-formats.md](reference-formats.md), book-name variants, normalization,
null cases), `toString` round-trips, and the value-object helpers.
- `BookNames` — canonical book-name normalization and per-book chapter counts.
- `VerseFormatter` — the three insert formats (inline reference, `bible` code block,
blockquote-with-text + citation).
- `VerseId``parseVerseId` of ESV marker ids (`v01001002-1` → `{chapter, verse}`).
- `VerseSelection` — collapsing a verse set into contiguous `BibleReference` runs and
its display label.
Coverage is the pure logic (parsing, formatting, value objects); extend it as other
pure logic becomes test-worthy.
This is the first suite; extend it as other pure logic becomes test-worthy.
## Gating

View file

@ -1,65 +1,22 @@
set shell := ["bash", "-c"]
# List available recipes.
default:
@just --list
# Cut a release: bump the version, promote the changelog, build, commit, push,
# and publish a GitHub release with the plugin assets attached.
# Usage: devbox run release 1.0.1 (or: just release 1.0.1)
release version:
#!/usr/bin/env bash
set -euo pipefail
# Obsidian versions/tags carry no leading "v".
version="{{version}}"
version="${version#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Refusing to release: '$version' is not a semver version (e.g. 1.0.1)."
exit 1
if [[ `git status --porcelain` ]]; then
echo "Cannot release: 'git status' is not clean. Commit/push or stash changes first"
exit 0
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "Cannot release: working tree is not clean. Commit or stash changes first."
exit 1
fi
if git rev-parse "$version" >/dev/null 2>&1; then
echo "Cannot release: tag '$version' already exists."
exit 1
fi
# Keep manifest.json, package.json, and versions.json in lockstep. The new
# version maps to the current minAppVersion in versions.json.
min_app_version="$(jq -r '.minAppVersion' manifest.json)"
jq --arg v "$version" '.version = $v' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
jq --arg v "$version" '.version = $v' package.json > package.tmp && mv package.tmp package.json
jq --arg v "$version" --arg m "$min_app_version" '.[$v] = $m' versions.json > versions.tmp && mv versions.tmp versions.json
# Promote the changelog's "Unreleased" section to this version, then use that
# section's body as the GitHub release notes.
if [[ -f CHANGELOG.md ]] && grep -q '^## Unreleased$' CHANGELOG.md; then
sed -i "s/^## Unreleased$/## $version/" CHANGELOG.md
fi
notes="$(awk -v ver="## $version" '$0==ver{g=1;next} /^## /&&g{exit} g' CHANGELOG.md 2>/dev/null | sed '/^$/d')"
[[ -z "$notes" ]] && notes="Release $version"
# Produce the release artifact (type-check + tests + bundle to main.js).
npm run build
git add manifest.json package.json versions.json CHANGELOG.md
git commit --allow-empty -m "Prepares for release '$version'"
git push -u origin HEAD
npm test
jq '.version = "{{version}}"' manifest.json > tmp && mv tmp manifest.json
jq '.version = "{{version}}"' package.json > tmp && mv tmp package.json
npm run build-no-check
git add .
git commit --allow-empty -am "Prepares for release '{{version}}'"
git push
token="$(just _gh-token)"
if [[ -n "$token" ]]; then export GH_TOKEN="$token"; fi
# Tag at the just-pushed commit and attach the assets Obsidian expects.
gh release create "$version" \
--title "$version" \
--target "$(git rev-parse --abbrev-ref HEAD)" \
--notes "$notes" \
main.js manifest.json styles.css
gh release create "{{version}}" --title "{{version}}" --notes "" main.js manifest.json styles.css
# Echo a GitHub token for `gh`. devbox bundles its own gh (nixpkgs) that can't
# read the host keyring where `gh auth login` stored the token, so fall back to

View file

@ -1,7 +1,7 @@
{
"id": "disciples-journal",
"name": "Disciples Journal",
"version": "1.14.0",
"version": "0.13.3",
"minAppVersion": "1.6.6",
"description": "Embed Bible references and passages in your notes and read the Bible.",
"author": "Scott Tomaszewski (Xentis)",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-disciples-journal",
"version": "0.13.3",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-disciples-journal",
"version": "0.13.3",
"version": "0.13.2",
"license": "MIT",
"devDependencies": {
"@codemirror/language": "^6.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-disciples-journal",
"version": "1.14.0",
"version": "0.13.3",
"description": "Embed Bible references and passages in your notes and read the Bible.",
"main": "main.js",
"scripts": {

View file

@ -38,7 +38,7 @@ export function createInlineReferenceExtension(contentService: BibleContentServi
const content = view.state.doc.sliceString(node.from, node.to);
// Try to parse as a Bible reference
try {
const reference = BibleReference.parseList(content);
const reference = BibleReference.parse(content);
if (reference) {
const decor = Decoration.mark({
inclusive: true,
@ -67,13 +67,13 @@ export function createInlineReferenceExtension(contentService: BibleContentServi
new Notice("No text content found for Bible reference", 10000);
return;
}
const reference = BibleReference.parseList(t.textContent);
const reference = BibleReference.parse(t.textContent);
if (!reference) {
new Notice("Invalid Bible reference: " + t.textContent, 10000);
return;
}
void contentService.getBibleContentList(t.textContent).then(response => {
void contentService.getBibleContent(reference).then(response => {
if (response.isError()) {
new Notice(response.errorMessage, 10000);
return;

View file

@ -6,8 +6,6 @@ import {BibleEventHandlers} from "src/core/BibleEventHandlers";
import {BibleFiles} from "../services/BibleFiles";
import {BibleReference} from "../core/BibleReference";
import {BiblePassage} from "../utils/BiblePassage";
import {VerseSelectionController} from "./VerseSelectionController";
import {VerseSelectionService} from "../core/VerseSelectionService";
/**
* Component for rendering Bible references in Obsidian
@ -17,18 +15,15 @@ export class BibleReferenceRenderer {
private bibleNavigation: BibleNavigation;
private plugin: DisciplesJournalPlugin;
private eventHandlers: BibleEventHandlers;
private selectionService: VerseSelectionService;
constructor(
bibleContentService: BibleContentService,
bibleFiles: BibleFiles,
plugin: DisciplesJournalPlugin,
selectionService: VerseSelectionService
plugin: DisciplesJournalPlugin
) {
this.bibleContentService = bibleContentService;
this.plugin = plugin;
this.bibleNavigation = new BibleNavigation(bibleFiles, plugin.app);
this.selectionService = selectionService;
}
/**
@ -58,17 +53,17 @@ export class BibleReferenceRenderer {
}
try {
// Accept single refs and non-contiguous lists (e.g. "Genesis 1:2, 4, 8")
if (!BibleReference.parseList(codeText)) {
const reference = BibleReference.parse(codeText);
if (!reference) {
continue;
}
// Create a Bible reference element
const referenceEl = element.doc.createElement('span');
referenceEl.classList.add('bible-reference');
referenceEl.textContent = codeText;
const response = await this.bibleContentService.getBibleContentList(codeText);
const response = await this.bibleContentService.getBibleContent(reference);
if (response.isError()) {
new Notice(response.errorMessage, 10000);
continue;
@ -93,9 +88,10 @@ export class BibleReferenceRenderer {
* Process full Bible passage code blocks
*/
public async processFullBiblePassage(source: string, el: HTMLElement): Promise<void> {
// Parse the reference (supports non-contiguous lists like "Genesis 1:2-3, 5")
// Parse the reference
const reference = source.trim();
if (!BibleReference.parseList(reference) && !BibleReference.parse(reference)) {
const parsedRef = BibleReference.parse(reference);
if (!parsedRef) {
const message = `Invalid bible reference: ${source}`;
console.error(message);
const errorContainer = el.createEl('div');
@ -104,8 +100,8 @@ export class BibleReferenceRenderer {
return;
}
// Grab the content (resolves and concatenates each contiguous run)
const response = await this.bibleContentService.getBibleContentList(reference);
// Grab the content
const response = await this.bibleContentService.getBibleContent(parsedRef);
if (response.isError()) {
const errorContainer = el.createEl('div');
@ -141,14 +137,6 @@ export class BibleReferenceRenderer {
containerEl.appendChild(passageEl);
el.appendChild(containerEl);
if (this.plugin.settings.enableVerseSelection) {
const controller = new VerseSelectionController(
this.plugin, passageEl, canonicalRef.book, this.selectionService
);
this.selectionService.addChild(controller); // unloads with the plugin/service
controller.load();
}
}
/**

View file

@ -1,21 +0,0 @@
import { App, FuzzySuggestModal, TFile } from "obsidian";
/** Fuzzy-pick a markdown note to append the selection to. */
export class InsertTargetModal extends FuzzySuggestModal<TFile> {
constructor(app: App, private onChoose: (file: TFile) => void | Promise<void>) {
super(app);
this.setPlaceholder("Append to note…");
}
getItems(): TFile[] {
return this.app.vault.getMarkdownFiles();
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
void this.onChoose(file);
}
}

View file

@ -68,7 +68,7 @@ export class OpenBibleModal extends SuggestModal<BibleTarget> {
if (chapterCount === 1) {
// Single-chapter book, open directly
const loadingNotice = new Notice("Loading chapter...", 0);
await this.bibleFiles.openChapterNote(new BibleReference(item.book, 1), true);
await this.bibleFiles.openChapterNote(new BibleReference(item.book, 1));
loadingNotice.hide();
} else {
// Reopen for chapter selection
@ -77,7 +77,7 @@ export class OpenBibleModal extends SuggestModal<BibleTarget> {
}
} else {
const loadingNotice = new Notice("Loading chapter...", 0);
await this.bibleFiles.openChapterNote(new BibleReference(item.book, item.chapter), true);
await this.bibleFiles.openChapterNote(new BibleReference(item.book, item.chapter));
loadingNotice.hide();
}
}
@ -124,7 +124,7 @@ class OpenBibleChapterModal extends SuggestModal<BibleTarget> {
private async openChapter(item: BibleTarget): Promise<void> {
const loadingNotice = new Notice("Loading chapter...", 0);
await this.bibleFiles.openChapterNote(new BibleReference(item.book, item.chapter), true);
await this.bibleFiles.openChapterNote(new BibleReference(item.book, item.chapter));
loadingNotice.hide();
}
}

View file

@ -1,102 +0,0 @@
import { Component, Menu, setIcon } from "obsidian";
import { VerseSelection } from "../core/VerseSelection";
import DisciplesJournalPlugin from "../core/DisciplesJournalPlugin";
import { InsertFormat, runVerseAction, VerseActionKind } from "./VerseActions";
const FORMAT_LABEL: Record<InsertFormat, string> = {
inline: "Ref",
codeblock: "Block",
blockquote: "Quote",
};
const FORMATS: InsertFormat[] = ["inline", "codeblock", "blockquote"];
export class VerseActionBar extends Component {
private root: HTMLElement | null = null;
constructor(
private plugin: DisciplesJournalPlugin,
private passageEl: HTMLElement,
private onClose: () => void,
) {
super();
}
onunload(): void {
this.root?.remove();
this.root = null;
}
render(selection: VerseSelection): void {
const doc = this.passageEl.doc;
this.root?.remove();
const bar = doc.body.createDiv({ cls: "dj-verse-action-bar" });
this.root = bar;
bar.createSpan({ cls: "dj-verse-action-label", text: selection.label() });
const actions: { kind: VerseActionKind; label: string }[] = [
{ kind: "copy", label: "Copy" },
{ kind: "insert", label: "Insert" },
];
if (this.plugin.settings.enableAppendToNote) {
actions.push({ kind: "append", label: "Append to note…" });
}
const style = this.plugin.settings.formatChooserStyle;
const defFormat = this.plugin.settings.defaultInsertFormat;
if (style === "toggle") {
let format: InsertFormat = defFormat;
const toggle = bar.createDiv({ cls: "dj-format-toggle" });
for (const f of FORMATS) {
const btn = toggle.createEl("button", { text: FORMAT_LABEL[f] });
btn.toggleClass("is-active", f === format);
btn.onClickEvent(() => {
format = f;
toggle.findAll("button").forEach((b) => b.removeClass("is-active"));
btn.addClass("is-active");
});
}
for (const a of actions) {
const btn = bar.createEl("button", { text: a.label });
btn.onClickEvent(() => void runVerseAction(this.plugin, a.kind, selection, format, this.passageEl));
}
} else if (style === "submenu") {
for (const a of actions) {
const btn = bar.createEl("button", { text: a.label });
btn.onClickEvent((e) => this.openFormatMenu(e, a.kind, a.label, selection));
}
} else {
// split: body = default format, chevron = other formats
for (const a of actions) {
const group = bar.createDiv({ cls: "dj-split-button" });
const main = group.createEl("button", { cls: "dj-split-main", text: a.label });
main.onClickEvent(() => void runVerseAction(this.plugin, a.kind, selection, defFormat, this.passageEl));
const chevron = group.createEl("button", {
cls: "dj-split-chevron",
attr: { "aria-label": `${a.label}: choose format` },
});
setIcon(chevron, "chevron-down");
chevron.onClickEvent((e) => this.openFormatMenu(e, a.kind, a.label, selection));
}
}
const close = bar.createEl("button", {
cls: "dj-verse-action-close",
attr: { "aria-label": "Clear verse selection" },
});
setIcon(close, "x");
close.onClickEvent(() => this.onClose());
}
private openFormatMenu(e: MouseEvent, kind: VerseActionKind, label: string, selection: VerseSelection): void {
const menu = new Menu();
for (const f of FORMATS) {
menu.addItem((item) =>
item.setTitle(`${label}: ${FORMAT_LABEL[f]}`)
.onClick(() => void runVerseAction(this.plugin, kind, selection, f, this.passageEl)));
}
menu.showAtMouseEvent(e);
}
}

View file

@ -1,84 +0,0 @@
import { Notice, TFile } from "obsidian";
import { VerseSelection } from "../core/VerseSelection";
import { formatBlockquote, formatCodeBlock, formatInlineReference } from "../utils/VerseFormatter";
import { InsertTargetModal } from "./InsertTargetModal";
import DisciplesJournalPlugin from "../core/DisciplesJournalPlugin";
export type InsertFormat = "inline" | "codeblock" | "blockquote";
export type VerseActionKind = "copy" | "insert" | "append";
/**
* Build the markdown payload for a selection in the chosen format. `sourceEl` is the
* rendered passage the selection came from only the blockquote format reads it (to
* pull the visible verse text in its own document, which keeps popout windows correct).
*/
export function buildPayload(
plugin: DisciplesJournalPlugin,
selection: VerseSelection,
format: InsertFormat,
sourceEl: HTMLElement,
): string {
const label = selection.label();
if (format === "inline") return formatInlineReference(label);
if (format === "codeblock") return formatCodeBlock(label);
const text = extractSelectedText(selection, sourceEl);
return formatBlockquote(label, text, plugin.settings.preferredBibleVersion);
}
/**
* Pull the visible text of the selected verses out of the rendered passage, reading
* the `.dj-verse` spans the wrapper created (skipping verse-number markers/footnotes).
*/
function extractSelectedText(selection: VerseSelection, sourceEl: HTMLElement): string {
const parts: string[] = [];
for (const { chapter, verse } of selection.verseList()) {
const spans = sourceEl.querySelectorAll<HTMLElement>(
`.dj-verse[data-chapter="${chapter}"][data-verse="${verse}"]`,
);
let text = "";
spans.forEach((span) => {
const clone = span.cloneNode(true) as HTMLElement;
clone.querySelectorAll(".verse-num, .chapter-num, .footnote, .footnotes").forEach((n) => n.remove());
text += clone.textContent ?? "";
});
const trimmed = text.replace(/\s+/g, " ").trim();
if (trimmed) parts.push(trimmed);
}
return parts.join(" ");
}
export async function runVerseAction(
plugin: DisciplesJournalPlugin,
kind: VerseActionKind,
selection: VerseSelection,
format: InsertFormat,
sourceEl: HTMLElement,
): Promise<void> {
const payload = buildPayload(plugin, selection, format, sourceEl);
if (kind === "copy") {
await navigator.clipboard.writeText(payload);
new Notice(`Copied ${selection.label()}`);
return;
}
if (kind === "insert") {
const view = plugin.resolveInsertTarget();
if (!view) {
new Notice("No note to insert into — open a note (not a generated Bible note), or right-click where you want it.");
return;
}
view.editor.replaceSelection(payload);
new Notice(`Passage ${selection.label()} inserted into note ${view.file?.basename ?? "note"}`);
return;
}
// append: pick a note and add to its end (background edit → Vault.process)
new InsertTargetModal(plugin.app, async (file: TFile) => {
await plugin.app.vault.process(file, (data) => {
const sep = data.length === 0 || data.endsWith("\n") ? "" : "\n";
return `${data}${sep}\n${payload}\n`;
});
new Notice(`Appended ${selection.label()} to ${file.basename}`);
}).open();
}

View file

@ -1,165 +0,0 @@
import { Component } from "obsidian";
import { VerseSelection } from "../core/VerseSelection";
import { VerseRef } from "../utils/VerseId";
import { VerseSelectionService, SelectionOwner } from "../core/VerseSelectionService";
import { VerseActionBar } from "./VerseActionBar";
import { wrapPassageVerses } from "./VerseWrapper";
import DisciplesJournalPlugin from "../core/DisciplesJournalPlugin";
let nextId = 0;
const LONG_PRESS_MS = 400;
const MOVE_CANCEL_PX = 10;
export class VerseSelectionController extends Component implements SelectionOwner {
readonly id = `dj-passage-${nextId++}`;
private selection: VerseSelection;
private anchor: VerseRef | null = null;
private bar: VerseActionBar | null = null;
// touch state
private pressTimer: number | null = null;
private dragging = false;
private touchStart: { x: number; y: number } | null = null;
constructor(
private plugin: DisciplesJournalPlugin,
public readonly sourceEl: HTMLElement,
private book: string,
private service: VerseSelectionService,
) {
super();
this.selection = new VerseSelection(book);
}
onload(): void {
wrapPassageVerses(this.sourceEl);
this.sourceEl.addClass("dj-selectable");
this.registerDomEvent(this.sourceEl, "click", (e) => this.onClick(e));
this.registerDomEvent(this.sourceEl, "touchstart", (e) => this.onTouchStart(e), { passive: true });
this.registerDomEvent(this.sourceEl, "touchmove", (e) => this.onTouchMove(e), { passive: false });
this.registerDomEvent(this.sourceEl, "touchend", () => this.onTouchEnd());
this.register(this.service.onChange(() => this.reflect()));
}
onunload(): void {
// If this passage owned the selection (e.g. note re-rendered), drop it.
this.service.clearIfOwner(this);
this.clearPressTimer();
}
private verseAt(node: Element | null): { ref: VerseRef } | null {
const el = node?.closest<HTMLElement>(".dj-verse") ?? null;
if (!el || !this.sourceEl.contains(el)) return null;
const chapter = Number(el.dataset.chapter);
const verse = Number(el.dataset.verse);
if (!chapter || !verse) return null;
return { ref: { chapter, verse } };
}
private onClick(e: MouseEvent): void {
const hit = this.verseAt(e.target instanceof Element ? e.target : null);
if (!hit) return;
e.preventDefault();
if (e.shiftKey && this.anchor) {
this.selection.selectRange(this.anchor, hit.ref);
} else {
this.selection.toggle(hit.ref);
this.anchor = hit.ref;
}
this.commit();
}
private onTouchStart(e: TouchEvent): void {
const hit = this.verseAt(e.target instanceof Element ? e.target : null);
if (!hit) return;
const t = e.touches[0];
this.touchStart = { x: t.clientX, y: t.clientY };
this.anchor = hit.ref;
this.pressTimer = this.sourceEl.win.setTimeout(() => {
// Long-press engaged: select the pressed verse for immediate feedback, then
// subsequent moves extend the range (and stop the page scrolling).
this.dragging = true;
this.selection.add(hit.ref);
this.commit();
}, LONG_PRESS_MS);
}
private onTouchMove(e: TouchEvent): void {
if (!this.touchStart) return;
const t = e.touches[0];
if (!this.dragging) {
// Moved before the long-press fired → treat as a scroll, cancel selection.
const moved = Math.hypot(t.clientX - this.touchStart.x, t.clientY - this.touchStart.y);
if (moved > MOVE_CANCEL_PX) this.clearPressTimer();
return;
}
e.preventDefault(); // we own the gesture now; stop the page scrolling
const under = this.sourceEl.doc.elementFromPoint(t.clientX, t.clientY);
const hit = this.verseAt(under);
if (hit && this.anchor) {
this.selection.selectRange(this.anchor, hit.ref);
this.commit();
}
}
private onTouchEnd(): void {
const wasDragging = this.dragging;
this.clearPressTimer();
if (!wasDragging && this.anchor) {
// short tap → toggle a single verse
this.selection.toggle(this.anchor);
this.commit();
}
this.dragging = false;
this.touchStart = null;
}
private clearPressTimer(): void {
if (this.pressTimer !== null) {
this.sourceEl.win.clearTimeout(this.pressTimer);
this.pressTimer = null;
}
this.dragging = false;
}
/** Push our selection into the shared service (it decides ownership). */
private commit(): void {
this.service.set(this.selection, this);
}
/** Re-render highlight + bar from the service's current state. */
private reflect(): void {
const active = this.service.get();
const owned = active?.owner.id === this.id ? active.selection : null;
this.sourceEl.querySelectorAll<HTMLElement>(".dj-verse").forEach((el) => {
const ref = { chapter: Number(el.dataset.chapter), verse: Number(el.dataset.verse) };
el.toggleClass("dj-verse-selected", !!owned && owned.has(ref));
});
if (owned) {
if (!this.bar) {
this.bar = new VerseActionBar(this.plugin, this.sourceEl, () => this.clearSelection());
this.addChild(this.bar);
}
this.bar.render(owned);
} else {
this.hideBar();
}
}
clearSelection(): void {
this.selection.clear();
this.anchor = null;
this.service.clearIfOwner(this);
}
private hideBar(): void {
if (this.bar) {
this.removeChild(this.bar);
this.bar = null;
}
}
}

View file

@ -1,41 +0,0 @@
import { parseVerseId } from "../utils/VerseId";
/**
* Post-process a rendered ESV passage so each verse's inline text is wrapped in a
* `<span class="dj-verse" data-chapter data-verse>`, making verses selectable and
* highlightable. Idempotent: skips a passage that's already wrapped.
*
* ESV HTML marks verses only with `<b class="verse-num|chapter-num" id="vBBCCCVVV-N">`;
* a verse's text runs as loose nodes until the next marker, sometimes across <p>
* boundaries so we wrap per block element and tag spans with the verse number.
*/
export function wrapPassageVerses(passageEl: HTMLElement): void {
if (passageEl.querySelector(".dj-verse")) return;
const doc = passageEl.doc;
const blocks = passageEl.querySelectorAll("p, li");
blocks.forEach((block) => {
let current: HTMLSpanElement | null = null;
// Snapshot child nodes first; we re-parent them as we go.
const nodes = Array.from(block.childNodes);
for (const node of nodes) {
const marker =
node.instanceOf(HTMLElement) && (node.hasClass("verse-num") || node.hasClass("chapter-num"))
? parseVerseId(node.id)
: null;
if (marker) {
current = doc.createElement("span");
current.addClass("dj-verse");
current.dataset.chapter = String(marker.chapter);
current.dataset.verse = String(marker.verse);
block.insertBefore(current, node);
}
if (current) {
current.appendChild(node); // moves node out of block into the span
}
// Nodes before the first marker (rare) stay where they are.
}
});
}

View file

@ -116,49 +116,6 @@ export class BibleReference {
}
}
/**
* Parse a possibly non-contiguous list like "Genesis 1:2-3, 5" or "Genesis 1:31, 2:1".
* The first item is a full reference; later comma-separated items may be a bare verse
* ("5"), a bare verse range ("5-7") inheriting the prior book+chapter, or
* "chapter:verse[-end]" inheriting the book. Returns null if any item is invalid.
*/
public static parseList(reference: string): BibleReference[] | null {
const trimmed = reference.trim();
if (!trimmed) return null;
const items = trimmed.split(",").map((s) => s.trim().replace(/[–—]/g, "-"));
const out: BibleReference[] = [];
const first = BibleReference.parse(items[0]);
if (!first) return null;
out.push(first);
for (let i = 1; i < items.length; i++) {
const prev = out[out.length - 1];
const item = items[i];
// "chapter:verse" or "chapter:verse-end"
const cv = /^(\d+):(\d+)(?:-(\d+))?$/.exec(item);
if (cv) {
out.push(new BibleReference(prev.book, parseInt(cv[1], 10), parseInt(cv[2], 10),
cv[3] ? parseInt(cv[3], 10) : undefined));
continue;
}
// bare "verse" or "verse-end" (inherit prev chapter)
const v = /^(\d+)(?:-(\d+))?$/.exec(item);
if (v) {
out.push(new BibleReference(prev.book, prev.chapter, parseInt(v[1], 10),
v[2] ? parseInt(v[2], 10) : undefined));
continue;
}
return null;
}
return out;
}
/**
* Get the formatted reference as a string
*/

View file

@ -1,4 +1,4 @@
import {Plugin, MarkdownView, Notice, normalizePath, Editor, Menu, MenuItem, TFile, WorkspaceLeaf} from 'obsidian';
import {Plugin, MarkdownView, Notice, normalizePath} from 'obsidian';
import {ESVApiService} from '../services/ESVApiService';
import {BibleContentService} from '../services/BibleContentService';
import {BibleReferenceRenderer} from '../components/BibleReferenceRenderer';
@ -15,8 +15,6 @@ import {BibleReference} from './BibleReference';
import {BibleEventHandlers} from './BibleEventHandlers';
import {applyCustomFrontmatter, getCustomFrontmatterForReference} from "../utils/FrontmatterUtil";
import {OpenBibleModal} from "../components/OpenBibleModal";
import {VerseSelectionService} from './VerseSelectionService';
import {buildPayload, runVerseAction} from '../components/VerseActions';
/**
* Disciples Journal Plugin for Obsidian
@ -35,12 +33,6 @@ export default class DisciplesJournalPlugin extends Plugin {
private bibleReferenceRenderer: BibleReferenceRenderer;
private bibleEventHandlers: BibleEventHandlers;
private bibleMarkupProcessor: BibleMarkupProcessor;
private verseSelectionService: VerseSelectionService;
// The most recently active editable, non-generated markdown note — the target for
// "Insert selected verses" from the action bar/command (the passage's own pane is
// usually a generated Bible note, which we never want to insert into).
private lastEditableLeaf: WorkspaceLeaf | null = null;
async onload() {
// Initialize settings
@ -50,10 +42,6 @@ export default class DisciplesJournalPlugin extends Plugin {
this.esvApiService = new ESVApiService(this);
this.bibleContentService = new BibleContentService(this, this.esvApiService);
// Holds the single active verse selection across panes; unloads with the plugin.
this.verseSelectionService = new VerseSelectionService();
this.addChild(this.verseSelectionService);
// Check if ESV API token is set and show a notice if it's not
if (!this.settings.esvApiToken) {
new Notice('Disciples Journal: ESV API token not set. Bible content may not load correctly. Visit the plugin settings to add your API token.', 10000);
@ -66,8 +54,7 @@ export default class DisciplesJournalPlugin extends Plugin {
this.bibleReferenceRenderer = new BibleReferenceRenderer(
this.bibleContentService,
this.bibleFiles,
this,
this.verseSelectionService
this
);
// Single, long-lived hover-preview event handler owned by the plugin.
@ -106,45 +93,6 @@ export default class DisciplesJournalPlugin extends Plugin {
callback: () => this.updateAllBibleNoteFrontmatter()
});
this.addCommand({
id: 'insert-selected-verses',
name: 'Insert selected verses at cursor',
callback: () => {
const active = this.verseSelectionService.get();
if (!active) { new Notice('No verses selected.'); return; }
void runVerseAction(this, 'insert', active.selection, this.settings.defaultInsertFormat, active.owner.sourceEl);
}
});
this.addCommand({
id: 'copy-selected-verses',
name: 'Copy selected verses',
callback: () => {
const active = this.verseSelectionService.get();
if (!active) { new Notice('No verses selected.'); return; }
void runVerseAction(this, 'copy', active.selection, this.settings.defaultInsertFormat, active.owner.sourceEl);
}
});
this.addCommand({
id: 'clear-verse-selection',
name: 'Clear verse selection',
callback: () => this.verseSelectionService.clear()
});
// Right-click in any editor while a selection exists → insert at the click point.
this.registerEvent(this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor) => {
const active = this.verseSelectionService.get();
if (!active) return;
const label = active.selection.label();
menu.addItem((item: MenuItem) =>
item.setTitle(`Insert ${label} here`)
.setIcon('book-open')
.onClick(() => editor.replaceSelection(
buildPayload(this, active.selection, this.settings.defaultInsertFormat, active.owner.sourceEl)
)));
}));
// Ribbon icon
this.addRibbonIcon('book-open', 'Open Bible', () => {
new OpenBibleModal(this.app, this.bibleFiles).open();
@ -175,42 +123,6 @@ export default class DisciplesJournalPlugin extends Plugin {
handleActiveLeafChange() {
// Refresh theme/styling when active leaf changes
this.updateBibleStyles();
// Remember the last editable, non-generated note so "Insert" targets the note the
// user was working in — not the (generated) passage note they clicked to select.
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view?.file && !this.isBibleContentFile(view.file)) {
this.lastEditableLeaf = view.leaf;
}
}
/** True when a file lives under the configured Bible content folder (a generated note). */
public isBibleContentFile(file: TFile): boolean {
return file.path.startsWith(normalizePath(this.settings.bibleContentVaultPath) + '/');
}
/**
* Resolve where "Insert selected verses" should go: the most recently active editable,
* non-generated markdown note (if still open), else the active note when it isn't a
* generated Bible note. Returns null when there's no suitable target.
*/
public resolveInsertTarget(): MarkdownView | null {
let leaf = this.lastEditableLeaf;
if (leaf) {
let stillOpen = false;
this.app.workspace.iterateRootLeaves((l) => { if (l === leaf) stillOpen = true; });
if (!stillOpen) leaf = null;
}
if (leaf?.view instanceof MarkdownView) {
return leaf.view;
}
const active = this.app.workspace.getActiveViewOfType(MarkdownView);
if (active?.file && !this.isBibleContentFile(active.file)) {
return active;
}
return null;
}
/**

View file

@ -1,107 +0,0 @@
import { BibleReference } from "./BibleReference";
import { VerseRef } from "../utils/VerseId";
/**
* A mutable, ordered, de-duplicated set of verses within one book. Produces
* contiguous BibleReference runs and a human display label. Pure (no DOM / Obsidian).
*/
export class VerseSelection {
readonly book: string;
private verses: VerseRef[] = [];
constructor(book: string) {
this.book = book;
}
private indexOf(v: VerseRef): number {
return this.verses.findIndex((x) => x.chapter === v.chapter && x.verse === v.verse);
}
has(v: VerseRef): boolean {
return this.indexOf(v) !== -1;
}
isEmpty(): boolean {
return this.verses.length === 0;
}
count(): number {
return this.verses.length;
}
clear(): void {
this.verses = [];
}
add(v: VerseRef): void {
if (!this.has(v)) {
this.verses.push({ chapter: v.chapter, verse: v.verse });
this.sort();
}
}
toggle(v: VerseRef): void {
const i = this.indexOf(v);
if (i === -1) this.add(v);
else this.verses.splice(i, 1);
}
/** Add every verse from anchor..focus when they share a chapter; else just add focus. */
selectRange(anchor: VerseRef, focus: VerseRef): void {
if (anchor.chapter !== focus.chapter) {
this.add(focus);
return;
}
const lo = Math.min(anchor.verse, focus.verse);
const hi = Math.max(anchor.verse, focus.verse);
for (let v = lo; v <= hi; v++) this.add({ chapter: anchor.chapter, verse: v });
}
/** Sorted copy of the selected verses. */
verseList(): VerseRef[] {
return this.verses.map((v) => ({ chapter: v.chapter, verse: v.verse }));
}
private sort(): void {
this.verses.sort((a, b) => a.chapter - b.chapter || a.verse - b.verse);
}
/** Collapse consecutive verses within a chapter into BibleReference ranges. */
runs(): BibleReference[] {
const out: BibleReference[] = [];
let start: VerseRef | null = null;
let prev: VerseRef | null = null;
const flush = () => {
if (!start || !prev) return;
const endVerse = prev.verse === start.verse ? undefined : prev.verse;
out.push(new BibleReference(this.book, start.chapter, start.verse, endVerse));
};
for (const v of this.verses) {
if (prev && v.chapter === prev.chapter && v.verse === prev.verse + 1) {
prev = v;
continue;
}
flush();
start = v;
prev = v;
}
flush();
return out;
}
/** YouVersion-style label, e.g. "Genesis 1:2-3, 5" or "Genesis 1:31, 2:1". */
label(): string {
const runs = this.runs();
if (runs.length === 0) return this.book;
let lastChapter: number | null = null;
const parts: string[] = [];
for (const r of runs) {
const versePart = r.endVerse !== undefined ? `${r.verse}-${r.endVerse}` : `${r.verse}`;
parts.push(r.chapter === lastChapter ? versePart : `${r.chapter}:${versePart}`);
lastChapter = r.chapter;
}
return `${this.book} ${parts.join(", ")}`;
}
}

View file

@ -1,59 +0,0 @@
import { Component } from "obsidian";
import { VerseSelection } from "./VerseSelection";
/** Anything that can own the current selection (a per-passage controller). */
export interface SelectionOwner {
readonly id: string;
/** The rendered passage element — used to extract verse text in the right document. */
readonly sourceEl: HTMLElement;
}
interface ActiveSelection {
selection: VerseSelection;
owner: SelectionOwner;
}
/**
* Holds the single active verse selection across all panes/windows. Controllers
* subscribe; only the owning controller renders highlight + action bar.
*/
export class VerseSelectionService extends Component {
private active: ActiveSelection | null = null;
private listeners = new Set<() => void>();
/** Subscribe to selection changes. Returns an unsubscribe function. */
onChange(cb: () => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
get(): ActiveSelection | null {
return this.active;
}
/** Replace the active selection (or clear with an empty one). */
set(selection: VerseSelection, owner: SelectionOwner): void {
this.active = selection.isEmpty() ? null : { selection, owner };
this.emit();
}
clear(): void {
if (!this.active) return;
this.active = null;
this.emit();
}
/** Clear only if `owner` currently owns the selection (e.g. its passage re-rendered). */
clearIfOwner(owner: SelectionOwner): void {
if (this.active?.owner.id === owner.id) this.clear();
}
onunload(): void {
this.listeners.clear();
this.active = null;
}
private emit(): void {
for (const cb of this.listeners) cb();
}
}

View file

@ -69,30 +69,6 @@ export class BibleContentService {
}
}
/**
* Resolve a possibly non-contiguous reference string ("Genesis 1:2-3, 5") by parsing
* it into runs and resolving each. Returns one BiblePassage whose HTML is the runs'
* HTML concatenated and whose reference is the first run (used for the heading/link).
* Falls back to single-ref resolution when the string isn't a list.
*/
public async getBibleContentList(referenceText: string): Promise<BibleApiResponse> {
const runs = BibleReference.parseList(referenceText);
if (!runs || runs.length === 0) {
const single = BibleReference.parse(referenceText);
if (!single) return BibleApiResponse.error(`Invalid reference: ${referenceText}`, ErrorType.BadApiResponse);
return this.getBibleContent(single);
}
if (runs.length === 1) return this.getBibleContent(runs[0]);
let html = "";
for (const run of runs) {
const res = await this.getBibleContent(run);
if (res.isError()) return res;
html += res.passage.html;
}
return BibleApiResponse.success(new BiblePassage(runs[0], html));
}
private getCachedRef(ref: BibleReference): BiblePassage | undefined {
if (this.passageCache.has(ref)) {
return this.passageCache.get(ref);

View file

@ -67,12 +67,8 @@ export class BibleFiles {
* Open a chapter note for the given reference, creating it from the API first
* if it isn't on disk yet. If the reference includes a verse, the note is
* scrolled to that verse once it renders.
*
* Pass `openInNewTab` to open the chapter in a fresh tab instead of reusing
* the active leaf (used by the "Open Bible" command so it never replaces the
* note the user is reading).
*/
public async openChapterNote(reference: BibleReference, openInNewTab = false): Promise<void> {
public async openChapterNote(reference: BibleReference): Promise<void> {
try {
const chapterPath = BibleFiles.pathForPassage(reference, this.plugin);
if (!BibleFiles.fileExistsForPassage(reference, this.plugin)) {
@ -93,7 +89,7 @@ export class BibleFiles {
const passageNoteFile = BibleFiles.getFileForPassage(reference, this.plugin);
if (passageNoteFile instanceof TFile) {
const leaf = this.plugin.app.workspace.getLeaf(openInNewTab ? 'tab' : false);
const leaf = this.plugin.app.workspace.getLeaf(false);
await leaf.openFile(passageNoteFile);
// If there's a specific verse, scroll to it once it has rendered

View file

@ -22,10 +22,6 @@ export interface DisciplesJournalSettings {
hideFootnotesInPreview: boolean;
chapterNoteFrontmatter: string;
passageNoteFrontmatter: string;
enableVerseSelection: boolean;
defaultInsertFormat: 'inline' | 'codeblock' | 'blockquote';
formatChooserStyle: 'split' | 'toggle' | 'submenu';
enableAppendToNote: boolean;
}
export const DEFAULT_SETTINGS: DisciplesJournalSettings = {
@ -45,11 +41,7 @@ export const DEFAULT_SETTINGS: DisciplesJournalSettings = {
hideFootnotes: false,
hideFootnotesInPreview: false,
chapterNoteFrontmatter: '',
passageNoteFrontmatter: '',
enableVerseSelection: true,
defaultInsertFormat: 'inline',
formatChooserStyle: 'split',
enableAppendToNote: false
passageNoteFrontmatter: ''
};
export class DisciplesJournalSettingsTab extends PluginSettingTab {
@ -274,54 +266,6 @@ export class DisciplesJournalSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
new Setting(containerEl).setName('Verse selection').setHeading();
new Setting(containerEl)
.setName('Enable verse selection')
.setDesc('Tap verses in a rendered passage to select them and copy/insert them into notes.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableVerseSelection)
.onChange(async (value) => {
this.plugin.settings.enableVerseSelection = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default insert format')
.setDesc('The format used by the main button before you pick another.')
.addDropdown(dropdown => dropdown
.addOption('inline', 'Inline reference')
.addOption('codeblock', 'Bible code block')
.addOption('blockquote', 'Blockquote with text')
.setValue(this.plugin.settings.defaultInsertFormat)
.onChange(async (value) => {
this.plugin.settings.defaultInsertFormat = value as DisciplesJournalSettings['defaultInsertFormat'];
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Format chooser style')
.setDesc('How the action bar lets you pick a format.')
.addDropdown(dropdown => dropdown
.addOption('split', 'Split buttons (default format + chevron)')
.addOption('toggle', 'Format toggle + action buttons')
.addOption('submenu', 'Action menus with format submenus')
.setValue(this.plugin.settings.formatChooserStyle)
.onChange(async (value) => {
this.plugin.settings.formatChooserStyle = value as DisciplesJournalSettings['formatChooserStyle'];
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Enable "Append to note…"')
.setDesc('Add an action that appends the selection to the end of a note you pick. Overlaps with Insert at cursor, so it is off by default.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableAppendToNote)
.onChange(async (value) => {
this.plugin.settings.enableAppendToNote = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl).setName('ESV API').setHeading();
const apiInfoDiv = containerEl.createDiv({cls: 'disciples-journal-api-info'});

View file

@ -1,21 +0,0 @@
/** `` `Genesis 1:2-3, 5` `` — inline reference rendered by the plugin's hover preview. */
export function formatInlineReference(label: string): string {
return `\`${label}\``;
}
/** A fenced ```bible block the plugin renders as the full passage. */
export function formatCodeBlock(label: string): string {
return "```bible\n" + label + "\n```";
}
/**
* A markdown blockquote of the actual verse text, ending in a "— <ref> (<version>)"
* citation. Every line of `text` is prefixed so multi-line quotes stay inside the quote.
*/
export function formatBlockquote(label: string, text: string, version: string): string {
const body = text
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
return `${body}\n>\n> — ${label} (${version})`;
}

View file

@ -1,18 +0,0 @@
/** A chapter+verse coordinate within a single book. */
export interface VerseRef {
chapter: number;
verse: number;
}
const VERSE_ID = /^v\d{2}(\d{3})(\d{3})-\d+$/;
/**
* Parse an ESV passage marker id (e.g. "v01001002-1" on a `verse-num`/`chapter-num`
* `<b>`) into its chapter and verse. Returns null for ids that aren't verse markers.
* Book digits are ignored callers know the book from the passage reference.
*/
export function parseVerseId(id: string): VerseRef | null {
const m = VERSE_ID.exec(id);
if (!m) return null;
return { chapter: parseInt(m[1], 10), verse: parseInt(m[2], 10) };
}

View file

@ -527,97 +527,3 @@ button.clear-input-button {
.disciples-journal-book-suggest-input {
padding-right: 1.5em;
}
/* ===== Verse selection ===== */
.dj-selectable .dj-verse {
cursor: pointer;
border-radius: var(--radius-s);
}
.dj-selectable .dj-verse:hover {
background: var(--background-modifier-hover);
}
.dj-verse-selected,
.dj-selectable .dj-verse-selected:hover {
background: var(--text-selection);
box-shadow: inset 0 0 0 1px var(--text-accent);
}
/* Floating action bar bottom-center dock, so it stays in view regardless of how tall
the passage is (a full chapter is taller than the viewport, so anchoring to the passage
would push the bar off-screen). Appears only while verses are selected. */
.dj-verse-action-bar {
position: fixed;
z-index: var(--layer-popover);
left: 50%;
bottom: var(--size-4-6);
transform: translateX(-50%);
max-width: calc(100% - var(--size-4-8));
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: var(--size-4-2);
padding: var(--size-4-2) var(--size-4-3);
border-radius: var(--radius-m);
background: var(--background-secondary);
box-shadow: var(--shadow-l);
}
.dj-verse-action-label {
font-weight: var(--font-bold);
margin-right: var(--size-4-1);
}
.dj-verse-action-bar button {
cursor: pointer;
}
.dj-split-button {
display: inline-flex;
}
.dj-split-button .dj-split-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dj-split-button .dj-split-chevron {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: var(--size-4-1);
padding-right: var(--size-4-1);
}
.dj-format-toggle {
display: inline-flex;
gap: var(--size-2-1);
margin-right: var(--size-4-1);
}
.dj-format-toggle button.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.dj-verse-action-bar button:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
/* Full-width and enlarge touch targets on narrow / mobile layouts */
@media (max-width: 600px) {
.dj-verse-action-bar {
left: var(--size-4-2);
right: var(--size-4-2);
bottom: var(--size-4-3);
transform: none;
max-width: none;
}
.dj-verse-action-bar button {
min-height: 44px;
min-width: 44px;
}
}

View file

@ -179,35 +179,3 @@ test("value-object helpers", async (t) => {
test("constructor throws on an unnormalizable book name", () => {
assert.throws(() => new BibleReference("Xylophone", 1), /Illegal book name/);
});
test("parseList — comma/non-contiguous lists", async (t) => {
await t.test("single ref still parses as a one-element list", () => {
const list = BibleReference.parseList("John 3:16");
assert.ok(list);
assert.equal(list.length, 1);
assert.equal(list[0].toString(), "John 3:16");
});
await t.test("bare verse inherits book + chapter", () => {
const list = BibleReference.parseList("Genesis 1:2-3, 5");
assert.ok(list);
assert.deepEqual(list.map((r) => r.toString()), ["Genesis 1:2-3", "Genesis 1:5"]);
});
await t.test("chapter:verse item inherits only the book", () => {
const list = BibleReference.parseList("Genesis 1:31, 2:1");
assert.ok(list);
assert.deepEqual(list.map((r) => r.toString()), ["Genesis 1:31", "Genesis 2:1"]);
});
await t.test("round-trips a VerseSelection label", () => {
const list = BibleReference.parseList("Genesis 1:2-4, 7, 2:1");
assert.ok(list);
assert.deepEqual(list.map((r) => r.toString()), ["Genesis 1:2-4", "Genesis 1:7", "Genesis 2:1"]);
});
await t.test("invalid item makes the whole list null", () => {
assert.equal(BibleReference.parseList("Genesis 1:2, banana"), null);
assert.equal(BibleReference.parseList(""), null);
});
});

View file

@ -1,23 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatInlineReference, formatCodeBlock, formatBlockquote } from "../src/utils/VerseFormatter";
test("VerseFormatter", async (t) => {
await t.test("inline reference is backtick-wrapped", () => {
assert.equal(formatInlineReference("Genesis 1:2-3, 5"), "`Genesis 1:2-3, 5`");
});
await t.test("code block is a fenced bible block", () => {
assert.equal(formatCodeBlock("Genesis 1:2-3"), "```bible\nGenesis 1:2-3\n```");
});
await t.test("blockquote quotes the text and cites the reference + version", () => {
const out = formatBlockquote("John 3:16", "For God so loved the world...", "ESV");
assert.equal(out, "> For God so loved the world...\n>\n> — John 3:16 (ESV)");
});
await t.test("blockquote prefixes every wrapped line with '> '", () => {
const out = formatBlockquote("Genesis 1:1", "line one\nline two", "ESV");
assert.equal(out, "> line one\n> line two\n>\n> — Genesis 1:1 (ESV)");
});
});

View file

@ -1,20 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseVerseId } from "../src/utils/VerseId";
test("parseVerseId", async (t) => {
await t.test("verse-num id", () => {
assert.deepEqual(parseVerseId("v01001002-1"), { chapter: 1, verse: 2 });
});
await t.test("chapter-num id (first verse)", () => {
assert.deepEqual(parseVerseId("v01001001-1"), { chapter: 1, verse: 1 });
});
await t.test("multi-digit chapter and verse", () => {
assert.deepEqual(parseVerseId("v43011035-2"), { chapter: 11, verse: 35 });
});
await t.test("non-matching ids return null", () => {
assert.equal(parseVerseId("f1-1"), null);
assert.equal(parseVerseId(""), null);
assert.equal(parseVerseId("v123-1"), null);
});
});

View file

@ -1,58 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { VerseSelection } from "../src/core/VerseSelection";
function sel(book: string, ...vs: [number, number][]): VerseSelection {
const s = new VerseSelection(book);
for (const [c, v] of vs) s.toggle({ chapter: c, verse: v });
return s;
}
test("VerseSelection", async (t) => {
await t.test("toggle adds then removes", () => {
const s = sel("Genesis");
s.toggle({ chapter: 1, verse: 2 });
assert.equal(s.has({ chapter: 1, verse: 2 }), true);
s.toggle({ chapter: 1, verse: 2 });
assert.equal(s.has({ chapter: 1, verse: 2 }), false);
assert.equal(s.isEmpty(), true);
});
await t.test("contiguous run collapses to a range label", () => {
const s = sel("Genesis", [1, 2], [1, 3], [1, 4]);
assert.equal(s.label(), "Genesis 1:2-4");
const runs = s.runs();
assert.equal(runs.length, 1);
assert.equal(runs[0].toString(), "Genesis 1:2-4");
});
await t.test("non-contiguous verses join with commas (out-of-order input)", () => {
const s = sel("Genesis", [1, 5], [1, 2], [1, 3]);
assert.equal(s.label(), "Genesis 1:2-3, 5");
assert.deepEqual(s.runs().map((r) => r.toString()), ["Genesis 1:2-3", "Genesis 1:5"]);
});
await t.test("single verse", () => {
assert.equal(sel("John", [3, 16]).label(), "John 3:16");
});
await t.test("multiple chapters repeat the chapter, no cross-chapter merge", () => {
const s = sel("Genesis", [1, 31], [2, 1]);
assert.equal(s.label(), "Genesis 1:31, 2:1");
assert.equal(s.runs().length, 2);
});
await t.test("selectRange fills verses within a chapter", () => {
const s = new VerseSelection("Genesis");
s.selectRange({ chapter: 1, verse: 2 }, { chapter: 1, verse: 5 });
assert.equal(s.label(), "Genesis 1:2-5");
});
await t.test("verseList is sorted", () => {
const s = sel("Genesis", [1, 5], [1, 2]);
assert.deepEqual(s.verseList(), [
{ chapter: 1, verse: 2 },
{ chapter: 1, verse: 5 },
]);
});
});