mirror of
https://github.com/algometrix/second_brain_builder.git
synced 2026-07-22 08:29:32 +00:00
Add sub-note creation, README cost focus, .env vault path; bump to 1.0.9
- New "Create sub-note from selection" command and context menu entry: splits a selection into topics, generates linked notes in a sub-folder, with series cross-linking back to the parent note - README: lead with the no-extra-cost design (existing AI subscriptions via CLIs, or free local Ollama) plus a token usage caveat - Fix scripts can read OBSIDIAN_VAULT from a gitignored repo-root .env Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
311feaf328
commit
18147c27d8
13 changed files with 344 additions and 42 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,6 +3,7 @@ main.js
|
|||
release/
|
||||
.claude/
|
||||
data.json
|
||||
.env
|
||||
|
||||
# Personal mode definitions -- see "Note Modes" in README.md
|
||||
modes.json
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ Source lives in `src/`, bundled to a single root `main.js` by esbuild (entry poi
|
|||
- **manifest.json** -- Obsidian plugin manifest (id: `second-brain-builder`)
|
||||
- **scripts/** -- build helpers and vault fix scripts:
|
||||
- **sync-modes.js** -- prepares `modes.json` before builds (see above); wired into `npm run build` and `npm run dev`
|
||||
- **vault-root.js** -- shared vault path resolution for the fix scripts (first non-flag CLI argument, or `OBSIDIAN_VAULT` env var)
|
||||
- **vault-root.js** -- shared vault path resolution for the fix scripts (first non-flag CLI argument, `OBSIDIAN_VAULT` env var, or `OBSIDIAN_VAULT=` in a gitignored repo-root `.env`)
|
||||
- **fix-all.js** -- unified runner that executes all fix scripts below in order
|
||||
- **fix-callout-fences.js** -- fixes callout code fences missing the `> ` prefix on closing ``` or content lines
|
||||
- **fix-currency-dollars.js** -- escapes unescaped `$` currency signs that Obsidian misinterprets as LaTeX
|
||||
|
|
@ -49,7 +49,7 @@ Source lives in `src/`, bundled to a single root `main.js` by esbuild (entry poi
|
|||
- **fix-mermaid-quotes.js** -- strips nested double quotes inside already-quoted mermaid labels (inner `"` to `'`)
|
||||
- **fix-mermaid-list.js** -- fixes "Unsupported markdown: list" errors by converting `N.`/`N)` to `N:` in labels and joining `- item` lines with `<br/>`
|
||||
|
||||
All fix scripts share the same interface: `node scripts/<script> <vault-path>` for detect-only, add `--fix` to apply. The vault path can also come from the `OBSIDIAN_VAULT` environment variable.
|
||||
All fix scripts share the same interface: `node scripts/<script> <vault-path>` for detect-only, add `--fix` to apply. The vault path can also come from the `OBSIDIAN_VAULT` environment variable or a gitignored repo-root `.env` file.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ All fix scripts share the same interface: `node scripts/<script> <vault-path>` f
|
|||
|
||||
### Two Command Families
|
||||
|
||||
- **Note creation** ("Explain selection"): creates a new note from selection, replaces selection with a `[[wiki-link]]`. Uses modes from `modes.json` + custom user modes.
|
||||
- **Note creation** ("Explain selection"): creates a new note from selection, replaces selection with a `[[wiki-link]]`. Uses modes from `modes.json` + custom user modes. The "Create sub-note" variant splits the selection into multiple topics, generates one note per topic into a named sub-folder of the current note's folder, and injects series instructions so the notes cross-link each other and the parent note.
|
||||
- **Inline actions** ("Enhance selection"): transforms selected text in-place (expand, simplify, add examples, add diagram, summarize, challenge, fix & polish, ELI5, translate to code). Defined as `INLINE_ACTIONS` in main.ts.
|
||||
|
||||
### AI Provider Abstraction
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -4,7 +4,19 @@
|
|||

|
||||

|
||||
|
||||
Turn any topic into a folder of interlinked, deep-dive study notes: a generated index hub, mermaid concept maps, and wiki-links that light up Obsidian's graph view. Powered by an AI CLI (Claude Code, Gemini, or Codex) or a fully local Ollama model. It also explains selections into linked notes and enhances text in place. Desktop only. Works on Windows, macOS, and Linux.
|
||||
Turn any topic into a folder of interlinked, deep-dive study notes: a generated index hub, mermaid concept maps, and wiki-links that light up Obsidian's graph view. Powered by an AI CLI (Claude Code, Gemini, or Codex) or a fully local Ollama model. It also explains selections into linked notes and enhances text in place. No API keys and no per-token billing: it runs on the AI subscription you already have, or a free local model. Desktop only. Works on Windows, macOS, and Linux.
|
||||
|
||||
## Why This Plugin: No Extra AI Bills
|
||||
|
||||
Most AI note plugins ask for an API key and bill you per token, on top of whatever AI subscription you already pay for. This plugin is designed around a different idea: **note generation should not cost you anything extra.**
|
||||
|
||||
- **Use the subscription you already have.** The plugin drives the official CLI tools (Claude Code, Gemini CLI, Codex CLI) that come with a Claude, Google, or ChatGPT plan. You log in to the CLI once with your normal account. No API key, no separate billing, no per-note charges.
|
||||
- **Or pay nothing at all.** If you have no subscription, or you want your notes to stay fully private, point the plugin at a local [Ollama](https://ollama.com) model. It runs on your own machine, costs nothing, and nothing ever leaves your computer.
|
||||
- **Switch anytime.** All four backends sit behind one setting. Start with Ollama for free, move to a CLI when you want higher quality, or mix depending on the task.
|
||||
|
||||
The goal is simple: if you already pay for an AI service, your notes are covered by it, and if you do not, a local model gets you there for free.
|
||||
|
||||
> **Keep an eye on your usage.** Generating notes still consumes tokens against your plan's limits, and batch features like folder generation can use a lot in one run. Watch your usage while generating, especially with large selections or long series. Ollama has no limits since everything runs locally.
|
||||
|
||||
One prompt ("why is the dollar the world's reserve currency?") produced this index hub and the 16 linked notes behind it:
|
||||
|
||||
|
|
@ -70,6 +82,12 @@ Select a word or phrase, run **"Explain selection with AI"** (or right-click the
|
|||
|
||||
Use this when a term deserves its own note and you want your vault to grow into a linked knowledge base while you read.
|
||||
|
||||
### Create Sub-Notes
|
||||
|
||||
Select text in a note and run **"Create sub-note from selection with AI"** or right-click → **Create sub-note**. The modal asks for a sub-folder name and a note style, plus a topic list with one note topic per line. If the selection looks like a list ("Kafka, Redis, WebSocket Gateway"), the topics are prefilled from it; if it is prose ("US stopped redeeming dollars for gold"), it stays one line you can rewrite into whatever topics you actually want. On submit, each topic becomes its own note inside the sub-folder under the current note's folder. The generated notes cross-link each other, link back to the parent note, and reuse wiki-links from the surrounding context. Topics taken from the selection replace it with links; rewritten topics keep your text and append the links after it. The sub-folder name is remembered for next time.
|
||||
|
||||
Use this to grow a deep-dive layer under an existing series, for example selecting "Kafka, Redis, WebSocket Gateway" in a system design note and generating a linked `Components` folder in one step.
|
||||
|
||||
### Enhance Selection (inline actions)
|
||||
|
||||
Run **"Enhance selection with AI"** to transform the selected text in place, or insert the result below it. All nine actions are also one right-click away under **AI actions**:
|
||||
|
|
@ -304,7 +322,7 @@ Rebuild. Both files are gitignored, so your prompts stay private. You can also a
|
|||
|
||||
LLMs occasionally emit markdown that Obsidian renders badly, mostly around mermaid diagrams and callouts. The `scripts/` folder contains standalone fixers that scan every `.md` file in a vault, report problems, and optionally repair them.
|
||||
|
||||
All scripts take the vault path as the first argument (or the `OBSIDIAN_VAULT` environment variable) and are detect-only unless you pass `--fix`:
|
||||
All scripts take the vault path as the first argument, or from the `OBSIDIAN_VAULT` environment variable, or from an `OBSIDIAN_VAULT=` line in a gitignored `.env` file at the repo root. They are detect-only unless you pass `--fix`:
|
||||
|
||||
```bash
|
||||
node scripts/fix-all.js "/path/to/YourVault" # detect only, all fixers
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "second-brain-builder",
|
||||
"name": "Second Brain Builder",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"minAppVersion": "1.8.7",
|
||||
"description": "Grow your vault into a linked knowledge graph: select any word or phrase to generate detailed, wiki-linked explanation notes using Claude Code, Gemini, Codex, or a local Ollama model.",
|
||||
"author": "algometrix",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "second-brain-builder",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "second-brain-builder",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.9",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^16.11.6",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "second-brain-builder",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"description": "Obsidian plugin that grows your vault into a linked knowledge graph using Claude Code, Gemini, Codex, or Ollama",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,31 @@
|
|||
// Shared vault path resolution for all fix scripts.
|
||||
// The vault path comes from the first non-flag CLI argument, or from the
|
||||
// OBSIDIAN_VAULT environment variable if no argument is given.
|
||||
// The vault path comes from the first non-flag CLI argument, then the
|
||||
// OBSIDIAN_VAULT environment variable, then an OBSIDIAN_VAULT entry in a
|
||||
// .env file at the repo root (KEY=VALUE lines, # comments allowed).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function readVaultFromDotEnv() {
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
if (!fs.existsSync(envPath)) return undefined;
|
||||
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*OBSIDIAN_VAULT\s*=\s*(.+?)\s*$/);
|
||||
if (match && !line.trim().startsWith('#')) {
|
||||
return match[1].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveVaultRoot() {
|
||||
const arg = process.argv.slice(2).find(a => !a.startsWith('--'));
|
||||
const root = arg || process.env.OBSIDIAN_VAULT;
|
||||
const root = arg || process.env.OBSIDIAN_VAULT || readVaultFromDotEnv();
|
||||
|
||||
if (!root) {
|
||||
const script = path.basename(process.argv[1] || 'fix-script.js');
|
||||
console.error(`Usage: node scripts/${script} <vault-path> [--fix]`);
|
||||
console.error('Or set the OBSIDIAN_VAULT environment variable to your vault path.');
|
||||
console.error('Or set OBSIDIAN_VAULT in the environment or in a .env file at the repo root.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
|
|||
123
src/main.ts
123
src/main.ts
|
|
@ -59,7 +59,7 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
const from = editor.getCursor("from");
|
||||
const to = editor.getCursor("to");
|
||||
new NoteCreatorModal(this.app, this.getStandardModes(), selection, this.settings.lastModeId || this.settings.defaultModeId, (configs) => {
|
||||
const linkReplacement = this.buildLinkReplacement(editor, from, to, selection, configs);
|
||||
const linkReplacement = this.buildLinkReplacement(editor, from, to, selection, configs, view.file?.parent?.path ?? "");
|
||||
for (const config of configs) {
|
||||
void this.enqueueNote(editor, view, selection, config, linkReplacement);
|
||||
}
|
||||
|
|
@ -67,6 +67,20 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// ── Sub-note creation command ──
|
||||
this.addCommand({
|
||||
id: "create-sub-note",
|
||||
name: "Create sub-note from selection with AI",
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
const selection = editor.getSelection().trim();
|
||||
if (!selection) {
|
||||
new Notice("Select some text first.");
|
||||
return;
|
||||
}
|
||||
this.openSubNoteCreator(editor, view, selection, editor.getCursor("from"), editor.getCursor("to"));
|
||||
},
|
||||
});
|
||||
|
||||
// ── Inline action command (shows all actions) ──
|
||||
this.addCommand({
|
||||
id: "enhance-selection",
|
||||
|
|
@ -224,7 +238,7 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
.setIcon("book-open")
|
||||
.onClick(() => {
|
||||
new NoteCreatorModal(this.app, this.getStandardModes(), selection, this.settings.lastModeId || this.settings.defaultModeId, (configs) => {
|
||||
const linkReplacement = this.buildLinkReplacement(editor, from, to, selection, configs);
|
||||
const linkReplacement = this.buildLinkReplacement(editor, from, to, selection, configs, view.file?.parent?.path ?? "");
|
||||
for (const config of configs) {
|
||||
void this.enqueueNote(editor, view, selection, config, linkReplacement);
|
||||
}
|
||||
|
|
@ -232,6 +246,15 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
});
|
||||
});
|
||||
|
||||
// Sub-note creation entry
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Create sub-note from "${selLabel}"`)
|
||||
.setIcon("folder-tree")
|
||||
.onClick(() => {
|
||||
this.openSubNoteCreator(editor, view, selection, from, to);
|
||||
});
|
||||
});
|
||||
|
||||
// Inline enhance entry (opens full action picker)
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Enhance "${selLabel}" with AI`)
|
||||
|
|
@ -470,6 +493,11 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
await this.saveSettings();
|
||||
}
|
||||
|
||||
async saveLastSubfolder(subfolder: string) {
|
||||
this.settings.lastSubfolder = subfolder;
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
removeFromQueue(index: number) {
|
||||
this.queue.splice(index, 1);
|
||||
this.updateStatusBar();
|
||||
|
|
@ -582,19 +610,88 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
appWithSettings.setting.openTabById(this.manifest.id);
|
||||
}
|
||||
|
||||
private openSubNoteCreator(editor: Editor, view: MarkdownView, selection: string, from: EditorPosition, to: EditorPosition) {
|
||||
new NoteCreatorModal(
|
||||
this.app,
|
||||
this.getStandardModes(),
|
||||
selection,
|
||||
this.settings.lastModeId || this.settings.defaultModeId,
|
||||
(configs) => {
|
||||
const parentName = view.file?.basename ?? "";
|
||||
const titles = configs.map(c => c.title);
|
||||
const contextOverride = this.buildSelectionContext(editor, from, selection);
|
||||
for (const config of configs) {
|
||||
config.seriesInstructions = this.buildSubNoteSeriesInstructions(parentName, config.title, titles, selection);
|
||||
config.contextOverride = contextOverride;
|
||||
}
|
||||
// If every topic came from the selected text (single term or a
|
||||
// selected list), the links can replace the selection. Topics the
|
||||
// user rewrote keep the selected prose intact, with links appended.
|
||||
const topicsMatchSelection = configs.every(c =>
|
||||
selection.toLowerCase().includes(c.title.toLowerCase())
|
||||
);
|
||||
const linkReplacement = this.buildLinkReplacement(editor, from, to, selection, configs, view.file?.parent?.path ?? "", !topicsMatchSelection);
|
||||
for (const config of configs) {
|
||||
void this.enqueueNote(editor, view, config.title, config, linkReplacement);
|
||||
}
|
||||
if (configs[0]?.subfolder) {
|
||||
void this.saveLastSubfolder(configs[0].subfolder);
|
||||
}
|
||||
},
|
||||
(modeId) => { void this.saveLastMode(modeId); },
|
||||
this.settings.lastSubfolder || DEFAULT_SETTINGS.lastSubfolder,
|
||||
view.file?.parent?.path ?? "",
|
||||
).open();
|
||||
}
|
||||
|
||||
// The note context sent with each sub-note: a window of the parent note
|
||||
// centered on the selection, so the selected passage is always included
|
||||
// even in long notes.
|
||||
private buildSelectionContext(editor: Editor, from: EditorPosition, selection: string): string {
|
||||
const content = editor.getValue();
|
||||
if (content.length <= 2000) return content;
|
||||
const selStart = editor.posToOffset(from);
|
||||
const windowStart = Math.max(0, selStart - Math.floor((2000 - selection.length) / 2));
|
||||
const windowEnd = Math.min(content.length, windowStart + 2000 + selection.length);
|
||||
return (windowStart > 0 ? "...(earlier content omitted)\n" : "")
|
||||
+ content.slice(windowStart, windowEnd)
|
||||
+ (windowEnd < content.length ? "\n...(later content omitted)" : "");
|
||||
}
|
||||
|
||||
private buildSubNoteSeriesInstructions(parentName: string, title: string, allTitles: string[], selection: string): string {
|
||||
const siblings = allTitles.filter(t => t !== title);
|
||||
const lines = [
|
||||
`This note is part of a linked sub-note series generated from the note "${parentName}".`,
|
||||
`The user selected this passage in that note: "${selection.replace(/"/g, '\\"')}". Stay grounded in it: the note should explain its topic in a way that illuminates this passage.`,
|
||||
`Link back to the parent note as [[${parentName}]] in a Related Concepts or similar section.`,
|
||||
];
|
||||
if (siblings.length > 0) {
|
||||
lines.push(`Sibling notes being generated in the same series: ${siblings.map(t => `[[${t}]]`).join(", ")}. Cross-link them with [[wiki-links]] wherever they are relevant to the content.`);
|
||||
}
|
||||
lines.push("Also reuse [[wiki-links]] that already appear in the provided context where relevant, so this note connects to the existing note series.");
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
private buildLinkReplacement(
|
||||
editor: Editor,
|
||||
from: EditorPosition,
|
||||
to: EditorPosition,
|
||||
selection: string,
|
||||
configs: NoteConfig[]
|
||||
configs: NoteConfig[],
|
||||
folderPath: string,
|
||||
keepSelectionText = false
|
||||
): PendingLinkReplacement {
|
||||
const links = configs.map(c => {
|
||||
if (!c.subfolder) return `[[${c.title}]]`;
|
||||
const dir = folderPath ? `${folderPath}/${c.subfolder}` : c.subfolder;
|
||||
return `[[${dir}/${c.title}|${c.title}]]`;
|
||||
});
|
||||
return {
|
||||
editor,
|
||||
from,
|
||||
to,
|
||||
selection,
|
||||
linksText: configs.map(c => `[[${c.title}]]`).join(" | "),
|
||||
linksText: keepSelectionText ? `${selection} (${links.join(", ")})` : links.join(" | "),
|
||||
applied: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -627,8 +724,16 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
const targetFolder = config.subfolder
|
||||
? normalizePath(folderPath ? `${folderPath}/${config.subfolder}` : config.subfolder)
|
||||
: folderPath;
|
||||
if (config.subfolder && !this.app.vault.getAbstractFileByPath(targetFolder)) {
|
||||
// Sibling sub-notes enqueue concurrently and can race on the same
|
||||
// folder; a real failure surfaces later in vault.create().
|
||||
await this.app.vault.createFolder(targetFolder).catch(() => {});
|
||||
}
|
||||
const newNotePath = normalizePath(
|
||||
folderPath ? `${folderPath}/${noteName}.md` : `${noteName}.md`
|
||||
targetFolder ? `${targetFolder}/${noteName}.md` : `${noteName}.md`
|
||||
);
|
||||
|
||||
const existing = this.app.vault.getAbstractFileByPath(newNotePath);
|
||||
|
|
@ -642,15 +747,19 @@ export default class ClaudeExplainerPlugin extends Plugin {
|
|||
}
|
||||
|
||||
const contextLines = editor.getValue();
|
||||
const contextSnippet = contextLines.length > 2000
|
||||
const contextSnippet = config.contextOverride ?? (contextLines.length > 2000
|
||||
? contextLines.slice(0, 2000) + "\n...(truncated)"
|
||||
: contextLines;
|
||||
: contextLines);
|
||||
|
||||
let fullPrompt = config.mode.prompt
|
||||
.replace(/\{selection\}/g, selection.replace(/"/g, '\\"'))
|
||||
.replace(/\{context\}/g, contextSnippet.replace(/"/g, '\\"'))
|
||||
+ getOutputRules();
|
||||
|
||||
if (config.seriesInstructions) {
|
||||
fullPrompt += `\n\n${config.seriesInstructions}`;
|
||||
}
|
||||
|
||||
if (config.extraInstructions) {
|
||||
fullPrompt += `\n\nAdditional instructions from the user:\n${config.extraInstructions}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { App, Modal, Notice } from "obsidian";
|
||||
import { InlineAction, InlineActionConfig, NoteConfig, NoteMode } from "../types";
|
||||
import { createModeGrid, renderModeCard, setModalTitle } from "../ui";
|
||||
import { sanitizeFilename } from "../utils";
|
||||
import { sanitizeFilename, splitIntoTopics } from "../utils";
|
||||
|
||||
// ─── Note Creator Modal (multi-select) ───────────────────────────
|
||||
|
||||
|
|
@ -14,10 +14,15 @@ export class NoteCreatorModal extends Modal {
|
|||
selectedModes: NoteMode[];
|
||||
titleValue: string;
|
||||
extraValue: string;
|
||||
subfolderDefault: string | null;
|
||||
subfolderValue: string;
|
||||
topicsValue: string;
|
||||
parentFolderPath: string;
|
||||
previewEl: HTMLDivElement | null;
|
||||
btnEls: { id: string; el: HTMLElement }[];
|
||||
submitBtnEl: HTMLButtonElement | null;
|
||||
|
||||
constructor(app: App, modes: NoteMode[], selection: string, lastModeId: string, onSubmit: (configs: NoteConfig[]) => void, onModeUsed?: (modeId: string) => void) {
|
||||
constructor(app: App, modes: NoteMode[], selection: string, lastModeId: string, onSubmit: (configs: NoteConfig[]) => void, onModeUsed?: (modeId: string) => void, subfolderDefault?: string, parentFolderPath?: string) {
|
||||
super(app);
|
||||
this.modes = modes;
|
||||
this.selection = String(selection || "");
|
||||
|
|
@ -27,18 +32,34 @@ export class NoteCreatorModal extends Modal {
|
|||
this.selectedModes = [];
|
||||
this.titleValue = sanitizeFilename(this.selection);
|
||||
this.extraValue = "";
|
||||
this.subfolderDefault = subfolderDefault ?? null;
|
||||
this.subfolderValue = subfolderDefault ?? "";
|
||||
this.topicsValue = splitIntoTopics(this.selection).join("\n");
|
||||
this.parentFolderPath = parentFolderPath ?? "";
|
||||
this.previewEl = null;
|
||||
this.btnEls = [];
|
||||
this.submitBtnEl = null;
|
||||
}
|
||||
|
||||
parseTopics(): string[] {
|
||||
return [...new Set(this.topicsValue.split("\n").map(t => sanitizeFilename(t)).filter(Boolean))];
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const el = this.contentEl;
|
||||
el.empty();
|
||||
this.modalEl.addClass("ch-modal");
|
||||
setModalTitle(this, "Create note");
|
||||
setModalTitle(this, this.subfolderDefault === null ? "Create note" : "Create sub-notes");
|
||||
|
||||
const wrapper = el.createDiv();
|
||||
|
||||
if (this.subfolderDefault !== null) {
|
||||
const intro = wrapper.createDiv({ cls: "ch-flow" });
|
||||
const sel = this.selection.length > 80 ? this.selection.slice(0, 80) + "..." : this.selection;
|
||||
intro.createDiv({ cls: "ch-flow-selection", text: `Selected: "${sel}"` });
|
||||
intro.createDiv({ text: "Each title below becomes its own note, generated using your selection and this note as context. Rename the titles to anything - the notes stay grounded in the selected text, and they link to each other and back to this note." });
|
||||
}
|
||||
|
||||
wrapper.createDiv({ cls: "ch-label", text: "Note style" });
|
||||
wrapper.createDiv({ cls: "ch-hint", text: "Click multiple to blend styles into one note" });
|
||||
|
||||
|
|
@ -47,14 +68,47 @@ export class NoteCreatorModal extends Modal {
|
|||
|
||||
const sep = wrapper.createDiv({ cls: "ch-sep" });
|
||||
|
||||
sep.createDiv({ cls: "ch-label", text: "Note title" });
|
||||
const titleInput = sep.createEl("input", { type: "text", cls: "ch-input" });
|
||||
titleInput.value = this.titleValue;
|
||||
titleInput.addEventListener("input", () => { this.titleValue = titleInput.value; });
|
||||
titleInput.addEventListener("keydown", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") { e.preventDefault(); this.doSubmit(); }
|
||||
});
|
||||
if (this.subfolderDefault === null) {
|
||||
sep.createDiv({ cls: "ch-label", text: "Note title" });
|
||||
const titleInput = sep.createEl("input", { type: "text", cls: "ch-input" });
|
||||
titleInput.value = this.titleValue;
|
||||
titleInput.addEventListener("input", () => { this.titleValue = titleInput.value; });
|
||||
titleInput.addEventListener("keydown", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") { e.preventDefault(); this.doSubmit(); }
|
||||
});
|
||||
} else {
|
||||
sep.createDiv({ cls: "ch-label", text: "Sub-folder (created under this note's folder)" });
|
||||
const subfolderInput = sep.createEl("input", { type: "text", cls: "ch-input" });
|
||||
subfolderInput.value = this.subfolderValue;
|
||||
subfolderInput.placeholder = "e.g. Components, Deep Dives...";
|
||||
subfolderInput.addEventListener("input", () => {
|
||||
this.subfolderValue = subfolderInput.value;
|
||||
this.updatePreview();
|
||||
});
|
||||
subfolderInput.addEventListener("keydown", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") { e.preventDefault(); this.doSubmit(); }
|
||||
});
|
||||
|
||||
sep.createDiv({ cls: "ch-label", text: "Note titles (one per line)" });
|
||||
sep.createDiv({ cls: "ch-hint", text: "Each line is the exact title of one new note" });
|
||||
const topicsInput = sep.createEl("textarea", { cls: "ch-textarea" });
|
||||
topicsInput.value = this.topicsValue;
|
||||
topicsInput.rows = Math.min(8, Math.max(3, this.topicsValue.split("\n").length));
|
||||
topicsInput.addEventListener("input", () => {
|
||||
this.topicsValue = topicsInput.value;
|
||||
this.updatePreview();
|
||||
this.updateModeButtons();
|
||||
});
|
||||
topicsInput.addEventListener("keydown", (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.doSubmit(); }
|
||||
});
|
||||
|
||||
this.previewEl = sep.createDiv();
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
sep.createDiv({ cls: "ch-label", text: "Extra instructions (optional)" });
|
||||
const extraInput = sep.createEl("textarea", { cls: "ch-textarea" });
|
||||
|
|
@ -86,6 +140,7 @@ export class NoteCreatorModal extends Modal {
|
|||
this.selectedModes.push(mode);
|
||||
}
|
||||
this.updateModeButtons();
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
updateModeButtons(): void {
|
||||
|
|
@ -94,30 +149,71 @@ export class NoteCreatorModal extends Modal {
|
|||
b.el.toggleClass("is-selected", selectedIds.has(b.id));
|
||||
}
|
||||
if (this.submitBtnEl) {
|
||||
const generateLabel = this.subfolderDefault === null
|
||||
? "Generate"
|
||||
: `Generate ${this.parseTopics().length || "?"} sub-note${this.parseTopics().length === 1 ? "" : "s"}`;
|
||||
if (this.selectedModes.length === 0) {
|
||||
this.submitBtnEl.disabled = true;
|
||||
this.submitBtnEl.setText("Select a note style");
|
||||
} else if (this.selectedModes.length === 1) {
|
||||
this.submitBtnEl.disabled = false;
|
||||
this.submitBtnEl.setText("Generate - " + this.selectedModes[0].name);
|
||||
this.submitBtnEl.setText(`${generateLabel} - ${this.selectedModes[0].name}`);
|
||||
} else {
|
||||
this.submitBtnEl.disabled = false;
|
||||
this.submitBtnEl.setText(`Generate blended - ${this.selectedModes.map(m => m.name).join(" + ")}`);
|
||||
this.submitBtnEl.setText(`${generateLabel} blended - ${this.selectedModes.map(m => m.name).join(" + ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePreview(): void {
|
||||
if (!this.previewEl) return;
|
||||
this.previewEl.empty();
|
||||
const subfolder = sanitizeFilename(this.subfolderValue);
|
||||
const dir = this.parentFolderPath
|
||||
? `${this.parentFolderPath}/${subfolder || "?"}`
|
||||
: (subfolder || "?");
|
||||
const titles = this.parseTopics();
|
||||
this.previewEl.createDiv({ cls: "ch-label", text: "What will happen" });
|
||||
if (titles.length === 0) {
|
||||
this.previewEl.createDiv({ cls: "ch-hint", text: "Nothing yet - add at least one title line above" });
|
||||
return;
|
||||
}
|
||||
const styleName = this.selectedModes.length > 0
|
||||
? this.selectedModes.map(m => m.name).join(" + ")
|
||||
: "the selected style";
|
||||
const list = this.previewEl.createDiv({ cls: "ch-preview-list" });
|
||||
for (const title of titles) {
|
||||
list.createDiv({ cls: "ch-preview-item", text: `${dir}/${title}.md - a ${styleName} note about "${title}"` });
|
||||
}
|
||||
list.createDiv({ cls: "ch-preview-note", text: "Each note is written from your selected passage and the surrounding note, and links to the other notes above and back to this note." });
|
||||
}
|
||||
|
||||
doSubmit(): void {
|
||||
if (this.selectedModes.length === 0) {
|
||||
new Notice("Pick at least one note style.");
|
||||
return;
|
||||
}
|
||||
const title = this.titleValue.trim();
|
||||
if (!title) {
|
||||
new Notice("Note title cannot be empty.");
|
||||
return;
|
||||
let titles: string[];
|
||||
let subfolder: string | undefined;
|
||||
if (this.subfolderDefault === null) {
|
||||
const title = sanitizeFilename(this.titleValue.trim());
|
||||
if (!title) {
|
||||
new Notice("Note title cannot be empty.");
|
||||
return;
|
||||
}
|
||||
titles = [title];
|
||||
} else {
|
||||
subfolder = sanitizeFilename(this.subfolderValue);
|
||||
if (!subfolder) {
|
||||
new Notice("Sub-folder name cannot be empty.");
|
||||
return;
|
||||
}
|
||||
titles = this.parseTopics();
|
||||
if (titles.length === 0) {
|
||||
new Notice("Add at least one sub-note title.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const sanitizedTitle = sanitizeFilename(title);
|
||||
this.close();
|
||||
|
||||
// Remember the last used mode (first selected)
|
||||
|
|
@ -130,11 +226,12 @@ export class NoteCreatorModal extends Modal {
|
|||
finalMode = NoteCreatorModal.createBlendedMode(this.selectedModes);
|
||||
}
|
||||
|
||||
this.onSubmit([{
|
||||
this.onSubmit(titles.map(title => ({
|
||||
mode: finalMode,
|
||||
title: sanitizedTitle,
|
||||
title,
|
||||
extraInstructions: this.extraValue.trim(),
|
||||
}]);
|
||||
subfolder,
|
||||
})));
|
||||
}
|
||||
|
||||
static createBlendedMode(modes: NoteMode[]): NoteMode {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export interface NoteConfig {
|
|||
mode: NoteMode;
|
||||
title: string;
|
||||
extraInstructions: string;
|
||||
subfolder?: string;
|
||||
seriesInstructions?: string;
|
||||
contextOverride?: string;
|
||||
}
|
||||
|
||||
export interface InlineActionConfig {
|
||||
|
|
@ -91,6 +94,7 @@ export interface ClaudeExplainerSettings {
|
|||
modelFlag: string;
|
||||
defaultModeId: string;
|
||||
lastModeId: string;
|
||||
lastSubfolder: string;
|
||||
customModes: NoteMode[];
|
||||
enableLogging: boolean;
|
||||
aiProvider: AIProvider;
|
||||
|
|
@ -108,6 +112,7 @@ export const DEFAULT_SETTINGS: ClaudeExplainerSettings = {
|
|||
modelFlag: "",
|
||||
defaultModeId: "",
|
||||
lastModeId: "",
|
||||
lastSubfolder: "Deep Dives",
|
||||
customModes: [],
|
||||
enableLogging: true,
|
||||
aiProvider: "claude",
|
||||
|
|
|
|||
12
src/utils.ts
12
src/utils.ts
|
|
@ -22,6 +22,18 @@ export function sanitizeFilename(name: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
export function splitIntoTopics(text: string): string[] {
|
||||
const parts = text
|
||||
.split(/\r?\n|[,;]/)
|
||||
.map(p => p.replace(/^[-*•\d.)\s]+/, "").trim())
|
||||
.filter(Boolean);
|
||||
// Only treat the selection as a topic list when every part is short and
|
||||
// title-like; otherwise it is prose (e.g. a sentence with commas) and the
|
||||
// user will name the topics themselves.
|
||||
const looksLikeList = parts.length > 1 && parts.every(p => p.length <= 50 && p.split(/\s+/).length <= 6);
|
||||
return looksLikeList ? [...new Set(parts)] : [text.replace(/\s+/g, " ").trim()];
|
||||
}
|
||||
|
||||
export function extractJsonArray(raw: string): string {
|
||||
const start = raw.indexOf("[");
|
||||
const end = raw.lastIndexOf("]");
|
||||
|
|
|
|||
46
styles.css
46
styles.css
|
|
@ -153,6 +153,52 @@
|
|||
|
||||
/* ─── Modal form elements ──────────────────────────────────────── */
|
||||
|
||||
.ch-flow {
|
||||
background: var(--background-secondary);
|
||||
border-left: 3px solid var(--interactive-accent);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.ch-flow-selection {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ch-preview-list {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ch-preview-item {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 12px;
|
||||
color: var(--text-normal);
|
||||
padding: 2px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ch-preview-item::before {
|
||||
content: "+ ";
|
||||
color: var(--color-green, var(--interactive-accent));
|
||||
}
|
||||
|
||||
.ch-preview-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.ch-sep {
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
padding-top: 12px;
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@
|
|||
"1.0.5": "1.8.7",
|
||||
"1.0.6": "1.8.7",
|
||||
"1.0.7": "1.8.7",
|
||||
"1.0.8": "1.8.7"
|
||||
"1.0.8": "1.8.7",
|
||||
"1.0.9": "1.8.7"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue