From 0b0fb67f36052f0d1173bb7600c7581386a5640b Mon Sep 17 00:00:00 2001 From: stfrigerio Date: Sun, 13 Apr 2025 14:57:11 +0200 Subject: [PATCH] added journals --- main.ts | 55 +++++++--- src/commands/index.ts | 4 +- src/commands/syncDBToJournals.ts | 183 +++++++++++++++++++++++++++++++ src/commands/syncJournalsToDB.ts | 183 +++++++++++++++++++++++++++++++ src/helpers/index.ts | 3 +- src/settingTab.ts | 23 +++- src/styles/index.ts | 5 + src/types.ts | 4 + src/webcomponents/index.ts | 8 ++ 9 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 src/commands/syncDBToJournals.ts create mode 100644 src/commands/syncJournalsToDB.ts create mode 100644 src/styles/index.ts create mode 100644 src/webcomponents/index.ts diff --git a/main.ts b/main.ts index 659d96f..4d0fd52 100644 --- a/main.ts +++ b/main.ts @@ -4,29 +4,25 @@ import { Editor, MarkdownView, MarkdownPostProcessorContext, - Notice + Notice, } from "obsidian"; import { DBService } from "./src/DBService"; import { SQLiteDBSettingTab } from "./src/settingTab"; -import { inspectTableStructure, convertEntriesInNotes } from "./src/commands"; +import { inspectTableStructure, convertEntriesInNotes, syncDBToJournals, syncJournalsToDB } from "./src/commands"; import { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer } from "./src/codeblocks"; -import { pickTableName } from "./src/helpers"; -import { replacePlaceholders } from "src/helpers/replacePlaceholders"; - -import { injectDatePickerStyles } from "src/styles/datePickerInject"; -import { injectDateNavigatorStyles, removeDateNavigatorStyles } from './src/styles/dateNavigationInject'; - -import { registerHabitCounter } from "./src/webcomponents/HabitCounter/registerHabitCounter"; -import { registerBooleanSwitch } from "src/webcomponents/BooleanSwitch/registerBooleanSwitch"; -import { registerTextInput } from "src/webcomponents/TextInput/registerTextInput"; -import { registerTimestampUpdaterButton } from "src/webcomponents/TimestampUpdaterButton/registerTimestampUpdaterButton"; -import { registerSqlChartRenderer } from "src/webcomponents/SqlChart/registerSqlChart"; - +import { pickTableName, replacePlaceholders } from "./src/helpers"; +import { injectDatePickerStyles, injectDateNavigatorStyles, injectTimePickerStyles, removeDateNavigatorStyles } from "src/styles"; +import { + registerHabitCounter, + registerBooleanSwitch, + registerTextInput, + registerTimestampUpdaterButton, + registerSqlChartRenderer, + registerMoodNoteButtonProcessor +} from "./src/webcomponents"; import { SQLiteDBSettings, DEFAULT_SETTINGS } from "./src/types"; import { pluginState } from "src/pluginState"; -import { MoodNoteEntryModal } from "src/components/MoodNoteModal/MoodNoteEntryModal"; -import { registerMoodNoteButtonProcessor } from "src/webcomponents/MoodNote/moodButtonProcessor"; export default class SQLiteDBPlugin extends Plugin { settings: SQLiteDBSettings; @@ -42,6 +38,7 @@ export default class SQLiteDBPlugin extends Plugin { injectDatePickerStyles(); injectDateNavigatorStyles(); + injectTimePickerStyles(); //? Components this.registerMarkdownPostProcessor((el, ctx) => { @@ -104,6 +101,32 @@ export default class SQLiteDBPlugin extends Plugin { }, }); + this.addCommand({ + id: 'sync-journal-notes-to-db', + name: 'Sync Journal Notes -> Database', + callback: async () => { + if (!this.settings.journalFolderPath || !this.settings.journalTableName) { + new Notice("Please configure Journal folder path and table name in settings."); + return; + } + new Notice("Starting sync: Notes -> DB..."); + await syncJournalsToDB(this.dbService, this.settings.journalFolderPath, this.settings.journalTableName, this.app); + } + }); + + this.addCommand({ + id: 'sync-journal-db-to-notes', + name: 'Sync Database -> Journal Notes', + callback: async () => { + if (!this.settings.journalFolderPath || !this.settings.journalTableName) { + new Notice("Please configure Journal folder path and table name in settings."); + return; + } + new Notice("Starting sync: DB -> Notes..."); + await syncDBToJournals(this.dbService, this.settings.journalTableName, this.settings.journalFolderPath, this.app); + } + }); + this.addSettingTab(new SQLiteDBSettingTab(this.app, this)); } diff --git a/src/commands/index.ts b/src/commands/index.ts index a003c08..3c8c190 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,6 @@ import { inspectTableStructure } from "./inspectTableStructure"; import { convertEntriesInNotes } from "./convertEntriesInNotes"; +import { syncDBToJournals } from "./syncDBToJournals"; +import { syncJournalsToDB } from "./syncJournalsToDB"; -export { inspectTableStructure, convertEntriesInNotes }; +export { inspectTableStructure, convertEntriesInNotes, syncDBToJournals, syncJournalsToDB }; diff --git a/src/commands/syncDBToJournals.ts b/src/commands/syncDBToJournals.ts new file mode 100644 index 0000000..3ac5532 --- /dev/null +++ b/src/commands/syncDBToJournals.ts @@ -0,0 +1,183 @@ +import { App, Notice, TFile, TFolder, normalizePath, stringifyYaml, MetadataCache, Vault } from "obsidian"; +import { DBService } from "../DBService"; // Adjust path + +/** + * Syncs database rows from a table to notes in a specified folder. + * - Queries the DB table. + * - For each row, tries to find a matching note using 'db_id' in frontmatter. + * - Creates new notes for DB rows not found in the folder. + * - Updates existing notes if the DB row is newer (based on updated_at). + */ +export async function syncDBToJournals(dbService: DBService, tableName: string, folderPath: string, app: App) { + const { vault, metadataCache } = app; + const db = dbService.getDB(); // Assuming local for now + if (!db && dbService.mode === "local") { + new Notice("Local DB not loaded."); + return; + } + + // --- Ensure Target Folder Exists --- + const targetFolder = await ensureFolderExists(folderPath, app.vault); + if (!targetFolder) { + new Notice(`Failed to find or create folder "${folderPath}".`); + return; + } + + // --- Get All Notes in Target Folder with db_id --- + const notesInFolder = targetFolder.children.filter((file): file is TFile => file instanceof TFile && file.extension === 'md'); + const notesMap = new Map(); // Map db_id -> note info + + for (const note of notesInFolder) { + const fm = metadataCache.getFileCache(note)?.frontmatter; + if (fm && fm.uuid) { + notesMap.set(fm.uuid, { + file: note, + fmUpdatedAt: fm.updatedAt || null, + mtime: note.stat.mtime + }); + } + } + + // --- Query DB --- + // Adapt column names! Fetch all relevant columns including uuid and updated_at + const query = `SELECT uuid, date, place, text, createdAt, updatedAt FROM "${tableName}"`; + const dbRows = await dbService.getQuery(query); // Adapt for local/remote + + if (!dbRows || dbRows.length === 0) { + new Notice(`No rows found in DB table "${tableName}".`); + return; + } + + let createdCount = 0; + let updatedCount = 0; + let skippedCount = 0; + let errorCount = 0; + + for (const row of dbRows) { + try { + const uuid = row.uuid; // Adapt column name + const dbUpdatedAt = row.updatedAt; // Adapt column name + + if (!uuid || !dbUpdatedAt) { + console.warn("Skipping DB row - missing 'uuid' or 'updatedAt'.", row); + errorCount++; + continue; + } + + const existingNoteInfo = notesMap.get(uuid); + + // --- Decide Action --- + if (existingNoteInfo) { + // Note exists - check if update needed + const noteFile = existingNoteInfo.file; + const noteMtime = existingNoteInfo.mtime; + const dbTimestamp = new Date(dbUpdatedAt).getTime(); + + // Compare DB timestamp with note modification time. + // If DB is significantly newer, update the note. + // Add a small buffer (e.g., 1-2 seconds) to avoid sync loops if timestamps are very close. + const buffer = 2000; // 2 seconds + if (dbTimestamp > (noteMtime + buffer)) { + console.log(`DB row ${uuid} is newer than note "${noteFile.path}". Updating note.`); + await updateOrCreateNoteFromDbRow(row, tableName, targetFolder.path, app, noteFile); + updatedCount++; + } else { + // console.log(`Note "${noteFile.path}" is up-to-date with DB row ${dbId}. Skipping.`); + skippedCount++; + } + // Remove from map so remaining notes can be potentially deleted later (optional) + notesMap.delete(uuid); + } else { + // Note does not exist - create it + console.log(`DB row ${uuid} not found in notes. Creating new note.`); + await updateOrCreateNoteFromDbRow(row, tableName, targetFolder.path, app); + createdCount++; + } + } catch (error) { + console.error(`Error processing DB row with uuid ${row.uuid}:`, error); + new Notice(`Error processing DB row ${row.uuid}. Check console.`); + errorCount++; + } + } + + new Notice(`Sync DB to Notes complete. Created: ${createdCount}, Updated: ${updatedCount}, Skipped: ${skippedCount}, Errors: ${errorCount}.`); +} + + +// Helper Function to Create/Update a Note from a DB Row +async function updateOrCreateNoteFromDbRow( + dbRow: any, + tableName: string, + folderPath: string, + app: App, + existingFile?: TFile | null +) { + const { vault } = app; + + // --- Map DB columns to Frontmatter keys --- (Adapt these!) + const frontmatterData: Record = { + uuid: dbRow.uuid, + db_table: tableName, + date: dbRow.date, + place: dbRow.place, + createdAt: dbRow.createdAt, + updatedAt: dbRow.updatedAt, + }; + // Remove null/undefined values from frontmatter if desired + Object.keys(frontmatterData).forEach(key => + (frontmatterData[key] === null || frontmatterData[key] === undefined) && delete frontmatterData[key] + ); + + const noteBody = dbRow.text || ''; // Adapt column name + + // --- Construct File Content --- + const frontmatterString = stringifyYaml(frontmatterData); + const fileContent = `---\n${frontmatterString}---\n\n${noteBody}`; + + if (existingFile) { + // --- Update Existing File --- + const currentContent = await vault.cachedRead(existingFile); + if (fileContent !== currentContent) { // Only modify if content changed + await vault.modify(existingFile, fileContent); + } + } else { + // --- Create New File --- + const datePart = dbRow.date ? new Date(dbRow.date).toISOString().split('T')[0] : 'UnknownDate'; + const safeBaseName = sanitizeFilename(`Journal ${datePart}`); + let notePath = normalizePath(`${folderPath}/${safeBaseName}.md`); + + // Handle filename collisions + let suffix = 1; + while (vault.getAbstractFileByPath(notePath)) { + suffix++; + notePath = normalizePath(`${folderPath}/${safeBaseName}_${suffix}.md`); + } + await vault.create(notePath, fileContent); + } +} + + +// Helper to ensure folder exists +async function ensureFolderExists(folderPath: string, vault: Vault): Promise { + const normPath = normalizePath(folderPath); + let folder = vault.getAbstractFileByPath(normPath); + + if (folder && !(folder instanceof TFolder)) { + new Notice(`"${normPath}" exists but is not a folder.`); + return null; + } + if (!folder) { + try { + folder = await vault.createFolder(normPath); + } catch (err) { + console.error(`Error creating folder "${normPath}":`, err); + return null; + } + } + return folder as TFolder; +} + +// Re-use sanitizeFilename function from your original code +function sanitizeFilename(name: string): string { + return name.replace(/[\\\/:*?"<>|]/g, "_").trim(); +} \ No newline at end of file diff --git a/src/commands/syncJournalsToDB.ts b/src/commands/syncJournalsToDB.ts new file mode 100644 index 0000000..55eeb7a --- /dev/null +++ b/src/commands/syncJournalsToDB.ts @@ -0,0 +1,183 @@ +import { App, Notice, TFile, TFolder, normalizePath, stringifyYaml } from "obsidian"; +import { DBService } from "../DBService"; // Adjust path + +// Define an interface for your Journal structure (optional but helpful) +interface JournalData { + uuid?: string; + date: string; // ISO Format + place?: string; + text: string; + createdAt?: string; // ISO Format + updatedAt?: string; // ISO Format +} + +/** + * Syncs notes from a specified folder to a database table. + * - Finds notes in the folder. + * - Parses frontmatter and content. + * - Uses 'db_id' frontmatter key to link to DB rows. + * - Performs UPSERT (UPDATE or INSERT) based on db_id and timestamps. + * - Updates note frontmatter with db_id and timestamps after INSERT. + */ +export async function syncJournalsToDB(dbService: DBService, folderPath: string, tableName: string, app: App) { + const { vault, metadataCache } = app; + const db = dbService.getDB(); // Assuming local for now, adapt for remote + if (!db && dbService.mode === "local") { + new Notice("Local DB not loaded."); + return; + } + + const folder = vault.getAbstractFileByPath(normalizePath(folderPath)); + if (!folder || !(folder instanceof TFolder)) { + new Notice(`Folder "${folderPath}" not found.`); + return; + } + + const notes = folder.children.filter((file): file is TFile => file instanceof TFile && file.extension === 'md'); + let updatedCount = 0; + let insertedCount = 0; + let errorCount = 0; + + for (const note of notes) { + try { + const fileContent = await vault.cachedRead(note); + const frontmatter = metadataCache.getFileCache(note)?.frontmatter || {}; + const noteMtime = note.stat.mtime; // Note's last modified time + + // --- Extract data from note --- + const journalData: Partial = { + uuid: frontmatter.uuid, + date: frontmatter.date, + place: frontmatter.place, + // Extract content AFTER frontmatter + text: extractNoteContent(fileContent), + createdAt: frontmatter.createdAt, + updatedAt: frontmatter.updatedAt, + }; + + // Basic validation + if (!journalData.date) { + console.warn(`Skipping note "${note.path}" - missing 'date' in frontmatter.`); + continue; + } + + // --- Sync Logic --- + let syncAction: 'skip' | 'update' | 'insert' = 'skip'; + let dbUpdatedAt: string | null = null; + + if (journalData.uuid) { + // Note has an ID - check DB and timestamps + const checkQuery = `SELECT updatedAt FROM "${tableName}" WHERE uuid = ?`; // Use your actual ID column name + const existingRow = await dbService.getQuery(checkQuery, [journalData.uuid]); // Adapt for local/remote + + if (existingRow && existingRow.length > 0) { + dbUpdatedAt = existingRow[0].updatedAt; + // Compare note mtime with DB updated_at timestamp + // If note is newer than DB record, update DB. + // (More robust: compare note mtime > frontmatter.updated_at) + if (noteMtime > (new Date(dbUpdatedAt!)).getTime()) { + syncAction = 'update'; + console.log(`Note "${note.path}" is newer than DB record. Scheduling UPDATE.`); + } else { + console.log(`Note "${note.path}" is up-to-date with DB record. Skipping.`); + } + } else { + // Note has ID, but not found in DB (maybe deleted in DB?) -> Re-insert + console.warn(`Note "${note.path}" has uuid ${journalData.uuid} but not found in DB. Scheduling INSERT.`); + syncAction = 'insert'; + journalData.uuid = dbService.generateUuid(); + } + } else { + // Note has no ID - it's new -> Insert + syncAction = 'insert'; + journalData.uuid = dbService.generateUuid(); // Generate new UUID for insertion + console.log(`Note "${note.path}" has no uuid. Scheduling INSERT with new ID ${journalData.uuid}.`); + } + + // --- Perform DB Action --- + if (syncAction === 'insert') { + const now = new Date().toISOString(); + journalData.createdAt = now; + journalData.updatedAt = now; + + // Adapt column names and parameter order! + const insertSql = `INSERT INTO "${tableName}" (uuid, date, place, text, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + const params = [ + journalData.uuid, + journalData.date, + journalData.place || null, // Handle optional fields + journalData.text, + journalData.createdAt, + journalData.updatedAt, + ]; + await dbService.runQuery(insertSql, params); // Adapt for local/remote + + // --- IMPORTANT: Update Note Frontmatter with new ID and timestamps --- + await updateNoteFrontmatter(note, { + uuid: journalData.uuid, + createdAt: journalData.createdAt, + updatedAt: journalData.updatedAt, + // Keep other existing frontmatter + ...frontmatter, + date: journalData.date, // Ensure date is saved back if defaulted + place: journalData.place, // Ensure location is saved back + }, fileContent, app); + + insertedCount++; + + } else if (syncAction === 'update') { + const now = new Date().toISOString(); + journalData.updatedAt = now; // Update timestamp + + // Adapt column names and parameter order! + const updateSql = `UPDATE "${tableName}" SET date = ?, place = ?, text = ?, updatedAt = ? WHERE uuid = ?`; + const params = [ + journalData.date, + journalData.place || null, + journalData.text, + journalData.updatedAt, + journalData.uuid, // WHERE clause parameter + ]; + await dbService.runQuery(updateSql, params); // Adapt for local/remote + + // --- IMPORTANT: Update Note Frontmatter with new updated_at --- + await updateNoteFrontmatter(note, { + updatedAt: journalData.updatedAt, + // Keep other existing frontmatter + ...frontmatter, + date: journalData.date, + place: journalData.place, + }, fileContent, app); + + updatedCount++; + } + + } catch (error) { + console.error(`Error processing note "${note.path}":`, error); + new Notice(`Error syncing note "${note.name}". Check console.`); + errorCount++; + } + } + + new Notice(`Sync Notes to DB complete. Inserted: ${insertedCount}, Updated: ${updatedCount}, Errors: ${errorCount}.`); +} + + +// Helper to extract content after frontmatter +function extractNoteContent(fileContent: string): string { + const match = fileContent.match(/^---\s*([\s\S]*?)\s*---(\s*[\s\S]*)$/); + return match ? (match[2] || '').trim() : fileContent.trim(); +} + +// Helper to update note frontmatter non-destructively +async function updateNoteFrontmatter(file: TFile, newData: Record, currentContent: string, app: App) { + const contentOnly = extractNoteContent(currentContent); + const newFrontmatter = stringifyYaml(newData); // Use Obsidian's YAML stringifier + const newFileContent = `---\n${newFrontmatter}---\n\n${contentOnly}`; + + // Avoid triggering another update immediately by comparing content + if (newFileContent !== currentContent) { + await app.vault.modify(file, newFileContent); + console.log(`Updated frontmatter for "${file.path}"`); + } +} \ No newline at end of file diff --git a/src/helpers/index.ts b/src/helpers/index.ts index a3074a1..d67d34c 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,3 +1,4 @@ import { pickTableName } from "./pickTableName"; +import { replacePlaceholders } from "./replacePlaceholders"; -export { pickTableName }; +export { pickTableName, replacePlaceholders }; diff --git a/src/settingTab.ts b/src/settingTab.ts index bb971c9..4d5b07e 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -47,6 +47,27 @@ export class SQLiteDBSettingTab extends PluginSettingTab { this.plugin.settings.apiBaseUrl = value; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName('Journal Folder Path') + .setDesc('Path to the folder containing your journal notes (relative to vault root).') + .addText(text => text + .setPlaceholder('e.g., Journals or Notes/Daily') + .setValue(this.plugin.settings.journalFolderPath) + .onChange(async (value) => { + this.plugin.settings.journalFolderPath = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Journal Table Name') + .setDesc('The name of the database table for journal entries.') + .addText(text => text + .setPlaceholder('e.g., Journals') + .setValue(this.plugin.settings.journalTableName) + .onChange(async (value) => { + this.plugin.settings.journalTableName = value; + await this.plugin.saveSettings(); + })); } - } diff --git a/src/styles/index.ts b/src/styles/index.ts new file mode 100644 index 0000000..e45e26c --- /dev/null +++ b/src/styles/index.ts @@ -0,0 +1,5 @@ +import { injectDatePickerStyles } from "./datePickerInject"; +import { injectDateNavigatorStyles, removeDateNavigatorStyles } from "./dateNavigationInject"; +import { injectTimePickerStyles } from "./timePickerInject"; + +export { injectDatePickerStyles, injectDateNavigatorStyles, removeDateNavigatorStyles, injectTimePickerStyles }; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 1dd3e37..8750849 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,10 +2,14 @@ export interface SQLiteDBSettings { dbFilePath: string; mode: "local" | "remote"; apiBaseUrl: string; + journalFolderPath: string; + journalTableName: string; } export const DEFAULT_SETTINGS: SQLiteDBSettings = { dbFilePath: "", mode: "local", apiBaseUrl: "", + journalFolderPath: "", + journalTableName: "", }; diff --git a/src/webcomponents/index.ts b/src/webcomponents/index.ts new file mode 100644 index 0000000..5dcd2d5 --- /dev/null +++ b/src/webcomponents/index.ts @@ -0,0 +1,8 @@ +import { registerHabitCounter } from "./HabitCounter/registerHabitCounter"; +import { registerBooleanSwitch } from "./BooleanSwitch/registerBooleanSwitch"; +import { registerTextInput } from "./TextInput/registerTextInput"; +import { registerTimestampUpdaterButton } from "./TimestampUpdaterButton/registerTimestampUpdaterButton"; +import { registerSqlChartRenderer } from "./SqlChart/registerSqlChart"; +import { registerMoodNoteButtonProcessor } from "./MoodNote/moodButtonProcessor"; + +export { registerHabitCounter, registerBooleanSwitch, registerTextInput, registerTimestampUpdaterButton, registerSqlChartRenderer, registerMoodNoteButtonProcessor }; \ No newline at end of file