Working version of on this day ai

This commit is contained in:
Ben Stuart 2025-02-08 02:21:47 -06:00
parent 8118bf7aaf
commit da25e98f17
9 changed files with 3038 additions and 112 deletions

352
main.ts
View file

@ -1,85 +1,39 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; import { MarkdownView, Notice, Plugin, TFolder } from 'obsidian';
import OpenAI from 'openai';
import { OnThisDayPluginSettings, DEFAULT_SETTINGS } from 'src/OnThisDayPluginSettings';
import OnThisDaySettingTab from 'src/OnThisDaySettingTab';
import aiPrompt from 'src/prompt';
// Remember to rename these classes and interfaces! //
// Main Plugin Class
interface MyPluginSettings { //
mySetting: string; export default class OnThisDayPlugin extends Plugin {
} settings: OnThisDayPluginSettings;
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() { async onload() {
console.log('Loading On This Day AI plugin');
await this.loadSettings(); await this.loadSettings();
this.addSettingTab(new OnThisDaySettingTab(this.app, this));
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({ this.addCommand({
id: 'open-sample-modal-simple', id: 'on-this-day-ai:on-this-day-placeholder',
name: 'Open sample modal (simple)', name: 'Add On This Day AI Placeholder',
callback: () => { callback: async () => {
new SampleModal(this.app).open(); await this.addPlaceholder();
} },
}); });
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({ this.addCommand({
id: 'sample-editor-command', id: 'on-this-day-ai:generate-date-summaries',
name: 'Sample editor command', name: 'Generate Through The Years',
editorCallback: (editor: Editor, view: MarkdownView) => { callback: async () => {
console.log(editor.getSelection()); await this.generateDateSummaries();
editor.replaceSelection('Sample Editor Command'); },
}
}); });
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
} }
onunload() { async onunload() {
console.log('Unloading On This Day AI plugin');
} }
async loadSettings() { async loadSettings() {
@ -89,46 +43,236 @@ export default class MyPlugin extends Plugin {
async saveSettings() { async saveSettings() {
await this.saveData(this.settings); await this.saveData(this.settings);
} }
}
class SampleModal extends Modal { //
constructor(app: App) { // Utility Functions
super(app); //
// Check if a string matches the expected date format ("MMMM D, YYYY")
isValidDateString(dateString: string): boolean {
const regex =
/^(January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}$/;
return regex.test(dateString);
} }
onOpen() { // Parse a date string (assumes "MMMM D, YYYY")
const {contentEl} = this; parseDateFromString(dateString: string, format: string): Date | null {
contentEl.setText('Woah!'); // This regex is based on the expected format "MMMM D, YYYY"
const regex =
/^(January|February|March|April|May|June|July|August|September|October|November|December) (\d{1,2}), (\d{4})$/;
const match = dateString.match(regex);
if (match) {
const monthName = match[1];
const day = parseInt(match[2]);
const year = parseInt(match[3]);
const date = new Date(`${monthName} ${day}, ${year}`);
if (!isNaN(date.getTime())) {
return date;
}
}
return null;
} }
onClose() { //
const {contentEl} = this; // Main Function: Generate Date Summaries
contentEl.empty(); //
} async generateDateSummaries() {
} // 1. Ensure the command is run from a file with a valid date as its title.
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice('No active file found.');
return;
}
if (!this.isValidDateString(activeFile.basename)) {
new Notice(`This command must be run from a file with a date title in the format ${this.settings.dateFormat}`);
return;
}
class SampleSettingTab extends PluginSettingTab { const inputDateString = activeFile.basename;
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) { // 2. Parse the input date to extract month and day (ignoring the year)
super(app, plugin); const parsedDate = this.parseDateFromString(inputDateString, this.settings.dateFormat);
this.plugin = plugin; if (!parsedDate) {
new Notice('Could not parse the date from the file title.');
return;
}
const month = parsedDate.getMonth(); // 0-based month
const day = parsedDate.getDate();
const currentYear = parsedDate.getFullYear();
// 3. Search through the daily notes folder for matching files
const folder = this.app.vault.getAbstractFileByPath(this.settings.dailyNotesFolder);
if (!folder || !(folder instanceof TFolder)) {
new Notice('Daily notes folder not found.');
return;
}
// Get all Markdown files in the daily notes folder
const files = this.app.vault
.getFiles()
.filter(
(file) =>
file.path.startsWith(this.settings.dailyNotesFolder) && file.extension === 'md'
);
// Build a map: year => concatenated content of matching daily notes
let yearToContent: Record<string, string> = {};
for (const file of files) {
const fileDate = this.parseDateFromString(file.basename, this.settings.dateFormat);
if (!fileDate) continue;
if (fileDate.getMonth() === month && fileDate.getDate() === day && fileDate.getFullYear() != currentYear) {
const yearStr = fileDate.getFullYear().toString();
const content = await this.app.vault.read(file);
if (yearToContent[yearStr]) {
yearToContent[yearStr] += '\n' + content;
} else {
yearToContent[yearStr] = content;
}
}
}
if (Object.keys(yearToContent).length === 0) {
new Notice('No matching daily notes found for that date.');
return;
}
// 4. Construct the API prompt that instructs GPT-4 to produce a JSON summary mapping.
const prompt = this.constructPrompt(yearToContent, inputDateString);
// 5. Show a loading indicator (persistent notice) until work is done.
const loadingNotice = new Notice("Generating summaries...", 0);
// 6. Call the OpenAI API once with the entire JSON map.
let responseJSON: Record<string, string>;
try {
responseJSON = await this.callOpenAI(prompt);
} catch (error: any) {
loadingNotice.hide();
console.error('API call failed: ' + error.message);
new Notice('API call failed: ' + error.message);
return;
}
// 7. Build the output markdown block.
const outputBlock = this.buildOutputBlock(responseJSON, this.settings.horizontalRules);
// 8. Insert the output block by looking for a placeholder marker.
// Define a unique placeholder marker.
const placeholder = "<!OTD>";
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView) {
loadingNotice.hide();
new Notice('No active editor found.');
return;
}
const editor = (activeView as MarkdownView).editor;
const currentContent = editor.getValue();
let newContent = "";
if (currentContent.includes(placeholder)) {
// Replace the placeholder with the output block.
newContent = currentContent.replace(placeholder, outputBlock);
editor.setValue(newContent);
} else {
// If no placeholder is found, place block at cursor location
editor.replaceSelection(outputBlock);
}
editor.setValue(newContent);
// 9. Hide the loading notice and show a completion notice.
loadingNotice.hide();
new Notice('On This Day summaries inserted.');
} }
display(): void { //
const {containerEl} = this; // Construct the API Prompt
//
containerEl.empty(); constructPrompt(yearToContent: Record<string, string>, inputDateString: string): string {
const promptInstructions = aiPrompt
new Setting(containerEl) const dataString = JSON.stringify(yearToContent);
.setName('Setting #1') return `${promptInstructions}\n\nData:\n${dataString}`;
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
} }
}
//
// Call the OpenAI API
//
async callOpenAI(prompt: string): Promise<Record<string, string>> {
const client = new OpenAI({
apiKey: this.settings.openaiApiKey,
dangerouslyAllowBrowser: true
});
try {
const response = await client.chat.completions.create({
model: this.settings.model,
messages: [
{
role: 'system',
content:
'You are a helpful assistant that summarizes daily journal entries in a strict JSON format.',
},
{
role: 'user',
content: prompt,
},
],
temperature: 0.2,
});
// Validate the response structure
if (
!response.choices ||
response.choices.length === 0 ||
!response.choices[0].message?.content
) {
throw new Error('No valid response received from OpenAI.');
}
const content = response.choices[0].message.content;
try {
const result = JSON.parse(content);
return result;
} catch (parseError: any) {
console.error('Failed to parse JSON from OpenAI response:', parseError);
throw new Error(
'Failed to parse JSON from OpenAI response: ' + parseError.message
);
}
} catch (error: any) {
console.error('OpenAI API error:', error);
throw new Error('OpenAI API error: ' + error.message);
}
}
//
// Build the Output Markdown
//
buildOutputBlock(summaries: Record<string, string>, horizontalRules: string): string {
// Add horizontal rules based on settings
let output = '## On This Day...\n';
if (horizontalRules.includes('Above')) {
output = '---\n## On This Day...\n'
}
// Optionally, sort the years in descending order
const years = Object.keys(summaries).sort((a, b) => parseInt(b) - parseInt(a));
for (const year of years) {
output += `- **${year}:** ${summaries[year]}\n`;
}
if (horizontalRules.includes('Below')) {
output += "\n---"
}
return output;
}
addPlaceholder() {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView) {
new Notice('No active editor found.');
return;
}
const editor = (activeView as MarkdownView).editor;
editor.replaceSelection('<!OTD>');
}
}

View file

@ -1,11 +1,11 @@
{ {
"id": "sample-plugin", "id": "on-this-day-ai-obsidian-plugin",
"name": "Sample Plugin", "name": "On This Day AI",
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.", "description": "Use AI to remind yourself what has happened on this date in past years.",
"author": "Obsidian", "author": "Ben Stuart",
"authorUrl": "https://obsidian.md", "authorUrl": "https://github.com/benstuart0/on-this-day-ai-obsidian-plugin",
"fundingUrl": "https://obsidian.md/pricing", "fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": false "isDesktopOnly": false
} }

2646
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"name": "obsidian-sample-plugin", "name": "on-this-day-ai-obsidian-plugin",
"version": "1.0.0", "version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)", "description": "This is the first version of the On This Day AI Obsidian plugin",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
@ -9,7 +9,7 @@
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "Ben Stuart",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
@ -20,5 +20,8 @@
"obsidian": "latest", "obsidian": "latest",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4"
},
"dependencies": {
"openai": "^4.83.0"
} }
} }

View file

@ -0,0 +1,18 @@
//
// Settings Interfaces & Defaults
//
export interface OnThisDayPluginSettings {
openaiApiKey: string;
model: string;
dailyNotesFolder: string;
dateFormat: string;
horizontalRules: string;
}
export const DEFAULT_SETTINGS: OnThisDayPluginSettings = {
openaiApiKey: '',
model: 'gpt-4',
dailyNotesFolder: '010 Daily Notes',
dateFormat: 'MMMM D, YYYY',
horizontalRules: ''
};

