wiseguru_ReWrite-Voice-Notes/src/insert.ts
WiseGuru 42f0a43c62 Resolve the 1.2.1 review-bot no-unsafe-* warnings at the root (type parity)
All ~30 warnings traced to the bot's lint environment resolving types
differently from local, not to unsafe code (DEVCONFLICTS.md finding 10):

- tsconfig lib was ES2016 while the code uses ES2017-ES2019 APIs
  (Object.entries/values/fromEntries, padStart, Promise.finally); the
  test suite's auto-included @types/node masked it locally. lib is now
  ["DOM", "ES2019"] with types: [] so a mismatch fails npm run build.
- moment's typings don't resolve in the bot's environment (obsidian's
  re-export types via the moment package). All calls now go through
  formatMoment in src/time.ts, a narrow structural alias.
- secrets.ts needed as-BufferSource assertions on TS 5.7+ that older TS
  flags as unnecessary; restructured (inferred ArrayBuffer-backed byte
  helpers, BufferSource param, one 32-byte copy) to need none on any
  TS version. Crypto behavior unchanged.
- The two flagged as-Record casts became an isRecord type predicate.

Docs: CLAUDE.md "Review-bot type environment" gotcha, RELEASING.md
parity section (plus the pre-release docs from the workflow change),
DEVCONFLICTS.md finding 10, ROADMAP.md Unreleased entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:55:52 -07:00

269 lines
9.3 KiB
TypeScript

