Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import { Notice, Plugin, TFile } from "obsidian";
|
||||
import { Timer } from "src/recorder/Timer";
|
||||
import { Controls } from "src/recorder/Controls";
|
||||
import { AudioHandler } from "src/transcription/AudioHandler";
|
||||
import { NibbleAISettingsTab } from "src/NibbleAISettingsTab";
|
||||
import { SettingsManager, PluginSettings } from "src/SettingsManager";
|
||||
import { NativeAudioRecorder } from "src/recorder/AudioRecorder";
|
||||
import { RecordingStatus, StatusBar } from "src/recorder/StatusBar";
|
||||
import { getExtensionFromMimeType } from "src/transcription/utils";
|
||||
import { Proofreader } from "src/proofreading/Proofreader";
|
||||
import { TitleGenerator } from "src/title/TitleGenerator";
|
||||
import { PropertyFiller } from "src/property/PropertyFiller";
|
||||
|
||||
export default class NibbleAI extends Plugin {
|
||||
settings: PluginSettings;
|
||||
settingsManager: SettingsManager;
|
||||
timer: Timer;
|
||||
recorder: NativeAudioRecorder;
|
||||
audioHandler: AudioHandler;
|
||||
controls: Controls | null = null;
|
||||
statusBar: StatusBar;
|
||||
proofreader: Proofreader;
|
||||
titleGenerator: TitleGenerator;
|
||||
propertyFiller: PropertyFiller;
|
||||
|
||||
async onload() {
|
||||
this.settingsManager = new SettingsManager(this);
|
||||
this.settings = await this.settingsManager.loadSettings();
|
||||
|
||||
this.addRibbonIcon("bot", "Open NibbleAI recording controls", () => {
|
||||
this.openControls();
|
||||
});
|
||||
|
||||
this.addSettingTab(new NibbleAISettingsTab(this.app, this));
|
||||
|
||||
this.timer = new Timer();
|
||||
this.audioHandler = new AudioHandler(this);
|
||||
this.recorder = new NativeAudioRecorder();
|
||||
const deviceId =
|
||||
this.settings.audioDeviceId === "default"
|
||||
? null
|
||||
: this.settings.audioDeviceId;
|
||||
this.recorder.setDeviceId(deviceId);
|
||||
|
||||
this.statusBar = new StatusBar(this);
|
||||
|
||||
this.proofreader = new Proofreader(this);
|
||||
this.titleGenerator = new TitleGenerator(this);
|
||||
this.propertyFiller = new PropertyFiller(this);
|
||||
|
||||
// Timer reacts to recording state changes
|
||||
this.statusBar.onChange((status) => {
|
||||
switch (status) {
|
||||
case RecordingStatus.Recording:
|
||||
this.timer.start();
|
||||
break;
|
||||
case RecordingStatus.Paused:
|
||||
this.timer.pause();
|
||||
break;
|
||||
case RecordingStatus.Processing:
|
||||
case RecordingStatus.Idle:
|
||||
this.timer.reset();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommands();
|
||||
this.registerEvents();
|
||||
this.registerUriHandler();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
if (this.controls) {
|
||||
this.controls.close();
|
||||
}
|
||||
this.statusBar.remove();
|
||||
}
|
||||
|
||||
// ── Recording state transitions ──
|
||||
|
||||
async startRecording() {
|
||||
if (
|
||||
this.statusBar.status === RecordingStatus.Recording ||
|
||||
this.statusBar.status === RecordingStatus.Paused
|
||||
) {
|
||||
new Notice("Already recording");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.recorder.startRecording();
|
||||
this.statusBar.updateStatus(RecordingStatus.Recording);
|
||||
new Notice("Recording...");
|
||||
} catch (err) {
|
||||
this.statusBar.updateStatus(RecordingStatus.Idle);
|
||||
new Notice("✘ Could not start recording");
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording() {
|
||||
if (
|
||||
this.statusBar.status !== RecordingStatus.Recording &&
|
||||
this.statusBar.status !== RecordingStatus.Paused
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.statusBar.updateStatus(RecordingStatus.Processing);
|
||||
const audioBlob = await this.recorder.stopRecording();
|
||||
const extension = getExtensionFromMimeType(this.recorder.getMimeType());
|
||||
const fileName = `${new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")}.${extension}`;
|
||||
await this.audioHandler.sendAudioData(audioBlob, fileName);
|
||||
this.statusBar.updateStatus(RecordingStatus.Idle);
|
||||
}
|
||||
|
||||
async pauseRecording() {
|
||||
if (this.statusBar.status === RecordingStatus.Recording) {
|
||||
await this.recorder.pauseRecording();
|
||||
this.statusBar.updateStatus(RecordingStatus.Paused);
|
||||
new Notice("Recording paused");
|
||||
} else if (this.statusBar.status === RecordingStatus.Paused) {
|
||||
await this.recorder.pauseRecording();
|
||||
this.statusBar.updateStatus(RecordingStatus.Recording);
|
||||
new Notice("Recording resumed");
|
||||
}
|
||||
}
|
||||
|
||||
async cancelRecording() {
|
||||
if (
|
||||
this.statusBar.status !== RecordingStatus.Recording &&
|
||||
this.statusBar.status !== RecordingStatus.Paused
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.recorder.stopRecording();
|
||||
this.statusBar.updateStatus(RecordingStatus.Idle);
|
||||
new Notice("Recording cancelled");
|
||||
}
|
||||
|
||||
openControls() {
|
||||
if (!this.controls) {
|
||||
this.controls = new Controls(this);
|
||||
}
|
||||
this.controls.open();
|
||||
}
|
||||
|
||||
// ── Commands ──
|
||||
|
||||
addCommands() {
|
||||
// Transcription / Recording
|
||||
this.addCommand({
|
||||
id: "start-stop-recording",
|
||||
name: "Start/stop recording",
|
||||
icon: "mic",
|
||||
callback: async () => {
|
||||
if (
|
||||
this.statusBar.status !== RecordingStatus.Recording &&
|
||||
this.statusBar.status !== RecordingStatus.Paused
|
||||
) {
|
||||
await this.startRecording();
|
||||
} else {
|
||||
await this.stopRecording();
|
||||
}
|
||||
},
|
||||
hotkeys: [{ modifiers: ["Alt"], key: "Q" }],
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "upload-audio-file",
|
||||
name: "Upload audio file",
|
||||
icon: "upload",
|
||||
callback: () => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept =
|
||||
"audio/*,video/*,.mp4,.m4a,.wav,.webm,.ogg,.mp3";
|
||||
fileInput.onchange = async (event) => {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
const audioBlob = file.slice(0, file.size, file.type);
|
||||
await this.audioHandler.sendAudioData(
|
||||
audioBlob,
|
||||
file.name
|
||||
);
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "pause-resume-recording",
|
||||
name: "Pause/resume recording",
|
||||
icon: "pause",
|
||||
callback: () => this.pauseRecording(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-recording-controls",
|
||||
name: "Open recording controls",
|
||||
icon: "popup-open",
|
||||
callback: () => this.openControls(),
|
||||
});
|
||||
|
||||
// Proofreading
|
||||
this.addCommand({
|
||||
id: "fix-punctuation-grammar",
|
||||
name: "Proofread with AI",
|
||||
icon: "wand",
|
||||
editorCallback: (editor) => {
|
||||
const selection = editor.getSelection();
|
||||
if (!selection || selection.trim().length === 0) {
|
||||
new Notice("No text selected.");
|
||||
return;
|
||||
}
|
||||
this.proofreader.fixSelection(editor);
|
||||
},
|
||||
});
|
||||
|
||||
// Title Generation
|
||||
this.addCommand({
|
||||
id: "generate-title",
|
||||
name: "Generate title for current note",
|
||||
icon: "heading",
|
||||
callback: async () => {
|
||||
await this.titleGenerator.generateTitle();
|
||||
},
|
||||
});
|
||||
|
||||
// Property Filler
|
||||
this.addCommand({
|
||||
id: "fill-property",
|
||||
name: "Fill property with AI",
|
||||
icon: "pencil",
|
||||
callback: async () => {
|
||||
await this.propertyFiller.fillProperty();
|
||||
},
|
||||
});
|
||||
|
||||
// Dynamic preset property commands from settings
|
||||
const propertyNames = this.settings.propertyPresets
|
||||
? this.settings.propertyPresets.split(",").map(s => s.trim()).filter(s => s.length > 0)
|
||||
: [];
|
||||
|
||||
const propertyIcons: Record<string, string> = {
|
||||
summary: "list",
|
||||
tags: "tags",
|
||||
};
|
||||
|
||||
for (const prop of propertyNames) {
|
||||
const safeId = prop.toLowerCase().replace(/[^a-z0-9]/g, "-");
|
||||
this.addCommand({
|
||||
id: `fill-property-${safeId}`,
|
||||
name: `Fill "${prop}" property`,
|
||||
icon: propertyIcons[prop] || "pencil",
|
||||
callback: async () => {
|
||||
await this.propertyFiller.fillSpecificProperty(prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ──
|
||||
|
||||
registerEvents() {
|
||||
// Right-click audio files to transcribe
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-menu", (menu, file) => {
|
||||
if (!(file instanceof TFile)) return;
|
||||
|
||||
const audioExtensions = [
|
||||
".mp3",
|
||||
".mp4",
|
||||
".mpeg",
|
||||
".mpga",
|
||||
".m4a",
|
||||
".wav",
|
||||
".webm",
|
||||
".ogg",
|
||||
];
|
||||
if (!audioExtensions.some((ext) => file.path.endsWith(ext)))
|
||||
return;
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Transcribe audio file 🔊")
|
||||
.setIcon("document")
|
||||
.onClick(async () => {
|
||||
const audioBlob = new Blob([
|
||||
await file.vault.readBinary(file),
|
||||
]);
|
||||
await this.audioHandler.sendAudioData(
|
||||
audioBlob,
|
||||
file.name
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Right-click selected text to fix punctuation
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("editor-menu", (menu, editor) => {
|
||||
const selection = editor.getSelection();
|
||||
if (!selection || selection.trim().length === 0) return;
|
||||
if (!this.settings.proofreadingContextMenu) return;
|
||||
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("Proofread with AI")
|
||||
.setIcon("wand")
|
||||
.onClick(async () => {
|
||||
await this.proofreader.fixSelection(editor);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── URI Handler ──
|
||||
|
||||
registerUriHandler() {
|
||||
this.registerObsidianProtocolHandler("nibbleai", async (params) => {
|
||||
const command = params.command;
|
||||
if (!command) {
|
||||
this.openControls();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "start":
|
||||
await this.startRecording();
|
||||
break;
|
||||
case "stop":
|
||||
await this.stopRecording();
|
||||
break;
|
||||
case "pause":
|
||||
await this.pauseRecording();
|
||||
break;
|
||||
case "cancel":
|
||||
await this.cancelRecording();
|
||||
break;
|
||||
default:
|
||||
new Notice(`✘ Unknown nibbleai command: ${command}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user