106
src/OnThisDaySettingTab.ts Normal file
View file

@ -0,0 +1,106 @@
import { App, PluginSettingTab, Setting, TFolder } from 'obsidian';
import moment from 'moment';
import OnThisDayPlugin from 'main';
import { validateInputTools } from 'openai/lib/parser';
//
// Settings Tab
//
export default class OnThisDaySettingTab extends PluginSettingTab {
plugin: OnThisDayPlugin;
constructor(app: App, plugin: OnThisDayPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'On This Day AI Settings' });
// Date Format Setting with dynamic example using the current setting
const dateSetting = new Setting(containerEl)
.setName('Date Format')
.setDesc(`Format for daily note filenames. Example: ${moment(new Date()).format(this.plugin.settings.dateFormat)}`)
.addText((text) => {
text
.setPlaceholder('MMMM D, YYYY')
.setValue(this.plugin.settings.dateFormat)
.onChange(async (value) => {
this.plugin.settings.dateFormat = value;
await this.plugin.saveSettings();
// Update the description with a new example using the updated format
const newExample = moment(new Date()).format(value);
dateSetting.setDesc(`Format for daily note filenames. Example: ${newExample}`);
});
});
// Daily Notes Folder Setting
new Setting(containerEl)
.setName('Daily Notes Folder')
.setDesc('Select the folder where your daily notes are stored.')
.addDropdown((dropdown) => {
// Create an object to hold the folder options
const folderOptions: Record<string, string> = {};
// Get all files from the vault and filter for folders
const allFolders = this.app.vault.getAllLoadedFiles().filter((f) => f instanceof TFolder) as TFolder[];
// Populate the folderOptions with folder paths
allFolders.forEach((folder) => {
folderOptions[folder.path] = folder.path;
});
// Add each folder option to the dropdown
for (const key in folderOptions) {
dropdown.addOption(key, folderOptions[key]);
}
// Set the current value from the plugin settings
dropdown.setValue(this.plugin.settings.dailyNotesFolder);
dropdown.onChange(async (value: string) => {
this.plugin.settings.dailyNotesFolder = value;
await this.plugin.saveSettings();
});
});
// OpenAI API Key Setting
new Setting(containerEl)
.setName('OpenAI API Key')
.setDesc('Your OpenAI API key.')
.addText((text) =>
text
.setPlaceholder('sk-...')
.setValue(this.plugin.settings.openaiApiKey)
.onChange(async (value) => {
this.plugin.settings.openaiApiKey = value;
await this.plugin.saveSettings();
})
);
// Model Version Dropdown Setting
new Setting(containerEl)
.setName('Model Version')
.setDesc('Select the OpenAI model to use.')
.addDropdown((dropdown) => {
dropdown.addOption('gpt-3.5-turbo', 'gpt-3.5-turbo');
dropdown.addOption('gpt-4', 'gpt-4');
dropdown.setValue(this.plugin.settings.model);
dropdown.onChange(async (value: string) => {
this.plugin.settings.model = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('Horizontal Rules')
.setDesc('Would you like horizontal rules above or below the output block?')
.addDropdown((dropdown) => {
dropdown.addOption('', 'None');
dropdown.addOption('Below', 'Below');
dropdown.addOption('Above', 'Above');
dropdown.addOption('Above Below', 'Both');
dropdown.setValue(this.plugin.settings.horizontalRules);
dropdown.onChange(async (value: string) => {
this.plugin.settings.horizontalRules = value;
await this.plugin.saveSettings();
})
})
}
}

5
src/aiPrompt.txt Normal file
View file

@ -0,0 +1,5 @@
const aiPrompt = "Below is a JSON object where each key is a year and each value is the full content of my daily journal for this day on that given year.
For each year, please provide an interesting summary in 2-3 sentences that highlights the events of that day. Try to include who I spent time with (including their names), what I did, and how I felt.
Use second-person language (you), as you are referring to me - the writer of the journals. Do not include the date in the summary. Return only a JSON object mapping each year to its summary with no extra text, headers, or footers."

3
src/prompt.ts Normal file
View file

@ -0,0 +1,3 @@
const aiPrompt = "Below is a JSON object where each key is a year and each value is the full content of my daily journal for this day on that given year. For each year, please provide an interesting summary in 2-3 sentences that first highlights the most interesting events of the day and then describes how I felt, using second-person language (you), as you are referring to me - the writer of the journals. Do not include the date in the summary. Return only a JSON object mapping each year to its summary with no extra text, headers, or footers."
export default aiPrompt

View file

@ -11,6 +11,7 @@
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"strictNullChecks": true, "strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"lib": [ "lib": [
"DOM", "DOM",
"ES5", "ES5",