mirror of
https://github.com/scotttomaszewski/obsidian-disciples-journal.git
synced 2026-07-22 12:20:30 +00:00
Compare commits
30 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd1e4558b | ||
|
|
f47e3177d4 | ||
|
|
dadcef7bc1 | ||
|
|
3bfd78d90c | ||
|
|
08723df2f5 | ||
|
|
e661205654 | ||
|
|
8df899b6ed | ||
|
|
b3d215c4e3 | ||
|
|
8ddcc59be5 | ||
|
|
2bc00828a4 | ||
|
|
2ea9bc7f0f | ||
|
|
f42759c0e2 | ||
|
|
a8442d2a39 | ||
|
|
ab12b922f2 | ||
|
|
973c9435f1 | ||
|
|
4304baa175 | ||
|
|
1be2f41afc | ||
|
|
4cab57f53e | ||
|
|
fb34d464d8 | ||
|
|
6fb1b08ea3 | ||
|
|
48d714f776 | ||
|
|
2e9dd5f525 | ||
|
|
d0c3af1303 | ||
|
|
edb18749da | ||
|
|
31ee9aeaa0 | ||
|
|
7d6b3c5b40 | ||
|
|
3ec0418e70 | ||
|
|
9167f0cc10 | ||
|
|
d92abc4aa9 | ||
|
|
ad89a157b4 |
38 changed files with 3284 additions and 81 deletions
|
|
@ -52,6 +52,10 @@ 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
|
||||
|
||||
|
|
@ -82,6 +86,18 @@ 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
|
||||
|
||||
|
|
@ -90,9 +106,11 @@ 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/`
|
||||
|
||||
|
|
@ -115,7 +133,20 @@ note renders
|
|||
```
|
||||
|
||||
Full passages follow the same resolution path through `getBibleContent`, but render
|
||||
the whole passage inline (`processFullBiblePassage`) with navigation + heading.
|
||||
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.
|
||||
|
||||
## Storage model
|
||||
|
||||
|
|
@ -129,9 +160,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 `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).
|
||||
`*.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).
|
||||
|
||||
## Build
|
||||
|
||||
|
|
|
|||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,7 +1,27 @@
|
|||
# Changelog
|
||||
# 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`).
|
||||
|
||||
## 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
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -10,6 +10,14 @@ 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,
|
||||
|
|
@ -52,5 +60,9 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
36
FOLLOWUPS.md
36
FOLLOWUPS.md
|
|
@ -1,27 +1,33 @@
|
|||
# Workspace Follow-Ups
|
||||
# Follow-ups
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Add new entries at the top. Remove entries when done (commit message can reference them).
|
||||
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 (1–4 h) · M (1 day) · L (multi-day).
|
||||
|
||||
## Entry format
|
||||
<!-- 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
|
||||
-->
|
||||
|
||||
Each entry should include:
|
||||
## 1. Finish pop-out window styling
|
||||
|
||||
- **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 (1–4 h), M (1 day), L (multi-day)
|
||||
|
||||
---
|
||||
|
||||
## Finish pop-out window styling
|
||||
**Status:** open
|
||||
|
||||
- **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` (`:131-158`) flags this in a
|
||||
- **Context:** `DisciplesJournalPlugin.updateBibleStyles` (`:219-246`) 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
|
||||
|
|
|
|||
85
ROADMAP.md
85
ROADMAP.md
|
|
@ -1,20 +1,85 @@
|
|||
# Roadmap
|
||||
|
||||
Larger planned or in-flight efforts. For small deferred findings see
|
||||
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
|
||||
[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. Promote one to a real plan/spec doc (and a
|
||||
> `## Status` section) when work actually starts.
|
||||
> 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.
|
||||
|
||||
## No efforts currently in flight.
|
||||
Each item carries a **Lens** (User-facing / Technical health / Growth & ecosystem)
|
||||
and an **Effort** tag: XS (<1 h) · S (1–4 h) · M (1 day) · L (multi-day).
|
||||
|
||||
## Observed future directions
|
||||
## 1. Multiple Bible versions
|
||||
|
||||
- **Multiple Bible versions.** The storage path scheme is already
|
||||
- **Lens:** User-facing · **Effort:** L
|
||||
- The storage path scheme is already
|
||||
`<bibleContentVaultPath>/<preferredBibleVersion>/...` and settings expose a
|
||||
"preferred version", but `BookNames`, `ESVApiService`, and rendering assume ESV
|
||||
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).
|
||||
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:** S–M
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -31,15 +31,24 @@ The repo follows `eslint-plugin-obsidianmd` (see `eslint.config.mjs`) and the
|
|||
|
||||
## Releasing
|
||||
|
||||
Releases go through the `justfile` (`just release <version>`), which:
|
||||
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:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### gh token note
|
||||
|
||||
|
|
|
|||
|
|
@ -82,3 +82,13 @@ 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.
|
||||
|
|
|
|||
|
|
@ -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:60-65`, `BibleEventHandlers.ts`.)
|
||||
handler. (`DisciplesJournalPlugin.ts:73-77`, `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:131-158`.)
|
||||
not your change. (`DisciplesJournalPlugin.ts:219-246`.)
|
||||
|
||||
## 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,72-80`.)
|
||||
(`BibleContentService.ts:14,97-103`.)
|
||||
|
||||
## scrollToVerse watches the DOM instead of guessing a delay
|
||||
|
||||
|
|
@ -39,3 +39,44 @@ 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.
|
||||
|
|
|
|||
|
|
@ -44,3 +44,19 @@ 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.
|
||||
|
|
|
|||
1658
docs/superpowers/plans/2026-06-02-verse-selection.md
Normal file
1658
docs/superpowers/plans/2026-06-02-verse-selection.md
Normal file
File diff suppressed because it is too large
Load diff
213
docs/superpowers/specs/2026-06-02-verse-selection-design.md
Normal file
213
docs/superpowers/specs/2026-06-02-verse-selection-design.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# 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.
|
||||
|
|
@ -26,8 +26,15 @@ 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.
|
||||
|
||||
This is the first suite; extend it as other pure logic becomes test-worthy.
|
||||
Coverage is the pure logic (parsing, formatting, value objects); extend it as other
|
||||
pure logic becomes test-worthy.
|
||||
|
||||
## Gating
|
||||
|
||||
|
|
|
|||
65
justfile
65
justfile
|
|
@ -1,22 +1,65 @@
|
|||
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
|
||||
if [[ `git status --porcelain` ]]; then
|
||||
echo "Cannot release: 'git status' is not clean. Commit/push or stash changes first"
|
||||
exit 0
|
||||
|
||||
# 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
|
||||
fi
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
token="$(just _gh-token)"
|
||||
if [[ -n "$token" ]]; then export GH_TOKEN="$token"; fi
|
||||
gh release create "{{version}}" --title "{{version}}" --notes "" main.js manifest.json styles.css
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "disciples-journal",
|
||||
"name": "Disciples Journal",
|
||||
"version": "0.13.3",
|
||||
"version": "1.14.0",
|
||||
"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
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-disciples-journal",
|
||||
"version": "0.13.2",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-disciples-journal",
|
||||
"version": "0.13.2",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-disciples-journal",
|
||||
"version": "0.13.3",
|
||||
"version": "1.14.0",
|
||||
"description": "Embed Bible references and passages in your notes and read the Bible.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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.parse(content);
|
||||
const reference = BibleReference.parseList(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.parse(t.textContent);
|
||||
const reference = BibleReference.parseList(t.textContent);
|
||||
if (!reference) {
|
||||
new Notice("Invalid Bible reference: " + t.textContent, 10000);
|
||||
return;
|
||||
}
|
||||
|
||||
void contentService.getBibleContent(reference).then(response => {
|
||||
void contentService.getBibleContentList(t.textContent).then(response => {
|
||||
if (response.isError()) {
|
||||
new Notice(response.errorMessage, 10000);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ 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
|
||||
|
|
@ -15,15 +17,18 @@ export class BibleReferenceRenderer {
|
|||
private bibleNavigation: BibleNavigation;
|
||||
private plugin: DisciplesJournalPlugin;
|
||||
private eventHandlers: BibleEventHandlers;
|
||||
private selectionService: VerseSelectionService;
|
||||
|
||||
constructor(
|
||||
bibleContentService: BibleContentService,
|
||||
bibleFiles: BibleFiles,
|
||||
plugin: DisciplesJournalPlugin
|
||||
plugin: DisciplesJournalPlugin,
|
||||
selectionService: VerseSelectionService
|
||||
) {
|
||||
this.bibleContentService = bibleContentService;
|
||||
this.plugin = plugin;
|
||||
this.bibleNavigation = new BibleNavigation(bibleFiles, plugin.app);
|
||||
this.selectionService = selectionService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,17 +58,17 @@ export class BibleReferenceRenderer {
|
|||
}
|
||||
|
||||
try {
|
||||
const reference = BibleReference.parse(codeText);
|
||||
if (!reference) {
|
||||
// Accept single refs and non-contiguous lists (e.g. "Genesis 1:2, 4, 8")
|
||||
if (!BibleReference.parseList(codeText)) {
|
||||
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.getBibleContent(reference);
|
||||
const response = await this.bibleContentService.getBibleContentList(codeText);
|
||||
if (response.isError()) {
|
||||
new Notice(response.errorMessage, 10000);
|
||||
continue;
|
||||
|
|
@ -88,10 +93,9 @@ export class BibleReferenceRenderer {
|
|||
* Process full Bible passage code blocks
|
||||
*/
|
||||
public async processFullBiblePassage(source: string, el: HTMLElement): Promise<void> {
|
||||
// Parse the reference
|
||||
// Parse the reference (supports non-contiguous lists like "Genesis 1:2-3, 5")
|
||||
const reference = source.trim();
|
||||
const parsedRef = BibleReference.parse(reference);
|
||||
if (!parsedRef) {
|
||||
if (!BibleReference.parseList(reference) && !BibleReference.parse(reference)) {
|
||||
const message = `Invalid bible reference: ${source}`;
|
||||
console.error(message);
|
||||
const errorContainer = el.createEl('div');
|
||||
|
|
@ -100,8 +104,8 @@ export class BibleReferenceRenderer {
|
|||
return;
|
||||
}
|
||||
|
||||
// Grab the content
|
||||
const response = await this.bibleContentService.getBibleContent(parsedRef);
|
||||
// Grab the content (resolves and concatenates each contiguous run)
|
||||
const response = await this.bibleContentService.getBibleContentList(reference);
|
||||
|
||||
if (response.isError()) {
|
||||
const errorContainer = el.createEl('div');
|
||||
|
|
@ -137,6 +141,14 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
21
src/components/InsertTargetModal.ts
Normal file
21
src/components/InsertTargetModal.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
await this.bibleFiles.openChapterNote(new BibleReference(item.book, 1), true);
|
||||
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));
|
||||
await this.bibleFiles.openChapterNote(new BibleReference(item.book, item.chapter), true);
|
||||
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));
|
||||
await this.bibleFiles.openChapterNote(new BibleReference(item.book, item.chapter), true);
|
||||
loadingNotice.hide();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
src/components/VerseActionBar.ts
Normal file
102
src/components/VerseActionBar.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
84
src/components/VerseActions.ts
Normal file
84
src/components/VerseActions.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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();
|
||||
}
|
||||
165
src/components/VerseSelectionController.ts
Normal file
165
src/components/VerseSelectionController.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/components/VerseWrapper.ts
Normal file
41
src/components/VerseWrapper.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -116,6 +116,49 @@ 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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {Plugin, MarkdownView, Notice, normalizePath} from 'obsidian';
|
||||
import {Plugin, MarkdownView, Notice, normalizePath, Editor, Menu, MenuItem, TFile, WorkspaceLeaf} from 'obsidian';
|
||||
import {ESVApiService} from '../services/ESVApiService';
|
||||
import {BibleContentService} from '../services/BibleContentService';
|
||||
import {BibleReferenceRenderer} from '../components/BibleReferenceRenderer';
|
||||
|
|
@ -15,6 +15,8 @@ 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
|
||||
|
|
@ -33,6 +35,12 @@ 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
|
||||
|
|
@ -42,6 +50,10 @@ 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);
|
||||
|
|
@ -54,7 +66,8 @@ export default class DisciplesJournalPlugin extends Plugin {
|
|||
this.bibleReferenceRenderer = new BibleReferenceRenderer(
|
||||
this.bibleContentService,
|
||||
this.bibleFiles,
|
||||
this
|
||||
this,
|
||||
this.verseSelectionService
|
||||
);
|
||||
|
||||
// Single, long-lived hover-preview event handler owned by the plugin.
|
||||
|
|
@ -93,6 +106,45 @@ 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();
|
||||
|
|
@ -123,6 +175,42 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
107
src/core/VerseSelection.ts
Normal file
107
src/core/VerseSelection.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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(", ")}`;
|
||||
}
|
||||
}
|
||||
59
src/core/VerseSelectionService.ts
Normal file
59
src/core/VerseSelectionService.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,30 @@ 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);
|
||||
|
|
|
|||
|
|
@ -67,8 +67,12 @@ 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): Promise<void> {
|
||||
public async openChapterNote(reference: BibleReference, openInNewTab = false): Promise<void> {
|
||||
try {
|
||||
const chapterPath = BibleFiles.pathForPassage(reference, this.plugin);
|
||||
if (!BibleFiles.fileExistsForPassage(reference, this.plugin)) {
|
||||
|
|
@ -89,7 +93,7 @@ export class BibleFiles {
|
|||
|
||||
const passageNoteFile = BibleFiles.getFileForPassage(reference, this.plugin);
|
||||
if (passageNoteFile instanceof TFile) {
|
||||
const leaf = this.plugin.app.workspace.getLeaf(false);
|
||||
const leaf = this.plugin.app.workspace.getLeaf(openInNewTab ? 'tab' : false);
|
||||
await leaf.openFile(passageNoteFile);
|
||||
|
||||
// If there's a specific verse, scroll to it once it has rendered
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ 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 = {
|
||||
|
|
@ -41,7 +45,11 @@ export const DEFAULT_SETTINGS: DisciplesJournalSettings = {
|
|||
hideFootnotes: false,
|
||||
hideFootnotesInPreview: false,
|
||||
chapterNoteFrontmatter: '',
|
||||
passageNoteFrontmatter: ''
|
||||
passageNoteFrontmatter: '',
|
||||
enableVerseSelection: true,
|
||||
defaultInsertFormat: 'inline',
|
||||
formatChooserStyle: 'split',
|
||||
enableAppendToNote: false
|
||||
};
|
||||
|
||||
export class DisciplesJournalSettingsTab extends PluginSettingTab {
|
||||
|
|
@ -266,6 +274,54 @@ 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'});
|
||||
|
|
|
|||
21
src/utils/VerseFormatter.ts
Normal file
21
src/utils/VerseFormatter.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** `` `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})`;
|
||||
}
|
||||
18
src/utils/VerseId.ts
Normal file
18
src/utils/VerseId.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/** 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) };
|
||||
}
|
||||
94
styles.css
94
styles.css
|
|
@ -527,3 +527,97 @@ 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,3 +179,35 @@ 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
23
test/VerseFormatter.test.ts
Normal file
23
test/VerseFormatter.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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)");
|
||||
});
|
||||
});
|
||||
20
test/VerseId.test.ts
Normal file
20
test/VerseId.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
58
test/VerseSelection.test.ts
Normal file
58
test/VerseSelection.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue