justin-arnold_Journalyst/main.ts
2026-04-25 20:23:51 -04:00

1542 lines
56 KiB
TypeScript

import { Notice, Plugin, TAbstractFile, TFile, TFolder, normalizePath, WorkspaceLeaf, moment, parseYaml } from 'obsidian';
import {
BasesGenerationTarget,
buildEntryBaseContents,
buildJournalystNoteProperties,
buildReflectionBaseContents,
getFrontmatterPropertyDiff,
getJournalystBaseFileName,
JournalNotePropertyBackfillItem,
JournalystEntryType,
parseJournalystNoteKind,
upsertFrontmatterProperties,
} from "./bases";
import { JournalCadenceConfig, normalizeJournalCadence } from "./cadence";
import { BUILT_IN_PROMPT_LISTS } from "./promptLibrary";
import {
createCustomPromptListDefinition,
createPromptBlock,
getPromptTemplateContext,
JournalPromptHistory,
JournalPromptSettings,
normalizeJournalPromptSettings,
PromptListDefinition,
ResolvedPrompt,
resolvePromptForJournal,
resolvePromptList,
} from "./prompts";
import {
buildReminderHistoryKey,
buildSynthesisReminderFileName,
getReminderEventsForJournal,
hasAnyEnabledReminder,
JournalReminderSettings,
normalizeJournalReminderSettings,
ReminderOccurrenceRecord,
ResolvedReminderEvent,
ReviewReminderPeriod,
} from "./reminders";
import { buildSynthesisNotePreview } from "./review/buildSnapshot";
import { ReviewWorkspaceTab, SidebarMode, SynthesisPeriodType } from "./review/types";
import { createTemplateStrategies } from "./templates/strategies";
import {
buildJournalMigrationPlan,
findJournalEntryFile,
formatJournalNoteBaseName,
formatJournalNoteFileName,
JournalNoteMigrationItem,
parseJournalDateFromFile,
} from "./journalNaming";
import {
CoreTemplatesSettings,
JournalTemplateEngineStrategy,
ObsidianInternalPlugins,
ObsidianPlugins,
TemplateAvailability,
TemplateEngine,
TemplateFailureBehavior,
TemplaterPlugin,
TemplatePromptContext,
} from "./templates/types";
import { ReviewView, VIEW_TYPE_REVIEW } from "./views/Review";
import { SideBarView, VIEW_TYPE_SIDE_BAR } from "./views/SideBar";
import { JournalystSettingsTab } from "./views/Settings";
export interface JournalystPluginSettings {
rootDirectory: string;
basesIntegrationEnabled: boolean;
remindersEnabled: boolean;
osNotificationsEnabled: boolean;
sidebarMode: SidebarMode;
noteDateFormat: string;
noteDateFormatHistory: string[];
journalCadences: Record<string, JournalCadenceConfig>;
customPromptLists: Record<string, PromptListDefinition>;
journalPromptSettings: Record<string, JournalPromptSettings>;
journalPromptHistory: Record<string, JournalPromptHistory>;
journalReminderSettings: Record<string, JournalReminderSettings>;
reminderHistory: Record<string, ReminderOccurrenceRecord>;
templateEngine: TemplateEngine;
templateFailureBehavior: TemplateFailureBehavior;
templaterJournalTemplates: Record<string, string>;
coreJournalTemplates: Record<string, string>;
journalTemplates?: Record<string, string>;
}
const DEFAULT_SETTINGS: JournalystPluginSettings = {
rootDirectory: '/',
basesIntegrationEnabled: false,
remindersEnabled: false,
osNotificationsEnabled: false,
sidebarMode: 'journals-mini',
noteDateFormat: 'YYYY-MM-DD',
noteDateFormatHistory: [],
journalCadences: {},
customPromptLists: {},
journalPromptSettings: {},
journalPromptHistory: {},
journalReminderSettings: {},
reminderHistory: {},
templateEngine: 'templater',
templateFailureBehavior: 'fallback-default',
templaterJournalTemplates: {},
coreJournalTemplates: {},
}
export default class JournalystPlugin extends Plugin {
settings: JournalystPluginSettings;
journals: TFolder[] = [];
private journalCommandIds: string[] = [];
// Strategy instances keep engine-specific behavior out of the main plugin flow.
private templateStrategies: Partial<Record<Exclude<TemplateEngine, 'none'>, JournalTemplateEngineStrategy>>;
private lastReminderCheckMinute: string | null = null;
private reviewState: { journalPath: string | null; anchorDate: string; activeTab: ReviewWorkspaceTab } = {
journalPath: null,
anchorDate: moment().format('YYYY-MM-DD'),
activeTab: 'home',
};
async onload() {
await this.loadSettings();
this.initializeTemplateStrategies();
this.addSettingTab(new JournalystSettingsTab(this.app, this));
this.addRibbonIcon('notebook-pen', 'Go to Journalyst view', () => {
this.activateView();
});
this.addRibbonIcon('notebook', 'Open Journalyst sidebar', () => {
this.activateSidebarView();
});
this.app.workspace.onLayoutReady(() => {
this.refreshJournals();
this.registerView(
VIEW_TYPE_SIDE_BAR,
(leaf) => new SideBarView(leaf, this)
);
this.registerView(
VIEW_TYPE_REVIEW,
(leaf) => new ReviewView(leaf, this)
);
void this.checkReminderNotifications();
})
if (typeof window !== 'undefined') {
this.registerInterval(window.setInterval(() => {
void this.checkReminderNotifications();
}, 60_000));
}
this.addCommand({
id: 'open-journalyst-review',
name: 'Open Journalyst review',
callback: () => {
this.activateReviewView(undefined, undefined, 'review');
}
});
this.addCommand({
id: 'open-journalyst-sidebar',
name: 'Open Journalyst sidebar',
callback: () => {
this.activateSidebarView();
}
});
this.addCommand({
id: 'open-journalyst-analytics',
name: 'Open Journalyst analytics',
callback: () => {
this.activateReviewView(undefined, undefined, 'analytics');
}
});
this.addCommand({
id: 'open-journalyst-synthesis',
name: 'Open Journalyst synthesis',
callback: () => {
this.activateReviewView(undefined, undefined, 'synthesis');
}
});
this.addCommand({
id: 'open-journalyst-reminders',
name: 'Open Journalyst reminders',
callback: () => {
this.activateReviewView(undefined, undefined, 'reminders');
}
});
this.addCommand({
id: 'open-journalyst-review-current-journal',
name: 'Open review for current journal',
callback: () => {
const journal = this.inferCurrentJournal();
if (!journal) {
new Notice('Journalyst could not infer a journal from the current note.');
return;
}
this.activateReviewView(journal.path);
}
});
this.addCommand({
id: 'generate-bases-for-current-journal',
name: 'Generate Bases for current journal',
callback: async () => {
const journal = this.inferCurrentJournal() ?? this.getJournalByPath(this.getDefaultReviewJournalPath() ?? '');
if (!journal) {
new Notice('Journalyst could not find a journal to generate Bases for.');
return;
}
await this.generateBasesForJournal(journal);
}
});
this.addCommand({
id: 'generate-bases-for-all-journals',
name: 'Generate Bases for all journals',
callback: async () => {
await this.generateBasesForAllJournals();
}
});
this.registerEvent(
this.app.vault.on('create', (item) => this.onItemChange())
);
this.registerEvent(
this.app.vault.on('delete', (item) => this.onItemChange())
);
this.registerEvent(
this.app.vault.on('rename', (item, oldPath) => {
void this.onItemRename(item, oldPath);
})
);
};
onunload() {}
private onItemChange() {
this.refreshJournals();
}
private async onItemRename(item: TAbstractFile, oldPath: string) {
if (item instanceof TFolder) {
this.remapJournalPaths(oldPath, item.path);
await this.saveSettings();
}
this.refreshJournals();
}
refreshJournals() {
const rootFolder = this.app.vault.getAbstractFileByPath(this.settings.rootDirectory);
// Journal commands are derived from folders under the configured root, so
// rebuild them whenever the root changes or the vault structure changes.
this.journalCommandIds.forEach(commandId => this.removeCommand(commandId));
this.journalCommandIds = [];
this.journals = [];
if (!(rootFolder instanceof TFolder)) {
return;
}
rootFolder.children.forEach((child, index) => {
if (!(child instanceof TFolder)) {
return;
}
this.journals.push(child);
this.addJournalCommand(child, index);
})
if (!this.reviewState.journalPath || !this.getJournalByPath(this.reviewState.journalPath)) {
this.reviewState.journalPath = this.getDefaultReviewJournalPath();
}
}
private addJournalCommand(journal: TFolder, index: number) {
const commandId = 'create-journal-' + index + '-' + journal.path.replace(/[^a-zA-Z0-9-]/g, '-');
this.journalCommandIds.push(commandId);
this.addCommand({
id: commandId,
name: 'Create new journal in ' + journal.name,
callback: () => {
this.createJournalEntry(journal);
}
})
}
async createJournalEntry(journalFolder: TFolder, date = moment().format('YYYY-MM-DD')) {
const newFileName = this.formatJournalNoteFileName(date);
const fullPath = normalizePath(journalFolder.path + '/' + newFileName);
const existingFile = this.findJournalEntryFile(journalFolder, date);
if (existingFile instanceof TFile) {
await this.app.workspace.openLinkText(existingFile.path, '/', false);
return existingFile;
}
const { resolvedPrompt, error: promptError } = await this.resolvePromptForJournal(journalFolder.path, date);
if (promptError) {
new Notice(promptError);
}
const promptTemplateMode = resolvedPrompt?.deliveryMode === 'template-variables';
const supportsPromptVariables = this.doesTemplateEngineSupportPromptContext(this.settings.templateEngine);
const promptContext = promptTemplateMode && supportsPromptVariables
? getPromptTemplateContext(resolvedPrompt)
: null;
const promptBlock = resolvedPrompt && (!promptTemplateMode || !supportsPromptVariables)
? createPromptBlock(resolvedPrompt)
: '';
if (resolvedPrompt && promptTemplateMode && !supportsPromptVariables) {
new Notice('This template engine cannot receive prompt variables directly, so Journalyst appended the prompt to the note body instead.');
}
let file: TFile | null = null;
const templatePath = this.getJournalTemplatePath(this.settings.templateEngine, journalFolder.path);
if (templatePath) {
// Each engine owns its own application logic; the plugin only selects
// the active engine and handles the fallback to default note content.
const fileFromTemplate = await this.createJournalEntryFromTemplate(this.settings.templateEngine, journalFolder, templatePath, date, promptContext);
if (fileFromTemplate) {
file = fileFromTemplate;
} else if (this.settings.templateFailureBehavior === 'abort') {
new Notice('Journalyst did not create a note because the configured template could not be applied.');
return null;
}
}
if (!file) {
const baseContents = this.getDefaultJournalEntryContents(date);
const nextContents = promptBlock ? this.appendPromptBlock(baseContents, promptBlock) : baseContents;
file = await this.app.vault.create(fullPath, nextContents);
} else if (promptBlock) {
await this.appendPromptBlockToFile(file, promptBlock);
}
await this.applyJournalystEntryProperties(file, {
cadence: this.getJournalCadence(journalFolder.path),
date,
entryType: 'entry',
journalName: journalFolder.name,
journalPath: journalFolder.path,
promptSettings: this.getJournalPromptSettings(journalFolder.path),
promptTitle: resolvedPrompt?.prompt.title ?? null,
});
if (resolvedPrompt?.history) {
this.settings.journalPromptHistory[journalFolder.path] = resolvedPrompt.history;
await this.saveSettings();
}
await this.app.workspace.openLinkText(file.path, '/', false);
return file;
}
private async createJournalEntryFromTemplate(
templateEngine: TemplateEngine,
journalFolder: TFolder,
templatePath: string,
date: string,
promptContext?: TemplatePromptContext | null,
) {
const templateStrategy = this.getTemplateStrategy(templateEngine);
if (!templateStrategy) {
return null;
}
return templateStrategy.createJournalEntry(journalFolder, templatePath, date, promptContext);
}
getJournalTemplatePath(templateEngine: TemplateEngine, journalPath: string) {
if (templateEngine === 'templater') {
return this.settings.templaterJournalTemplates[journalPath];
}
if (templateEngine === 'core') {
return this.settings.coreJournalTemplates[journalPath];
}
return undefined;
}
async getJournalTemplateStatus(templateEngine: TemplateEngine, journalPath: string) {
const templatePath = this.getJournalTemplatePath(templateEngine, journalPath);
if (!templatePath) {
return 'none';
}
const templateFile = this.app.vault.getAbstractFileByPath(templatePath);
if (!(templateFile instanceof TFile)) {
return 'missing';
}
const templateFolder = await this.getConfiguredTemplateFolder(templateEngine);
if (!templateFolder) {
return 'valid';
}
const normalizedFolder = normalizePath(templateFolder);
const normalizedPath = normalizePath(templateFile.path);
const folderPrefix = normalizedFolder === '/' ? '' : normalizedFolder + '/';
return normalizedPath.startsWith(folderPrefix) ? 'valid' : 'outside-folder';
}
setJournalTemplatePath(templateEngine: TemplateEngine, journalPath: string, templatePath: string) {
const templateMap = this.getJournalTemplateMap(templateEngine);
if (templateMap && templatePath) {
templateMap[journalPath] = templatePath;
}
}
clearJournalTemplatePath(templateEngine: TemplateEngine, journalPath: string) {
const templateMap = this.getJournalTemplateMap(templateEngine);
if (templateMap) {
delete templateMap[journalPath];
}
}
getTemplaterAvailability(): TemplateAvailability | Promise<TemplateAvailability> {
const templaterStrategy = this.getTemplateStrategy('templater');
return templaterStrategy ? templaterStrategy.getAvailability() : 'disabled';
}
getTemplaterTemplateFolder() {
const templaterStrategy = this.getTemplateStrategy('templater');
return templaterStrategy ? templaterStrategy.getTemplateFolder() : null;
}
getTemplaterTemplateFiles() {
const templaterStrategy = this.getTemplateStrategy('templater');
return templaterStrategy ? templaterStrategy.getTemplateFiles() : [];
}
async getCoreTemplatesAvailability(): Promise<TemplateAvailability> {
const coreStrategy = this.getTemplateStrategy('core');
return coreStrategy ? coreStrategy.getAvailability() : 'disabled';
}
async getCoreTemplateFolder() {
const coreStrategy = this.getTemplateStrategy('core');
return coreStrategy ? coreStrategy.getTemplateFolder() : null;
}
async getCoreTemplateFiles() {
const coreStrategy = this.getTemplateStrategy('core');
return coreStrategy ? coreStrategy.getTemplateFiles() : [];
}
async getTemplateAvailability(templateEngine: TemplateEngine): Promise<TemplateAvailability> {
const templateStrategy = this.getTemplateStrategy(templateEngine);
return templateStrategy ? templateStrategy.getAvailability() : 'ready';
}
async getTemplateFiles(templateEngine: TemplateEngine) {
const templateStrategy = this.getTemplateStrategy(templateEngine);
return templateStrategy ? templateStrategy.getTemplateFiles() : [];
}
async getTemplateFolder(templateEngine: TemplateEngine) {
const templateStrategy = this.getTemplateStrategy(templateEngine);
return templateStrategy ? templateStrategy.getTemplateFolder() : null;
}
async getConfiguredTemplateFolder(templateEngine: TemplateEngine) {
if (templateEngine === 'templater') {
return this.getRawTemplaterTemplateFolder();
}
if (templateEngine === 'core') {
return this.getRawCoreTemplateFolder();
}
return null;
}
formatJournalNoteBaseName(date: string) {
return formatJournalNoteBaseName(date, this.settings);
}
formatJournalNoteFileName(date: string) {
return formatJournalNoteFileName(date, this.settings);
}
parseJournalDateFromFile(file: TAbstractFile) {
return parseJournalDateFromFile(file, this.settings);
}
findJournalEntryFile(journal: TFolder, date: string) {
return findJournalEntryFile(journal, date, this.settings);
}
getJournalMigrationPlan() {
return buildJournalMigrationPlan(this.journals, this.settings);
}
getJournalCadence(journalPath: string) {
return normalizeJournalCadence(this.settings.journalCadences[journalPath]);
}
async updateJournalCadence(journalPath: string, cadence: JournalCadenceConfig) {
this.settings.journalCadences[journalPath] = normalizeJournalCadence(cadence);
await this.saveSettings();
}
getBuiltInPromptLists() {
return BUILT_IN_PROMPT_LISTS;
}
getCustomPromptLists() {
return this.settings.customPromptLists;
}
getJournalPromptSettings(journalPath: string) {
return normalizeJournalPromptSettings(this.settings.journalPromptSettings[journalPath]);
}
async updateJournalPromptSettings(journalPath: string, promptSettings: JournalPromptSettings) {
const normalizedSettings = normalizeJournalPromptSettings(promptSettings);
const previousSettings = this.getJournalPromptSettings(journalPath);
const previousSourceKey = this.getPromptSourceKey(previousSettings);
const nextSourceKey = this.getPromptSourceKey(normalizedSettings);
this.settings.journalPromptSettings[journalPath] = normalizedSettings;
if (previousSourceKey !== nextSourceKey || previousSettings.selectionMode !== normalizedSettings.selectionMode) {
delete this.settings.journalPromptHistory[journalPath];
}
await this.saveSettings();
}
async upsertCustomPromptList(listId: string, name: string, rawPromptText: string) {
this.settings.customPromptLists[listId] = createCustomPromptListDefinition(listId, name, rawPromptText);
await this.saveSettings();
}
async deleteCustomPromptList(listId: string) {
delete this.settings.customPromptLists[listId];
Object.entries(this.settings.journalPromptSettings).forEach(([journalPath, promptSettings]) => {
if (promptSettings.sourceType === 'custom' && promptSettings.selectedListId === listId) {
this.settings.journalPromptSettings[journalPath] = normalizeJournalPromptSettings({
...promptSettings,
enabled: false,
selectedListId: undefined,
staticPromptId: undefined,
weekdayOverrides: {},
});
delete this.settings.journalPromptHistory[journalPath];
}
});
await this.saveSettings();
}
createCustomPromptListId() {
return `custom-${Date.now().toString(36)}`;
}
async getResolvedPromptList(journalPath: string) {
const promptSettings = this.getJournalPromptSettings(journalPath);
return resolvePromptList(promptSettings, this.getPromptResolveContext());
}
async getPromptPreview(journalPath: string, limit = 3) {
const resolvedList = await this.getResolvedPromptList(journalPath);
return resolvedList?.prompts.slice(0, limit) ?? [];
}
async getPromptSettingsStatus(journalPath: string) {
const promptSettings = this.getJournalPromptSettings(journalPath);
if (!promptSettings.enabled) {
return null;
}
const resolvedList = await this.getResolvedPromptList(journalPath);
if (!resolvedList || resolvedList.prompts.length === 0) {
return 'No prompts are available from the selected source.';
}
if (promptSettings.deliveryMode === 'template-variables' && this.settings.templateEngine === 'templater') {
return 'Templater prompt variables are exposed as tp.frontmatter.journalyst_prompt and tp.frontmatter.journalyst_prompt_title.';
}
if (promptSettings.deliveryMode === 'template-variables' && !this.doesTemplateEngineSupportPromptContext(this.settings.templateEngine)) {
return 'This template engine cannot receive prompt variables directly. Journalyst will append the prompt to the note body instead.';
}
return null;
}
getJournalMigrationPlanForFormat(noteDateFormat: string) {
return buildJournalMigrationPlan(this.journals, {
...this.settings,
noteDateFormat,
});
}
formatJournalNoteFileNameForFormat(date: string, noteDateFormat: string) {
return formatJournalNoteFileName(date, {
...this.settings,
noteDateFormat,
});
}
async applyJournalMigrationPlan(migrationItems: JournalNoteMigrationItem[]) {
const safeItems = migrationItems.filter(item => !item.hasConflict);
let renamedCount = 0;
for (const item of safeItems) {
const file = this.app.vault.getAbstractFileByPath(item.currentPath);
if (!(file instanceof TFile)) {
continue;
}
await this.app.fileManager.renameFile(file, item.targetPath);
renamedCount += 1;
}
if (renamedCount > 0) {
new Notice(`Journalyst renamed ${renamedCount} journal note${renamedCount === 1 ? '' : 's'}.`);
}
this.refreshJournals();
}
async updateNoteDateFormat(noteDateFormat: string) {
const nextFormat = noteDateFormat.trim();
if (!nextFormat || nextFormat === this.settings.noteDateFormat) {
return;
}
this.settings.noteDateFormatHistory = Array.from(new Set([
this.settings.noteDateFormat,
...this.settings.noteDateFormatHistory,
].filter(Boolean)));
this.settings.noteDateFormat = nextFormat;
await this.saveSettings();
}
getReviewState() {
return this.reviewState;
}
isBasesIntegrationEnabled() {
return this.settings.basesIntegrationEnabled;
}
async updateBasesIntegrationEnabled(enabled: boolean) {
this.settings.basesIntegrationEnabled = enabled;
await this.saveSettings();
}
areRemindersEnabled() {
return this.settings.remindersEnabled;
}
async updateRemindersEnabled(enabled: boolean) {
this.settings.remindersEnabled = enabled;
await this.saveSettings();
if (enabled) {
await this.checkReminderNotifications(true);
}
}
areOsNotificationsEnabled() {
return this.settings.osNotificationsEnabled;
}
async updateOsNotificationsEnabled(enabled: boolean) {
this.settings.osNotificationsEnabled = enabled;
await this.saveSettings();
}
getSidebarMode() {
return this.settings.sidebarMode;
}
async updateSidebarMode(sidebarMode: SidebarMode) {
this.settings.sidebarMode = sidebarMode;
await this.saveSettings();
this.refreshSidebarView();
}
getNotificationPermissionStatus() {
if (typeof Notification === 'undefined') {
return 'unsupported';
}
return Notification.permission;
}
async requestNotificationPermission() {
if (typeof Notification === 'undefined') {
return 'unsupported';
}
return Notification.requestPermission();
}
async sendTestReminderNotification() {
await this.deliverReminderEvent({
historyKey: buildReminderHistoryKey('journalyst', 'test', moment().format()),
journalPath: '',
journalName: 'Journalyst',
deliveryMode: this.settings.osNotificationsEnabled ? 'os-preferred' : 'in-app',
title: 'Journalyst test reminder',
message: 'Notifications are working and ready for your journals.',
target: {
type: 'review',
anchorDate: moment().format('YYYY-MM-DD'),
reviewPeriod: 'weekly',
},
}, false);
}
getJournalReminderSettings(journalPath: string) {
return normalizeJournalReminderSettings(this.settings.journalReminderSettings[journalPath]);
}
async updateJournalReminderSettings(journalPath: string, reminderSettings: JournalReminderSettings) {
this.settings.journalReminderSettings[journalPath] = normalizeJournalReminderSettings(reminderSettings);
this.clearReminderHistoryForJournal(journalPath);
await this.saveSettings();
await this.checkReminderNotifications(true);
}
getJournalReminderSummary(journalPath: string) {
if (!this.settings.remindersEnabled) {
return 'Reminders off';
}
const cadence = this.getJournalCadence(journalPath);
const reminderSettings = this.getJournalReminderSettings(journalPath);
return hasAnyEnabledReminder(reminderSettings, cadence) ? 'Reminders active' : 'Reminders off';
}
async setReviewState(journalPath: string | null, anchorDate: string, activeTab?: ReviewWorkspaceTab) {
this.reviewState = {
journalPath,
anchorDate,
activeTab: activeTab ?? this.reviewState.activeTab,
};
}
getDefaultReviewJournalPath() {
return this.journals[0]?.path ?? null;
}
getJournalByPath(journalPath: string) {
return this.journals.find(journal => journal.path === journalPath) ?? null;
}
inferCurrentJournal() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
return null;
}
return this.journals.find(journal => activeFile.path.startsWith(journal.path + '/')) ?? null;
}
async checkReminderNotifications(force = false) {
if (!this.settings.remindersEnabled) {
return;
}
const now = moment();
const currentMinute = now.format('YYYY-MM-DD HH:mm');
if (!force && this.lastReminderCheckMinute === currentMinute) {
return;
}
this.lastReminderCheckMinute = currentMinute;
for (const journal of this.journals) {
const events = this.getReminderEventsForJournal(journal, now);
for (const event of events) {
await this.deliverReminderEvent(event);
}
}
}
async generateBasesForJournal(journal: TFolder | string) {
if (!this.settings.basesIntegrationEnabled) {
new Notice('Enable Bases integration in Journalyst settings first.');
return;
}
const resolvedJournal = typeof journal === 'string' ? this.getJournalByPath(journal) : journal;
if (!resolvedJournal) {
new Notice('Journalyst could not find that journal for Bases generation.');
return;
}
await this.upsertManagedBaseFile(resolvedJournal, 'entries');
await this.upsertManagedBaseFile(resolvedJournal, 'reflections');
new Notice(`Journalyst generated Bases for ${resolvedJournal.name}.`);
}
async generateBasesForAllJournals() {
if (!this.settings.basesIntegrationEnabled) {
new Notice('Enable Bases integration in Journalyst settings first.');
return;
}
for (const journal of this.journals) {
await this.upsertManagedBaseFile(journal, 'entries');
await this.upsertManagedBaseFile(journal, 'reflections');
}
new Notice(`Journalyst generated Bases for ${this.journals.length} journal${this.journals.length === 1 ? '' : 's'}.`);
}
async getJournalNotePropertyBackfillPreview(journalPath?: string | null) {
const journals = journalPath ? this.journals.filter(journal => journal.path === journalPath) : this.journals;
const items: JournalNotePropertyBackfillItem[] = [];
for (const journal of journals) {
const journalItems = await this.getJournalBackfillItems(journal);
items.push(...journalItems);
}
return items;
}
async applyJournalNotePropertyBackfill(items: JournalNotePropertyBackfillItem[]) {
let updatedCount = 0;
for (const item of items) {
const file = this.app.vault.getAbstractFileByPath(item.filePath);
if (!(file instanceof TFile)) {
continue;
}
await this.applyJournalystPropertiesToFile(file, item.properties);
updatedCount += 1;
}
if (updatedCount > 0) {
new Notice(`Journalyst backfilled properties for ${updatedCount} note${updatedCount === 1 ? '' : 's'}.`);
}
}
async createSynthesisNote(journalPath: string, anchorDate: string, periodType: SynthesisPeriodType) {
const journal = this.getJournalByPath(journalPath);
if (!journal) {
new Notice('Journalyst could not find that journal for synthesis.');
return null;
}
const preview = buildSynthesisNotePreview(journal, anchorDate, this.settings, periodType);
const filePath = normalizePath(journal.path + '/' + preview.payload.fileName);
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (existingFile instanceof TFile) {
await this.app.workspace.openLinkText(existingFile.path, '/', false);
return existingFile;
}
const file = await this.app.vault.create(filePath, preview.payload.body);
await this.applyJournalystEntryProperties(file, {
cadence: this.getJournalCadence(journal.path),
entryType: this.getSynthesisEntryType(periodType),
journalName: journal.name,
journalPath: journal.path,
periodEnd: preview.payload.endDate,
periodStart: preview.payload.startDate,
periodType,
});
await this.app.workspace.openLinkText(file.path, '/', false);
return file;
}
private getReminderEventsForJournal(journal: TFolder, now: moment.Moment) {
const entryDates = journal.children
.map(file => this.parseJournalDateFromFile(file))
.filter((date): date is string => !!date);
return getReminderEventsForJournal({
cadence: this.getJournalCadence(journal.path),
dateSet: new Set(entryDates),
journalName: journal.name,
journalPath: journal.path,
reminderSettings: this.getJournalReminderSettings(journal.path),
synthesisNoteExists: (period, anchorDate) => this.doesSynthesisNoteExist(journal, period, anchorDate),
}, now, this.settings.reminderHistory);
}
private doesSynthesisNoteExist(journal: TFolder, period: ReviewReminderPeriod, anchorDate: string) {
const fileName = buildSynthesisReminderFileName(period, anchorDate);
return this.app.vault.getAbstractFileByPath(normalizePath(`${journal.path}/${fileName}`)) instanceof TFile;
}
private async deliverReminderEvent(event: ResolvedReminderEvent, persist = true) {
new Notice(`${event.title}: ${event.message}`);
if (event.deliveryMode === 'os-preferred' && this.settings.osNotificationsEnabled) {
this.sendOsNotification(event);
}
if (persist) {
this.settings.reminderHistory[event.historyKey] = {
journalPath: event.journalPath,
ruleKey: event.historyKey.split('::')[1] ?? '',
occurrenceKey: event.historyKey.split('::')[2] ?? '',
sentAt: moment().toISOString(),
};
await this.saveSettings();
}
}
private sendOsNotification(event: ResolvedReminderEvent) {
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') {
return;
}
const notification = new Notification(event.title, {
body: event.message,
});
notification.onclick = () => {
void this.openReminderTarget(event);
};
}
private async openReminderTarget(event: ResolvedReminderEvent) {
if (event.target.type === 'entry') {
const journal = this.getJournalByPath(event.journalPath);
if (journal) {
await this.createJournalEntry(journal, event.target.anchorDate);
}
return;
}
await this.activateReviewView(event.journalPath, event.target.anchorDate, 'synthesis');
}
private getTemplaterPlugin(): TemplaterPlugin | undefined {
const plugins = this.getObsidianPlugins();
return plugins?.plugins?.['templater-obsidian'] as TemplaterPlugin | undefined;
}
private getObsidianPlugins(): ObsidianPlugins | undefined {
const appWithPlugins = this.app as typeof this.app & {
plugins?: ObsidianPlugins;
};
return appWithPlugins.plugins;
}
private getObsidianInternalPlugins(): ObsidianInternalPlugins | undefined {
const appWithInternalPlugins = this.app as typeof this.app & {
internalPlugins?: ObsidianInternalPlugins;
};
return appWithInternalPlugins.internalPlugins;
}
private getDefaultJournalEntryContents(date: string) {
return '---\ntitle: ' + date + '\n---\n';
}
private getSynthesisEntryType(periodType: SynthesisPeriodType): JournalystEntryType {
if (periodType === 'weekly') {
return 'weekly-review';
}
if (periodType === 'monthly') {
return 'monthly-reflection';
}
return 'quarter-summary';
}
private async applyJournalystEntryProperties(file: TFile, context: Parameters<typeof buildJournalystNoteProperties>[0]) {
if (!this.settings.basesIntegrationEnabled) {
return;
}
const properties = buildJournalystNoteProperties(context);
await this.applyJournalystPropertiesToFile(file, properties);
}
private async applyJournalystPropertiesToFile(file: TFile, properties: ReturnType<typeof buildJournalystNoteProperties>) {
const contents = await this.app.vault.read(file);
const nextContents = upsertFrontmatterProperties(contents, properties);
if (nextContents !== contents) {
await this.app.vault.modify(file, nextContents);
}
}
private appendPromptBlock(baseContents: string, promptBlock: string) {
return baseContents.trimEnd() + '\n\n' + promptBlock.trim() + '\n';
}
private async appendPromptBlockToFile(file: TFile, promptBlock: string) {
const contents = await this.app.vault.read(file);
await this.app.vault.modify(file, this.appendPromptBlock(contents, promptBlock));
}
private initializeTemplateStrategies() {
this.templateStrategies = createTemplateStrategies({
getCoreTemplatesSettings: () => this.getCoreTemplatesSettings(),
getMarkdownFilesInFolder: (templateFolder) => this.getMarkdownFilesInFolder(templateFolder),
getObsidianPlugins: () => this.getObsidianPlugins(),
getRawCoreTemplateFolder: () => this.getRawCoreTemplateFolder(),
getRawTemplaterTemplateFolder: () => this.getRawTemplaterTemplateFolder(),
getTemplaterPlugin: () => this.getTemplaterPlugin(),
isCoreTemplatesPluginEnabled: () => this.isCoreTemplatesPluginEnabled(),
readTemplateFile: (templateFile) => this.app.vault.read(templateFile),
renderCoreTemplate: (templateContents, date, promptContext) => this.renderCoreTemplate(templateContents, date, promptContext),
serializePromptFrontmatter: (promptContext) => this.serializePromptFrontmatter(promptContext),
formatJournalNoteBaseName: (date) => this.formatJournalNoteBaseName(date),
formatJournalNoteFileName: (date) => this.formatJournalNoteFileName(date),
vaultCreate: (path, contents) => this.app.vault.create(path, contents),
vaultGetAbstractFileByPath: (path) => this.app.vault.getAbstractFileByPath(path),
});
}
private getTemplateStrategy(templateEngine: TemplateEngine) {
if (templateEngine === 'none') {
return null;
}
return this.templateStrategies?.[templateEngine] ?? null;
}
private getJournalTemplateMap(templateEngine: TemplateEngine) {
if (templateEngine === 'templater') {
return this.settings.templaterJournalTemplates;
}
if (templateEngine === 'core') {
return this.settings.coreJournalTemplates;
}
return null;
}
private remapJournalPaths(oldPath: string, newPath: string) {
if (this.settings.rootDirectory === oldPath) {
this.settings.rootDirectory = newPath;
}
this.settings.templaterJournalTemplates = this.remapTemplateMapPaths(this.settings.templaterJournalTemplates, oldPath, newPath);
this.settings.coreJournalTemplates = this.remapTemplateMapPaths(this.settings.coreJournalTemplates, oldPath, newPath);
this.settings.journalCadences = this.remapCadenceMapPaths(this.settings.journalCadences, oldPath, newPath);
this.settings.journalPromptSettings = this.remapPromptSettingsPaths(this.settings.journalPromptSettings, oldPath, newPath);
this.settings.journalPromptHistory = this.remapPromptHistoryPaths(this.settings.journalPromptHistory, oldPath, newPath);
this.settings.journalReminderSettings = this.remapReminderSettingsPaths(this.settings.journalReminderSettings, oldPath, newPath);
this.settings.reminderHistory = this.remapReminderHistoryPaths(this.settings.reminderHistory, oldPath, newPath);
}
private async getJournalBackfillItems(journal: TFolder) {
const items: JournalNotePropertyBackfillItem[] = [];
for (const child of journal.children) {
if (!(child instanceof TFile) || child.extension !== 'md') {
continue;
}
const parsedEntryDate = this.parseJournalDateFromFile(child);
const noteKind = parsedEntryDate
? { date: parsedEntryDate, entryType: 'entry' as JournalystEntryType }
: parseJournalystNoteKind(child);
if (!noteKind) {
continue;
}
const desiredProperties = buildJournalystNoteProperties({
cadence: this.getJournalCadence(journal.path),
date: noteKind.date,
entryType: noteKind.entryType,
journalName: journal.name,
journalPath: journal.path,
periodEnd: noteKind.periodEnd,
periodStart: noteKind.periodStart,
periodType: noteKind.periodType,
});
const currentFrontmatter = await this.getFrontmatterFromFile(child);
const diff = getFrontmatterPropertyDiff(currentFrontmatter, desiredProperties);
if (diff.missingKeys.length === 0 && diff.changedKeys.length === 0) {
continue;
}
items.push({
filePath: child.path,
journalPath: journal.path,
journalName: journal.name,
entryType: noteKind.entryType,
missingKeys: diff.missingKeys,
changedKeys: diff.changedKeys,
properties: desiredProperties,
});
}
return items;
}
private async getFrontmatterFromFile(file: TFile) {
const contents = await this.app.vault.read(file);
const frontmatterMatch = contents.match(/^---\n([\s\S]*?)\n---\n?/);
if (!frontmatterMatch) {
return {};
}
try {
const parsed = parseYaml(frontmatterMatch[1]);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
} catch (_error) {
return {};
}
}
private async upsertManagedBaseFile(journal: TFolder, target: BasesGenerationTarget) {
const fileName = getJournalystBaseFileName(target);
const filePath = normalizePath(`${journal.path}/${fileName}`);
const contents = target === 'entries'
? buildEntryBaseContents(journal)
: buildReflectionBaseContents(journal);
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (existingFile instanceof TFile) {
await this.app.vault.modify(existingFile, contents);
return existingFile;
}
return this.app.vault.create(filePath, contents);
}
private remapTemplateMapPaths(templateMap: Record<string, string>, oldPath: string, newPath: string) {
const remappedTemplateMap: Record<string, string> = {};
Object.entries(templateMap).forEach(([journalPath, templatePath]) => {
if (journalPath === oldPath) {
remappedTemplateMap[newPath] = templatePath;
return;
}
if (journalPath.startsWith(oldPath + '/')) {
remappedTemplateMap[newPath + journalPath.slice(oldPath.length)] = templatePath;
return;
}
remappedTemplateMap[journalPath] = templatePath;
});
return remappedTemplateMap;
}
private remapCadenceMapPaths(cadenceMap: Record<string, JournalCadenceConfig>, oldPath: string, newPath: string) {
const remappedCadenceMap: Record<string, JournalCadenceConfig> = {};
Object.entries(cadenceMap).forEach(([journalPath, cadence]) => {
if (journalPath === oldPath) {
remappedCadenceMap[newPath] = cadence;
return;
}
if (journalPath.startsWith(oldPath + '/')) {
remappedCadenceMap[newPath + journalPath.slice(oldPath.length)] = cadence;
return;
}
remappedCadenceMap[journalPath] = cadence;
});
return remappedCadenceMap;
}
private remapPromptSettingsPaths(promptSettingsMap: Record<string, JournalPromptSettings>, oldPath: string, newPath: string) {
const remappedPromptSettingsMap: Record<string, JournalPromptSettings> = {};
Object.entries(promptSettingsMap).forEach(([journalPath, promptSettings]) => {
if (journalPath === oldPath) {
remappedPromptSettingsMap[newPath] = promptSettings;
return;
}
if (journalPath.startsWith(oldPath + '/')) {
remappedPromptSettingsMap[newPath + journalPath.slice(oldPath.length)] = promptSettings;
return;
}
remappedPromptSettingsMap[journalPath] = promptSettings;
});
return remappedPromptSettingsMap;
}
private remapPromptHistoryPaths(promptHistoryMap: Record<string, JournalPromptHistory>, oldPath: string, newPath: string) {
const remappedPromptHistoryMap: Record<string, JournalPromptHistory> = {};
Object.entries(promptHistoryMap).forEach(([journalPath, promptHistory]) => {
if (journalPath === oldPath) {
remappedPromptHistoryMap[newPath] = promptHistory;
return;
}
if (journalPath.startsWith(oldPath + '/')) {
remappedPromptHistoryMap[newPath + journalPath.slice(oldPath.length)] = promptHistory;
return;
}
remappedPromptHistoryMap[journalPath] = promptHistory;
});
return remappedPromptHistoryMap;
}
private remapReminderSettingsPaths(reminderSettingsMap: Record<string, JournalReminderSettings>, oldPath: string, newPath: string) {
const remappedReminderSettingsMap: Record<string, JournalReminderSettings> = {};
Object.entries(reminderSettingsMap).forEach(([journalPath, reminderSettings]) => {
if (journalPath === oldPath) {
remappedReminderSettingsMap[newPath] = reminderSettings;
return;
}
if (journalPath.startsWith(oldPath + '/')) {
remappedReminderSettingsMap[newPath + journalPath.slice(oldPath.length)] = reminderSettings;
return;
}
remappedReminderSettingsMap[journalPath] = reminderSettings;
});
return remappedReminderSettingsMap;
}
private remapReminderHistoryPaths(reminderHistoryMap: Record<string, ReminderOccurrenceRecord>, oldPath: string, newPath: string) {
const remappedReminderHistoryMap: Record<string, ReminderOccurrenceRecord> = {};
Object.values(reminderHistoryMap).forEach(record => {
const nextJournalPath = record.journalPath === oldPath
? newPath
: record.journalPath.startsWith(oldPath + '/')
? newPath + record.journalPath.slice(oldPath.length)
: record.journalPath;
const historyKey = buildReminderHistoryKey(nextJournalPath, record.ruleKey, record.occurrenceKey);
remappedReminderHistoryMap[historyKey] = {
...record,
journalPath: nextJournalPath,
};
});
return remappedReminderHistoryMap;
}
private async getCoreTemplatesSettings() {
return this.readConfigJson<CoreTemplatesSettings>('templates.json');
}
private getRawTemplaterTemplateFolder() {
const templateFolder = this.getTemplaterPlugin()?.settings?.templates_folder?.trim().replace(/\/$/, '');
return templateFolder || null;
}
private async getRawCoreTemplateFolder() {
const coreTemplatesSettings = await this.getCoreTemplatesSettings();
const templateFolder = coreTemplatesSettings?.folder
?? coreTemplatesSettings?.templateFolder
?? coreTemplatesSettings?.templates_folder;
const normalizedTemplateFolder = templateFolder?.trim().replace(/\/$/, '');
if (!normalizedTemplateFolder || normalizedTemplateFolder === '/') {
return null;
}
return normalizedTemplateFolder;
}
private isCoreTemplatesPluginEnabled() {
const internalPlugins = this.getObsidianInternalPlugins();
const coreTemplatesPlugin = internalPlugins?.getPluginById?.('templates')
?? internalPlugins?.plugins?.['templates'];
if (typeof coreTemplatesPlugin?.enabled === 'boolean') {
return coreTemplatesPlugin.enabled;
}
if (typeof coreTemplatesPlugin?._loaded === 'boolean') {
return coreTemplatesPlugin._loaded;
}
if (coreTemplatesPlugin?.instance) {
return true;
}
return true;
}
private async readConfigJson<T>(configFileName: string): Promise<T | null> {
const configPath = normalizePath(this.app.vault.configDir + '/' + configFileName);
if (!await this.app.vault.adapter.exists(configPath)) {
return null;
}
try {
const rawContents = await this.app.vault.adapter.read(configPath);
return JSON.parse(rawContents) as T;
} catch (error) {
console.error(`Journalyst failed to read ${configFileName}.`, error);
return null;
}
}
private async renderCoreTemplate(templateContents: string, date: string, promptContext?: TemplatePromptContext | null) {
const coreTemplateSettings = await this.getCoreTemplatesSettings();
const defaultDateFormat = coreTemplateSettings?.dateFormat || 'YYYY-MM-DD';
const defaultTimeFormat = coreTemplateSettings?.timeFormat || 'HH:mm';
return templateContents.replace(/{{\s*(title|date|time|prompt|promptTitle)(?::([^}]+))?\s*}}/g, (_match, variable: string, explicitFormat?: string) => {
if (variable === 'title') {
return date;
}
if (variable === 'prompt') {
return promptContext?.prompt ?? '';
}
if (variable === 'promptTitle') {
return promptContext?.promptTitle ?? '';
}
const format = explicitFormat?.trim() || (variable === 'date' ? defaultDateFormat : defaultTimeFormat);
return moment().format(format);
});
}
private serializePromptFrontmatter(promptContext: TemplatePromptContext) {
const formatValue = (value: string) => {
const lines = value.replace(/\r\n/g, '\n').split('\n');
if (lines.length === 1) {
return JSON.stringify(value);
}
return `|-\n${lines.map(line => ` ${line}`).join('\n')}`;
};
return [
'---',
`journalyst_prompt: ${formatValue(promptContext.prompt)}`,
`journalyst_prompt_title: ${formatValue(promptContext.promptTitle)}`,
'---',
'',
].join('\n');
}
private getMarkdownFilesInFolder(templateFolder: string) {
const normalizedTemplateFolder = normalizePath(templateFolder);
const templateFolderPrefix = normalizedTemplateFolder === '/' ? '' : normalizedTemplateFolder + '/';
return this.app.vault.getMarkdownFiles()
.filter(file => file.path.startsWith(templateFolderPrefix));
}
async activateView() {
await this.activateReviewView(undefined, undefined, 'home');
}
async activateSidebarView(sidebarMode?: SidebarMode) {
const { workspace } = this.app;
if (sidebarMode && this.settings.sidebarMode !== sidebarMode) {
this.settings.sidebarMode = sidebarMode;
await this.saveSettings();
}
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_SIDE_BAR);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
if (!leaf) {
return;
}
await leaf.setViewState({ type: VIEW_TYPE_SIDE_BAR, active: true });
}
if (leaf.view instanceof SideBarView) {
leaf.view.renderView();
}
// "Reveal" the leaf in case it is in a collapsed sidebar
workspace.revealLeaf(leaf);
}
async activateReviewView(journalPath?: string | null, anchorDate?: string, activeTab?: ReviewWorkspaceTab) {
const { workspace } = this.app;
const resolvedAnchorDate = anchorDate ?? moment().format('YYYY-MM-DD');
const targetJournalPath = journalPath ?? this.inferCurrentJournal()?.path ?? this.getDefaultReviewJournalPath();
await this.setReviewState(targetJournalPath, resolvedAnchorDate, activeTab);
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_REVIEW);
if (leaves.length > 0) {
leaf = leaves[0];
} else {
leaf = workspace.getLeaf(true);
if (!leaf) {
return;
}
await leaf.setViewState({ type: VIEW_TYPE_REVIEW, active: true });
}
const reviewView = leaf.view;
if (reviewView instanceof ReviewView) {
reviewView.updateReviewState(targetJournalPath, resolvedAnchorDate, activeTab);
}
workspace.revealLeaf(leaf);
}
private refreshSidebarView() {
const sideBarLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_SIDE_BAR);
sideBarLeaves.forEach(leaf => {
if (leaf.view instanceof SideBarView) {
leaf.view.renderView();
}
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
// Older installs stored a single journalTemplates map before engine-specific
// settings existed. Preserve those choices by migrating them into the
// templater map on load.
this.settings.templaterJournalTemplates = this.settings.templaterJournalTemplates ?? this.settings.journalTemplates ?? {};
this.settings.basesIntegrationEnabled = this.settings.basesIntegrationEnabled ?? false;
this.settings.remindersEnabled = this.settings.remindersEnabled ?? false;
this.settings.osNotificationsEnabled = this.settings.osNotificationsEnabled ?? false;
this.settings.sidebarMode = this.settings.sidebarMode ?? 'journals-mini';
this.settings.coreJournalTemplates = this.settings.coreJournalTemplates ?? {};
this.settings.noteDateFormat = this.settings.noteDateFormat ?? 'YYYY-MM-DD';
this.settings.noteDateFormatHistory = this.settings.noteDateFormatHistory ?? [];
this.settings.journalCadences = this.settings.journalCadences ?? {};
this.settings.customPromptLists = this.settings.customPromptLists ?? {};
this.settings.journalPromptSettings = this.settings.journalPromptSettings ?? {};
this.settings.journalPromptHistory = this.settings.journalPromptHistory ?? {};
this.settings.journalReminderSettings = this.settings.journalReminderSettings ?? {};
this.settings.reminderHistory = this.settings.reminderHistory ?? {};
this.settings.templateEngine = this.settings.templateEngine ?? 'templater';
this.settings.templateFailureBehavior = this.settings.templateFailureBehavior ?? 'fallback-default';
}
async saveSettings() {
await this.saveData(this.settings);
}
private async resolvePromptForJournal(journalPath: string, date: string): Promise<{ resolvedPrompt: ResolvedPrompt | null; error?: string | null }> {
const promptSettings = this.getJournalPromptSettings(journalPath);
const promptHistory = this.settings.journalPromptHistory[journalPath];
const result = await resolvePromptForJournal(promptSettings, promptHistory, date, this.getPromptResolveContext());
return {
resolvedPrompt: result.resolvedPrompt,
error: result.error,
};
}
private getPromptResolveContext() {
return {
customPromptLists: this.settings.customPromptLists,
getFileByPath: (path: string) => {
const file = this.app.vault.getAbstractFileByPath(path);
return file instanceof TFile ? file : null;
},
readFile: (file: TFile) => this.app.vault.read(file),
};
}
private getPromptSourceKey(promptSettings: JournalPromptSettings) {
if (promptSettings.sourceType === 'file') {
return `file:${promptSettings.selectedFilePath ?? ''}`;
}
return `${promptSettings.sourceType}:${promptSettings.selectedListId ?? ''}`;
}
private doesTemplateEngineSupportPromptContext(templateEngine: TemplateEngine) {
return templateEngine === 'core' || templateEngine === 'templater';
}
private clearReminderHistoryForJournal(journalPath: string) {
Object.keys(this.settings.reminderHistory).forEach(historyKey => {
if (this.settings.reminderHistory[historyKey]?.journalPath === journalPath) {
delete this.settings.reminderHistory[historyKey];
}
});
}
}