added journals

This commit is contained in:
stfrigerio 2025-04-13 14:57:11 +02:00
parent 49af0fb0a2
commit 0b0fb67f36
9 changed files with 449 additions and 19 deletions

55
main.ts
View file

@ -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));
}

View file

@ -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 };

View file

@ -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<string, { file: TFile, fmUpdatedAt: string | null, mtime: number }>(); // 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<string, any> = {
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<TFolder | null> {
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();
}

View file

@ -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<JournalData> = {
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<string, any>, 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}"`);
}
}

View file

@ -1,3 +1,4 @@
import { pickTableName } from "./pickTableName";
import { replacePlaceholders } from "./replacePlaceholders";
export { pickTableName };
export { pickTableName, replacePlaceholders };

View file

@ -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();
}));
}
}

5
src/styles/index.ts Normal file
View file

@ -0,0 +1,5 @@
import { injectDatePickerStyles } from "./datePickerInject";
import { injectDateNavigatorStyles, removeDateNavigatorStyles } from "./dateNavigationInject";
import { injectTimePickerStyles } from "./timePickerInject";
export { injectDatePickerStyles, injectDateNavigatorStyles, removeDateNavigatorStyles, injectTimePickerStyles };

View file

@ -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: "",
};

View file

@ -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 };