import { App, MarkdownView, Modal, normalizePath, Notice, Setting, TFile } from 'obsidian';
import { formatMoment } from 'time';
import { NewFileCollisionMode, NoteTemplate } from './types';
import { guardReservedName, sanitizeFilename } from './templates-folder';
export type InsertStage = 'cursor' | 'newFile' | 'append';
export interface InsertParams {
app: App;
template: NoteTemplate;
content: string;
collisionMode: NewFileCollisionMode;
// Frontmatter values to write into the created note (newFile mode only). Keyed
// by property name; values may be empty (the scaffold). Ignored by cursor/append,
// which write into a user-owned existing note.
properties?: Record<string, string>;
// LLM-generated filename title (newFile only; cursor/append ignore it). When
// present and non-empty it fills the {{title}} token, or becomes the whole file
// name when the template has no token. Empty/whitespace falls back to the name
// template's static expansion.
title?: string;
}
export interface InsertResult {
mode: InsertStage;
path?: string;
}
export async function insertOutput(params: InsertParams): Promise<InsertResult> {
switch (params.template.insertMode) {
case 'cursor':
return insertAtCursor(params);
case 'newFile':
return insertNewFile(params);
case 'append':
return insertAppend(params);
}
}
async function insertAtCursor(params: InsertParams): Promise<InsertResult> {
const view = params.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice('No active editor; appending to the last edited note instead.');
return insertAppend(params);
}
view.editor.replaceSelection(params.content);
return { mode: 'cursor', path: view.file?.path };
}
async function insertAppend(params: InsertParams): Promise<InsertResult> {
const view = params.app.workspace.getActiveViewOfType(MarkdownView);
let file: TFile | null = view?.file ?? null;
if (!file) {
file = findLastEditedMarkdown(params.app);
}
if (!file) {
new Notice('No note is open. Creating a new note.');
return insertNewFile(params);
}
await params.app.vault.process(file, (existing) => {
const needsBlankLine = existing.length > 0 && !existing.endsWith('\n\n');
const separator = existing.length === 0 ? '' : needsBlankLine ? (existing.endsWith('\n') ? '\n' : '\n\n') : '';
return existing + separator + params.content;
});
return { mode: 'append', path: file.path };
}
async function insertNewFile(params: InsertParams): Promise<InsertResult> {
const folder = params.template.newFileFolder.trim();
const nameTemplate = params.template.newFileNameTemplate.trim() || 'ReWrite {{date}} {{time}}';
const safeTitle = params.title ? titleToFilename(params.title) : '';
let expanded: string;
if (nameTemplate.includes('{{title}}')) {
// Template composes the title explicitly; {{title}} -> '' when no title, then
// collapse the doubled separators a missing token leaves behind.
expanded = expandFilenameTemplate(nameTemplate, params.title)
.replace(/\s{2,}/g, ' ')
.trim();
if (!expanded) expanded = expandFilenameTemplate('ReWrite {{date}} {{time}}');
} else if (safeTitle) {
// Flag on but no {{title}} token: the title becomes the whole file name.
expanded = safeTitle;
} else {
expanded = expandFilenameTemplate(nameTemplate);
}
const filename = expanded.endsWith('.md') ? expanded : `${expanded}.md`;
if (folder) {
await ensureFolder(params.app, folder);
}
const requestedPath = normalizePath(folder ? `${folder}/${filename}` : filename);
const path = await resolveNewFilePath(params.app, requestedPath, params.collisionMode);
const file = await params.app.vault.create(path, params.content);
// content carries no leading frontmatter (stripped in the pipeline), so this
// prepends a real `---...---` block above any `![[audio]]` embed.
if (params.properties && Object.keys(params.properties).length > 0) {
await params.app.fileManager.processFrontMatter(file, (fm) => {
Object.assign(fm, params.properties);
});
}
await params.app.workspace.openLinkText(file.path, '', true);
return { mode: 'newFile', path: file.path };
}
async function resolveNewFilePath(app: App, requestedPath: string, mode: NewFileCollisionMode): Promise<string> {
if (!app.vault.getAbstractFileByPath(requestedPath)) return requestedPath;
const nextFree = nextFreePath(app, requestedPath);
if (mode === 'auto') return nextFree;
const chosen = await promptForRename(app, requestedPath, nextFree);
if (chosen === null) {
throw new Error('Insert canceled: file already exists.');
}
if (app.vault.getAbstractFileByPath(chosen)) {
throw new Error(`File still exists at ${chosen}. Try again with a different name.`);
}
return chosen;
}
function nextFreePath(app: App, path: string): string {
const slash = path.lastIndexOf('/');
const dot = path.lastIndexOf('.');
const hasExt = dot > slash;
const stem = hasExt ? path.slice(0, dot) : path;
const ext = hasExt ? path.slice(dot) : '';
for (let n = 1; n < 1000; n++) {
const candidate = `${stem}-${n}${ext}`;
if (!app.vault.getAbstractFileByPath(candidate)) return candidate;
}
throw new Error(`Could not find a free filename near ${path}.`);
}
function promptForRename(app: App, conflictPath: string, suggestion: string): Promise<string | null> {
return new Promise((resolve) => {
const modal = new RenamePromptModal(app, conflictPath, suggestion, resolve);
modal.open();
});
}
class RenamePromptModal extends Modal {
private resolved = false;
constructor(
app: App,
private readonly conflictPath: string,
private readonly suggestion: string,
private readonly resolve: (value: string | null) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
this.modalEl.addClass('rewrite-rename-modal');
contentEl.createEl('h2', { text: 'File already exists' });
contentEl.createEl('p', { text: `A file already exists at ${this.conflictPath}. Choose a new path.` });
let value = this.suggestion;
new Setting(contentEl)
.setName('New path')
.addText((t) => {
t.setValue(this.suggestion);
t.inputEl.addClass('rewrite-rename-input');
t.onChange((v) => {
value = v;
});
window.setTimeout(() => {
t.inputEl.focus();
const dot = this.suggestion.lastIndexOf('.');
const slash = this.suggestion.lastIndexOf('/');
const end = dot > slash ? dot : this.suggestion.length;
t.inputEl.setSelectionRange(slash + 1, end);
}, 0);
t.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.finish(value);
}
});
});
new Setting(contentEl)
.addButton((b) => {
b.setButtonText('Save').setCta().onClick(() => this.finish(value));
})
.addButton((b) => {
b.setButtonText('Cancel').onClick(() => this.finish(null));
});
}
onClose(): void {
this.contentEl.empty();
if (!this.resolved) {
this.resolved = true;
this.resolve(null);
}
}
private finish(value: string | null): void {
if (this.resolved) return;
const trimmed = value === null ? null : normalizePath(value.trim());
if (trimmed !== null && trimmed.length === 0) {
new Notice('Path cannot be empty.');
return;
}
this.resolved = true;
this.resolve(trimmed);
this.close();
}
}
function expandFilenameTemplate(template: string, title?: string): string {
const now = new Date();
const safeTitle = title ? titleToFilename(title) : '';
return template
.replace(/\{\{date\}\}/g, formatMoment('YYYY-MM-DD', now))
.replace(/\{\{time\}\}/g, formatMoment('HHmmss', now))
.replace(/\{\{title\}\}/g, safeTitle);
}
const MAX_TITLE_LEN = 100;
// Make an LLM-generated title safe to use as a file name stem. The model output is
// far less trusted than a template name, so this hardens beyond sanitizeFilename:
// collapse whitespace, strip the illegal char set, drop leading dots (hidden/invalid)
// and trailing dots/spaces (Windows trims them silently), cap length, and guard the
// reserved Windows device names. Returns '' when nothing usable remains.
function titleToFilename(title: string): string {
const collapsed = title.replace(/\s+/g, ' ').trim();
if (!collapsed) return '';
let safe = sanitizeFilename(collapsed)
.replace(/^\.+/, '')
.replace(/[ .]+$/, '');
if (safe.length > MAX_TITLE_LEN) {
safe = safe.slice(0, MAX_TITLE_LEN).replace(/[ .]+$/, '');
}
// Re-guard after the extra stripping/capping above: it can turn a name that wasn't
// reserved (or wasn't all-dots) right after sanitizeFilename into one that is.
safe = guardReservedName(safe);
// sanitizeFilename/guardReservedName return 'Untitled' for empty/dot-only input; treat
// that as "no usable title" so the caller falls back to the date template rather than a
// literal "Untitled" file.
return safe === 'Untitled' ? '' : safe;
}
function findLastEditedMarkdown(app: App): TFile | null {
const files = app.vault.getMarkdownFiles();
let best: TFile | null = null;
let bestMtime = -1;
for (const f of files) {
if (f.stat.mtime > bestMtime) {
bestMtime = f.stat.mtime;
best = f;
}
}
return best;
}
async function ensureFolder(app: App, folder: string): Promise<void> {
const normalized = normalizePath(folder);
if (app.vault.getAbstractFileByPath(normalized)) return;
const parts = normalized.split('/');
let current = '';
for (const part of parts) {
if (!part) continue;
current = current ? `${current}/${part}` : part;
if (!app.vault.getAbstractFileByPath(current)) {
await app.vault.createFolder(current);
}
}
}