From c95cca0ffc9b6be94cefe0db0faaaf6f9da72a96 Mon Sep 17 00:00:00 2001 From: Henri Jamet <42291955+hjamet@users.noreply.github.com> Date: Wed, 21 May 2025 11:12:39 +0200 Subject: [PATCH 1/4] Enhance VoiceNotes plugin with user authentication features, including username and password handling, improved error notifications, and robust API request management. Added username field to settings and updated API class to manage credentials securely. Improved sync process with detailed logging and error handling for better user experience. --- main.ts | 94 ++++++++++++++------ settings.ts | 165 +++++++++++++++++++++++------------ types.ts | 1 - voicenotes-api.ts | 213 ++++++++++++++++++++++++++++------------------ 4 files changed, 306 insertions(+), 167 deletions(-) diff --git a/main.ts b/main.ts index 2882d49..93ed264 100644 --- a/main.ts +++ b/main.ts @@ -15,6 +15,7 @@ const DEFAULT_SETTINGS: VoiceNotesPluginSettings = { deleteSynced: false, reallyDeleteSynced: false, todoTag: '', + username: '', filenameDateFormat: 'YYYY-MM-DD', frontmatterTemplate: `duration: {{duration}} created_at: {{created_at}} @@ -139,6 +140,10 @@ export default class VoiceNotesPlugin extends Plugin { async onload() { await this.loadSettings(); + this.vnApi = new VoiceNotesApi({ + token: this.settings.token, + username: this.settings.username + }); this.addSettingTab(new VoiceNotesSettingTab(this.app, this)); if (this.settings.token) { @@ -320,10 +325,15 @@ export default class VoiceNotesPlugin extends Plugin { const outputLocationPath = normalizePath(`${audioPath}/${recording.recording_id}.mp3`); if (!(await this.app.vault.adapter.exists(outputLocationPath))) { const signedUrl = await this.vnApi.getSignedUrl(recording.recording_id); - await this.vnApi.downloadFile(this.fs, signedUrl.url, outputLocationPath); + if (signedUrl && signedUrl.url) { + await this.vnApi.downloadFile(this.fs, signedUrl.url, outputLocationPath); + embeddedAudioLink = `![[${recording.recording_id}.mp3]]`; + audioFilename = `${recording.recording_id}.mp3`; + } else { + new Notice(`Could not get download URL for audio: ${recording.title}. Skipping audio download.`) + console.error(`Failed to get signed URL for recording ID: ${recording.recording_id}`); + } } - embeddedAudioLink = `![[${recording.recording_id}.mp3]]`; - audioFilename = `${recording.recording_id}.mp3`; } // Handle attachments @@ -353,8 +363,8 @@ export default class VoiceNotesPlugin extends Plugin { const formattedPoints = points ? points.content.data.map((data: string) => `- ${data}`).join('\n') : null; const formattedTodos = todo ? todo.content.data - .map((data: string) => `- [ ] ${data}${this.settings.todoTag ? ' #' + this.settings.todoTag : ''}`) - .join('\n') + .map((data: string) => `- [ ] ${data}${this.settings.todoTag ? ' #' + this.settings.todoTag : ''}`) + .join('\n') : null; // Format tags, replacing spaces with hyphens for multi-word tags const formattedTags = @@ -383,20 +393,20 @@ export default class VoiceNotesPlugin extends Plugin { related_notes: recording.related_notes && recording.related_notes.length > 0 ? recording.related_notes - .map( - (relatedNote: { title: string; created_at: string }) => - `- [[${this.sanitizedTitle(relatedNote.title, relatedNote.created_at)}]]` - ) - .join('\n') + .map( + (relatedNote: { title: string; created_at: string }) => + `- [[${this.sanitizedTitle(relatedNote.title, relatedNote.created_at)}]]` + ) + .join('\n') : null, subnotes: recording.subnotes && recording.subnotes.length > 0 ? recording.subnotes - .map( - (subnote: { title: string; created_at: string }) => - `- [[${this.sanitizedTitle(subnote.title, subnote.created_at)}]]` - ) - .join('\n') + .map( + (subnote: { title: string; created_at: string }) => + `- [[${this.sanitizedTitle(subnote.title, subnote.created_at)}]]` + ) + .join('\n') : null, attachments: attachments, parent_note: isSubnote ? `[[${parentTitle}]]` : null, @@ -426,7 +436,10 @@ export default class VoiceNotesPlugin extends Plugin { } if (this.settings.deleteSynced && this.settings.reallyDeleteSynced) { - await this.vnApi.deleteRecording(recording.recording_id); + const deleted = await this.vnApi.deleteRecording(recording.recording_id); + if (!deleted) { + new Notice(`Failed to delete recording from server: ${recording.title}`); + } } } } catch (error) { @@ -458,9 +471,6 @@ export default class VoiceNotesPlugin extends Plugin { this.syncedRecordingIds = await this.getSyncedRecordingIds(); - this.vnApi = new VoiceNotesApi({}); - this.vnApi.token = this.settings.token; - const voiceNotesDir = normalizePath(this.settings.syncDirectory); if (!(await this.app.vault.adapter.exists(voiceNotesDir))) { new Notice('Creating sync directory for Voice Notes Sync plugin'); @@ -477,32 +487,62 @@ export default class VoiceNotesPlugin extends Plugin { if (fullSync && recordings.links.next) { let nextPage = recordings.links.next; + let pageNum = 1; + const initialTotal = recordings.data.length; + console.log(`Full Sync: Starting. Initial page loaded with ${initialTotal} recordings.`); do { - console.debug(`Performing a full sync ${nextPage}`); + pageNum++; + console.log(`Full Sync: Fetching page ${pageNum} from URL: ${nextPage}`); const moreRecordings = await this.vnApi.getRecordingsFromLink(nextPage); + if (!moreRecordings || !moreRecordings.data) { + new Notice('Failed to fetch further recordings during full sync. Please check login status.'); + // Token might be invalid, clear it to force re-login on next attempt or settings visit + this.settings.token = undefined; + await this.saveSettings(); + // Consider stopping the sync or further pagination here + return; + } recordings.data.push(...moreRecordings.data); nextPage = moreRecordings.links.next; + console.log(`Full Sync: Page ${pageNum} fetched. Total recordings so far: ${recordings.data.length}. Next page: ${nextPage ? 'Yes' : 'No'}`); } while (nextPage); + console.log(`Full Sync: All pages fetched. Total recordings: ${recordings.data.length}`); } - if (recordings) { + if (recordings && recordings.data) { new Notice(`Syncing latest Voicenotes`); + let processedCount = 0; + const totalToProcess = recordings.data.length; + console.log(`Processing ${totalToProcess} recordings...`); + for (const recording of recordings.data) { await this.processNote(recording, voiceNotesDir, false, '', unsyncedCount); + processedCount++; + if (processedCount % 10 === 0 || processedCount === totalToProcess) { // Log every 10 or at the end + console.log(`Processed ${processedCount}/${totalToProcess} recordings.`); + } } } new Notice(`Sync complete. ${unsyncedCount.count} recordings were not synced due to excluded tags.`); - } catch (error) { - console.error(error); - if (error.hasOwnProperty('status') !== 'undefined') { - this.settings.token = undefined; + } catch (error: any) { + console.error('Error during sync:', error); + if (error && (error.status === 401 || error.status === 403)) { + // Authentication or Authorization error + this.settings.token = undefined; // Clear token as it's invalid or insufficient await this.saveSettings(); - new Notice(`Login token was invalid, please try logging in again.`); + new Notice('Authentication failed. Please log in again via VoiceNotes Sync settings.'); + } else if (error && error.status === 429) { + // Rate limit error specifically + new Notice('Sync interrupted due to server rate limits. Please try again later.'); + } else if (error && error.message && error.message.includes('credentials not available')) { + // Specific error from our API client if login is needed but no creds + new Notice('Login credentials not available for VoiceNotes Sync. Please log in via settings.'); } else { - new Notice(`Error occurred syncing some notes to this vault.`) + // Other types of errors (network, unexpected server issues, etc.) + new Notice('Error occurred syncing some notes. Check console for details.'); } } } diff --git a/settings.ts b/settings.ts index be5e056..6f38748 100644 --- a/settings.ts +++ b/settings.ts @@ -1,4 +1,4 @@ -import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; +import { App, Notice, PluginSettingTab, Setting, TextComponent, ButtonComponent, ToggleComponent, TextAreaComponent } from 'obsidian'; import VoiceNotesApi from './voicenotes-api'; import VoiceNotesPlugin from './main'; import { autoResizeTextArea } from './utils'; @@ -6,51 +6,68 @@ import { autoResizeTextArea } from './utils'; export class VoiceNotesSettingTab extends PluginSettingTab { plugin: VoiceNotesPlugin; vnApi: VoiceNotesApi; - password: string; + password: string | null; constructor(app: App, plugin: VoiceNotesPlugin) { super(app, plugin); this.plugin = plugin; - this.vnApi = new VoiceNotesApi({}); + // this.vnApi = new VoiceNotesApi({}); // DO NOT create a new instance here for credential setting } async display(): Promise { const { containerEl } = this; - containerEl.empty(); + // Crucial check: Ensure vnApi is initialized + if (!this.plugin.vnApi) { + console.error("VoiceNotes Sync: this.plugin.vnApi is not available in settings tab. Plugin may not have loaded fully."); + new Setting(containerEl) + .setName("Error") + .setDesc("VoiceNotes Sync plugin is not fully initialized. Please try reloading Obsidian or checking for console errors."); + return; // Stop rendering if vnApi is not ready + } + if (!this.plugin.settings.token) { - new Setting(containerEl).setName('Username').addText((text) => + new Setting(containerEl).setName('Username').addText((text: TextComponent) => text .setPlaceholder('Email address') .setValue(this.plugin.settings.username) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.username = value; await this.plugin.saveSettings(); }) ); - new Setting(containerEl).setName('Password').addText((text) => { + new Setting(containerEl).setName('Password').addText((text: TextComponent) => { text .setPlaceholder('Password') - .setValue(this.plugin.settings.password) - .onChange(async (value) => { + .setValue(this.password ?? '') + .onChange(async (value: string) => { this.password = value; - await this.plugin.saveSettings(); + // Do not save the password in settings directly, it's temporary for login }); text.inputEl.type = 'password'; return text; }); - new Setting(containerEl).addButton((button) => + new Setting(containerEl).addButton((button: ButtonComponent) => button.setButtonText('Login').onClick(async () => { - this.plugin.settings.token = await this.vnApi.login({ + if (!this.plugin.settings.username || !this.password) { + new Notice('Please enter both username and password.'); + return; + } + const token = await this.plugin.vnApi.login({ username: this.plugin.settings.username, password: this.password, }); - this.plugin.settings.password = null; - if (this.plugin.settings.token) { + if (token) { + this.plugin.settings.token = token; + if (this.plugin.settings.username && this.password) { + // Use the plugin's shared API instance + this.plugin.vnApi.setCredentials(this.plugin.settings.username, this.password); + } + this.password = null; // Clear temporary password from settings tab memory new Notice('Login to voicenotes.com was successful'); await this.plugin.saveSettings(); this.plugin.setupAutoSync(); @@ -61,20 +78,29 @@ export class VoiceNotesSettingTab extends PluginSettingTab { }) ); - new Setting(containerEl).setName('Auth Token').addText((text) => + new Setting(containerEl).setName('Auth Token').addText((text: TextComponent) => text .setPlaceholder('12345|abcdefghijklmnopqrstuvwxyz') - .setValue(this.plugin.settings.token) - .onChange(async (value) => { + .setValue(this.plugin.settings.token ?? '') + .onChange(async (value: string) => { this.plugin.settings.token = value; await this.plugin.saveSettings(); }) ); - new Setting(containerEl).addButton((button) => + new Setting(containerEl).addButton((button: ButtonComponent) => button.setButtonText('Login with token').onClick(async () => { - this.vnApi.setToken(this.plugin.settings.token); - const response = await this.vnApi.getUserInfo(); - this.plugin.settings.password = null; + if (!this.plugin.settings.token) { + new Notice('Please enter a token.'); + return; + } + // Use the plugin's shared API instance + this.plugin.vnApi.setToken(this.plugin.settings.token); + // If logging in with token, credentials (username/password) for re-login won't be available + // unless they were set previously via username/password login. + // We should clear any old uname/pwd credentials in vnApi if user explicitly logs in with token only. + this.plugin.vnApi.setCredentials(undefined, undefined); + + const response = await this.plugin.vnApi.getUserInfo(); if (response) { new Notice('Login to voicenotes.com was successful'); @@ -88,22 +114,53 @@ export class VoiceNotesSettingTab extends PluginSettingTab { ); } if (this.plugin.settings.token) { - this.vnApi.setToken(this.plugin.settings.token); + const userInfo = await this.plugin.vnApi.getUserInfo(); - const userInfo = await this.vnApi.getUserInfo(); + if (!userInfo) { + // This can happen if the token is invalid or network error, even if vnApi exists + new Setting(containerEl) + .setName('Error') + .setDesc('Could not fetch user information. Your token might be invalid or there might be network issues. Please try logging out and logging in again.'); - new Setting(containerEl).setName('Name').addText((text) => text.setPlaceholder(userInfo.name).setDisabled(true)); + // Offer logout button even in this error state if token exists + new Setting(containerEl).addButton((button: ButtonComponent) => + button.setButtonText('Logout').onClick(async () => { + new Notice('Logged out of voicenotes.com'); + this.plugin.settings.token = null; + this.password = null; + if (this.plugin.vnApi) { + this.plugin.vnApi.setToken(undefined); + this.plugin.vnApi.setCredentials(undefined, undefined); + } else { + console.warn('VoiceNotesPlugin: vnApi instance not found on plugin during logout.'); + } + await this.plugin.saveSettings(); + await this.display(); + }) + ); + return; // Stop rendering further settings + } + + new Setting(containerEl).setName('Name').addText((text: TextComponent) => text.setPlaceholder(userInfo.name).setDisabled(true)); new Setting(containerEl) .setName('Email') - .addText((text) => text.setPlaceholder(userInfo.email).setDisabled(true)); - new Setting(containerEl).addButton((button) => + .addText((text: TextComponent) => text.setPlaceholder(userInfo.email).setDisabled(true)); + new Setting(containerEl).addButton((button: ButtonComponent) => button.setButtonText('Logout').onClick(async () => { new Notice('Logged out of voicenotes.com'); this.plugin.settings.token = null; - this.plugin.settings.password = null; - this.password = null; + this.password = null; // Clear any temporary password in the input field + + // Also update the active vnApi instance on the plugin + if (this.plugin && this.plugin.vnApi) { + this.plugin.vnApi.setToken(undefined); // Clear the token + this.plugin.vnApi.setCredentials(undefined, undefined); // Clear any in-memory credentials + } else { + console.warn('VoiceNotesPlugin: vnApi instance not found on plugin during logout.'); + } + await this.plugin.saveSettings(); - await this.display(); + await this.display(); // Refresh the settings tab UI }) ); @@ -112,14 +169,14 @@ export class VoiceNotesSettingTab extends PluginSettingTab { .setDesc( "Manual synchronization -- Prefer using the quick sync option unless you're having issues with syncing. Full synchronization will sync all notes, not just the last ten but can be much slower." ) - .addButton((button) => + .addButton((button: ButtonComponent) => button.setButtonText('Manual sync (quick)').onClick(async () => { new Notice('Performing manual synchronization of the last ten notes.'); await this.plugin.sync(); new Notice('Manual quick synchronization has completed.'); }) ) - .addButton((button) => + .addButton((button: ButtonComponent) => button.setButtonText('Manual sync (full)').onClick(async () => { new Notice('Performing manual synchronization of all notes.'); this.plugin.syncedRecordingIds = []; @@ -132,12 +189,12 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Automatic sync every') .setDesc('Number of minutes between syncing with VoiceNotes.com servers (uncheck to sync manually)') - .addText((text) => { + .addText((text: TextComponent) => { text .setDisabled(!this.plugin.settings.automaticSync) .setPlaceholder('30') .setValue(`${this.plugin.settings.syncTimeout}`) - .onChange(async (value) => { + .onChange(async (value: string) => { const numericValue = Number(value); const inputElement = text.inputEl; @@ -153,8 +210,8 @@ export class VoiceNotesSettingTab extends PluginSettingTab { text.inputEl.type = 'number'; return text; }) - .addToggle((toggle) => - toggle.setValue(this.plugin.settings.automaticSync).onChange(async (value) => { + .addToggle((toggle: ToggleComponent) => + toggle.setValue(this.plugin.settings.automaticSync).onChange(async (value: boolean) => { this.plugin.settings.automaticSync = value; // If we've turned on automatic sync again, let's re-sync right away if (value) { @@ -168,11 +225,11 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Sync directory') .setDesc('Directory to sync voice notes') - .addText((text) => + .addText((text: TextComponent) => text .setPlaceholder('voicenotes') .setValue(`${this.plugin.settings.syncDirectory}`) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.syncDirectory = value; await this.plugin.saveSettings(); }) @@ -181,11 +238,11 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Add a tag to todos') .setDesc('When syncing a note add an optional tag to the todo') - .addText((text) => + .addText((text: TextComponent) => text .setPlaceholder('TODO') .setValue(this.plugin.settings.todoTag) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.todoTag = value; await this.plugin.saveSettings(); }) @@ -194,8 +251,8 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Download audio') .setDesc('Store and download the audio associated with the transcript') - .addToggle((toggle) => - toggle.setValue(this.plugin.settings.downloadAudio).onChange(async (value) => { + .addToggle((toggle: ToggleComponent) => + toggle.setValue(this.plugin.settings.downloadAudio).onChange(async (value: boolean) => { this.plugin.settings.downloadAudio = Boolean(value); await this.plugin.saveSettings(); }) @@ -204,11 +261,11 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Date Format') .setDesc('Format of the date used in the templates below (moment.js format)') - .addText((text) => + .addText((text: TextComponent) => text .setPlaceholder('YYYY-MM-DD') .setValue(this.plugin.settings.dateFormat) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.dateFormat = value; await this.plugin.saveSettings(); }) @@ -217,11 +274,11 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Filename Date Format') .setDesc('Format of the date used to replace {{date}} if in Filename Template below (moment.js format)') - .addText((text) => + .addText((text: TextComponent) => text .setPlaceholder('YYYY-MM-DD') .setValue(this.plugin.settings.filenameDateFormat) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.filenameDateFormat = value; await this.plugin.saveSettings(); }) @@ -230,11 +287,11 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Filename Template') .setDesc('Template for the filename of synced notes. Available variables: {{date}}, {{title}}') - .addText((text) => + .addText((text: TextComponent) => text .setPlaceholder('{{date}} {{title}}') .setValue(this.plugin.settings.filenameTemplate) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.filenameTemplate = value; await this.plugin.saveSettings(); }) @@ -245,11 +302,11 @@ export class VoiceNotesSettingTab extends PluginSettingTab { .setDesc( 'Frontmatter / properties template for notes. recording_id and the three dashes before and after properties automatically added' ) - .addTextArea((text) => { + .addTextArea((text: TextAreaComponent) => { text .setPlaceholder(this.plugin.settings.frontmatterTemplate) .setValue(this.plugin.settings.frontmatterTemplate) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.frontmatterTemplate = value; await this.plugin.saveSettings(); }); @@ -265,11 +322,11 @@ export class VoiceNotesSettingTab extends PluginSettingTab { .setDesc( 'Template for synced notes. Available variables: {{recording_id}}, {{title}}, {{date}}, {{duration}}, {{created_at}}, {{updated_at}}, {{tags}}, {{transcript}}, {{embedded_audio_link}}, {{audio_filename}}, {{summary}}, {{tidy}}, {{points}}, {{todo}}, {{email}}, {{tweet}}, {{blog}}, {{custom}}, {{parent_note}} and {{related_notes}}' ) - .addTextArea((text) => { + .addTextArea((text: TextAreaComponent) => { text .setPlaceholder(this.plugin.settings.noteTemplate) .setValue(this.plugin.settings.noteTemplate) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.noteTemplate = value; await this.plugin.saveSettings(); }); @@ -283,12 +340,12 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Exclude Tags') .setDesc('Comma-separated list of tags to exclude from syncing') - .addText((text) => + .addText((text: TextComponent) => text .setPlaceholder('archive, trash') .setValue(this.plugin.settings.excludeTags.join(', ')) - .onChange(async (value) => { - this.plugin.settings.excludeTags = value.split(',').map((folder) => folder.trim()); + .onChange(async (value: string) => { + this.plugin.settings.excludeTags = value.split(',').map((folder: string) => folder.trim()); await this.plugin.saveSettings(); }) ); diff --git a/types.ts b/types.ts index 3c1eae7..4ffee30 100644 --- a/types.ts +++ b/types.ts @@ -5,7 +5,6 @@ export interface VoiceNotesPluginSettings { token?: string; username?: string; - password?: string; automaticSync: boolean; syncTimeout?: number; downloadAudio?: boolean; diff --git a/voicenotes-api.ts b/voicenotes-api.ts index 925cbd3..4a6019b 100644 --- a/voicenotes-api.ts +++ b/voicenotes-api.ts @@ -1,131 +1,174 @@ -import { DataAdapter, requestUrl } from 'obsidian'; +import { DataAdapter, requestUrl, RequestUrlParam } from 'obsidian'; import { User, VoiceNoteRecordings, VoiceNoteSignedUrl } from './types'; const VOICENOTES_API_URL = 'https://api.voicenotes.com/api'; export default class VoiceNotesApi { - token!: string; + token?: string; + username?: string; + password?: string; - constructor(options: { token?: string }) { + constructor(options: { token?: string; username?: string; password?: string }) { if (options.token) { this.token = options.token; } + if (options.username) { + this.username = options.username; + } + if (options.password) { + this.password = options.password; + } } - setToken(token: string): void { + setToken(token?: string): void { this.token = token; } - async login(options: { username?: string; password?: string }): Promise { - if (options.username && options.password) { + setCredentials(username?: string, password?: string): void { + this.username = username; + this.password = password; + } + + async login(options: { username?: string; password?: string }): Promise { + const username = options.username || this.username; + const password = options.password || this.password; + + if (username && password) { const loginUrl = `${VOICENOTES_API_URL}/auth/login`; console.log(`loginUrl: ${loginUrl}`); - const response = await requestUrl({ - url: loginUrl, - method: 'POST', - contentType: 'application/json', - body: JSON.stringify({ - email: options.username, - password: options.password, - }), - }); - - if (response.status === 200) { - this.token = response.json.authorisation.token; - return this.token; + try { + const response = await requestUrl({ + url: loginUrl, + method: 'POST', + contentType: 'application/json', + body: JSON.stringify({ + email: username, + password: password, + }), + }); + + if (response.status === 200) { + this.token = response.json.authorisation.token; + // Securely store/update credentials if needed, or ensure they are passed for re-login + this.username = username; + // Password should ideally not be stored long-term here directly + // For re-login, it's passed or retrieved securely + return this.token; + } + } catch (error) { + console.error('Login failed:', error); + return null; } - return null; } return null; } - async getSignedUrl(recordingId: number): Promise { + private async _requestWithRetry(requestParams: RequestUrlParam, attemptRelogin = true): Promise { + if (!this.token && attemptRelogin) { // If no token, try to login first + if (this.username && this.password) { + console.log('No token found, attempting to login before request.'); + await this.login({}); // Attempt login with stored credentials + if (!this.token) { + console.error('Failed to login before request.'); + throw new Error('Authentication required and login failed.'); + } + } else { + console.error('Authentication required, but no credentials available for login.'); + throw new Error('Authentication required, credentials not available.'); + } + } + + const paramsWithAuth = { ...requestParams }; if (this.token) { - const data = await requestUrl({ - url: `${VOICENOTES_API_URL}/recordings/${recordingId}/signed-url`, - headers: { - Authorization: `Bearer ${this.token}`, - }, - }); - return data.json as VoiceNoteSignedUrl; + paramsWithAuth.headers = { + ...paramsWithAuth.headers, + Authorization: `Bearer ${this.token}`, + }; } - return null; + + try { + const response = await requestUrl(paramsWithAuth); + return response.json as T; + } catch (error: any) { + if (error.status === 401 && attemptRelogin) { + console.log('Request failed with 401, attempting re-login.'); + if (this.username && this.password) { + const newToken = await this.login({ username: this.username, password: this.password }); + if (newToken) { + console.log('Re-login successful, retrying original request.'); + const retriedParamsWithAuth = { ...requestParams }; + retriedParamsWithAuth.headers = { + ...retriedParamsWithAuth.headers, + Authorization: `Bearer ${this.token}`, + }; + const retryResponse = await requestUrl(retriedParamsWithAuth); + return retryResponse.json as T; + } else { + console.error('Re-login failed.'); + this.token = undefined; + throw error; + } + } else { + console.warn('Cannot attempt re-login: username or password not available.'); + this.token = undefined; + throw error; + } + } else if (error.status === 429) { + const retryAfterSeconds = parseInt(error.headers?.get('retry-after')) || 5; + console.warn(`Rate limited by Voicenotes API. Retrying request to ${paramsWithAuth.url} in ${retryAfterSeconds} seconds. Headers: ${JSON.stringify(error.headers)}`); + await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000)); + console.log(`Retrying request to ${paramsWithAuth.url} after rate limit delay.`); + return this._requestWithRetry(requestParams, false); + } else { + throw error; + } + } + } + + async getSignedUrl(recordingId: number): Promise { + return this._requestWithRetry({ + url: `${VOICENOTES_API_URL}/recordings/${recordingId}/signed-url`, + }); } async downloadFile(fs: DataAdapter, url: string, outputLocationPath: string) { + // This request doesn't need auth usually, but if it starts to, it would need _requestWithRetry too const response = await requestUrl({ url, }); const buffer = Buffer.from(response.arrayBuffer); - await fs.writeBinary(outputLocationPath, buffer); } async deleteRecording(recordingId: number): Promise { - if (this.token) { - const data = await requestUrl({ + try { + await this._requestWithRetry({ // The response might not be JSON / can be empty url: `${VOICENOTES_API_URL}/recordings/${recordingId}`, - headers: { - Authorization: `Bearer ${this.token}`, - }, method: 'DELETE', - }); - - return data.status === 200; + }, true); // attemptRelogin = true + return true; // If request succeeds (or retries successfully), assume deletion was successful + } catch (error) { + console.error(`Failed to delete recording ${recordingId}:`, error); + return false; } - - return false; } - async getRecordingsFromLink(link: string): Promise { - if (this.token) { - const data = await requestUrl({ - url: link, - headers: { - Authorization: `Bearer ${this.token}`, - }, - }); - - return data.json as VoiceNoteRecordings; - } - return null; + async getRecordingsFromLink(link: string): Promise { + return this._requestWithRetry({ + url: link, + }); } - async getRecordings(): Promise { - if (this.token) { - try { - const data = await requestUrl({ - url: `${VOICENOTES_API_URL}/recordings`, - headers: { - Authorization: `Bearer ${this.token}`, - }, - }); - return data.json as VoiceNoteRecordings; - } catch (error) { - if (error.status === 401) { - this.token = undefined; - throw error; // rethrow so we can catch in caller - } - } - } - return null; + async getRecordings(): Promise { + return this._requestWithRetry({ + url: `${VOICENOTES_API_URL}/recordings`, + }); } - async getUserInfo(): Promise { - if (this.token) { - try { - const data = await requestUrl({ - url: `${VOICENOTES_API_URL}/auth/me`, - headers: { - Authorization: `Bearer ${this.token}`, - }, - }); - return data.json; - } catch (error) { - console.error(error); - } - } - return null; + async getUserInfo(): Promise { + return this._requestWithRetry({ + url: `${VOICENOTES_API_URL}/auth/me`, + }); } } From 57aaeb2e367a57be0a8084d0a5a39185fd02869a Mon Sep 17 00:00:00 2001 From: Henri Jamet <42291955+hjamet@users.noreply.github.com> Date: Wed, 21 May 2025 11:28:36 +0200 Subject: [PATCH 2/4] Add option to show image descriptions in VoiceNotes plugin settings Introduced a new setting to enable or disable the display of image descriptions below attachments. Updated the main functionality to conditionally include descriptions in the generated markdown. Ensured that files are only downloaded if they do not already exist in the vault. --- main.ts | 14 +++++++++++--- settings.ts | 10 ++++++++++ types.ts | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/main.ts b/main.ts index 93ed264..793498d 100644 --- a/main.ts +++ b/main.ts @@ -121,6 +121,7 @@ Date: {{ date }} `, excludeTags: [], dateFormat: 'YYYY-MM-DD', + showImageDescriptions: true, }; export default class VoiceNotesPlugin extends Plugin { @@ -351,12 +352,19 @@ export default class VoiceNotesPlugin extends Plugin { } else if (data.type === 2) { const filename = getFilenameFromUrl(data.url); const attachmentPath = normalizePath(`${attachmentsPath}/${filename}`); - await this.vnApi.downloadFile(this.fs, data.url, attachmentPath); - return `- ![[${filename}]]`; + if (!(await this.app.vault.adapter.exists(attachmentPath))) { + await this.vnApi.downloadFile(this.fs, data.url, attachmentPath); + } + let attachmentMarkdown = `- ![[${filename}]]`; + if (this.settings.showImageDescriptions && data.description && data.description.trim() !== '') { + attachmentMarkdown += `\n *${data.description.trim()}*`; + } + return attachmentMarkdown; } + return ''; }) ) - ).join('\n'); + ).filter(content => content && content.trim() !== '').join('\n'); } // Prepare context for Jinja template diff --git a/settings.ts b/settings.ts index 6f38748..e8e2e6f 100644 --- a/settings.ts +++ b/settings.ts @@ -258,6 +258,16 @@ export class VoiceNotesSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName('Show Image Descriptions') + .setDesc('If enabled, show the description below an image attachment (in italics).') + .addToggle((toggle: ToggleComponent) => + toggle.setValue(this.plugin.settings.showImageDescriptions).onChange(async (value: boolean) => { + this.plugin.settings.showImageDescriptions = value; + await this.plugin.saveSettings(); + }) + ); + new Setting(containerEl) .setName('Date Format') .setDesc('Format of the date used in the templates below (moment.js format)') diff --git a/types.ts b/types.ts index 4ffee30..7020427 100644 --- a/types.ts +++ b/types.ts @@ -19,6 +19,7 @@ export interface VoiceNotesPluginSettings { filenameTemplate: string; excludeTags: string[]; dateFormat: string; + showImageDescriptions: boolean; } export interface UserSettings { From a2e3175d21990cd8ba831d15e8f50a026a6d9191 Mon Sep 17 00:00:00 2001 From: Henri Jamet <42291955+hjamet@users.noreply.github.com> Date: Wed, 21 May 2025 11:36:40 +0200 Subject: [PATCH 3/4] Add manual entries feature to VoiceNotes plugin and update note template variables Implemented a new section for manual entries in the generated notes. Updated the handling of attachments to improve markdown formatting and prevent conflicts with variable names. Enhanced the note template to include the new {{entries}} variable for better customization. --- main.ts | 42 +++++++++++++++++++++++++++++------------- settings.ts | 2 +- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/main.ts b/main.ts index 793498d..906960a 100644 --- a/main.ts +++ b/main.ts @@ -43,6 +43,12 @@ Date: {{ date }} {{ attachments }} {% endif %} +{% if entries %} +## Manual Entries + +{{ entries }} +{% endif %} + {% if tidy %} ## Tidy Transcript @@ -338,35 +344,44 @@ export default class VoiceNotesPlugin extends Plugin { } // Handle attachments - let attachments = ''; + let attachmentsMarkdown = ''; + const manualEntries: string[] = []; // Array to store type 3 entry descriptions + if (recording.attachments && recording.attachments.length > 0) { const attachmentsPath = normalizePath(`${voiceNotesDir}/attachments`); if (!(await this.app.vault.adapter.exists(attachmentsPath))) { await this.app.vault.createFolder(attachmentsPath); } - attachments = ( + attachmentsMarkdown = ( // Renamed from 'attachments' to avoid conflict with context key await Promise.all( recording.attachments.map(async (data: any) => { - if (data.type === 1) { + if (data.type === 1) { // Text description attachment return `- ${data.description}`; - } else if (data.type === 2) { + } else if (data.type === 2) { // Image attachment const filename = getFilenameFromUrl(data.url); - const attachmentPath = normalizePath(`${attachmentsPath}/${filename}`); - if (!(await this.app.vault.adapter.exists(attachmentPath))) { - await this.vnApi.downloadFile(this.fs, data.url, attachmentPath); + const attachmentPathLocal = normalizePath(`${attachmentsPath}/${filename}`); // Renamed to avoid conflict + if (!(await this.app.vault.adapter.exists(attachmentPathLocal))) { + await this.vnApi.downloadFile(this.fs, data.url, attachmentPathLocal); } - let attachmentMarkdown = `- ![[${filename}]]`; + let imageMarkdown = `- ![[${filename}]]`; if (this.settings.showImageDescriptions && data.description && data.description.trim() !== '') { - attachmentMarkdown += `\n *${data.description.trim()}*`; + imageMarkdown += `\n *${data.description.trim()}*`; + } + return imageMarkdown; + } else if (data.type === 3) { // Manual entry/note + if (data.description && data.description.trim() !== '') { + manualEntries.push(data.description.trim()); } - return attachmentMarkdown; + return null; // These won't be part of the {{attachments}} variable directly } - return ''; + return null; // Return null for unhandled types or if no direct markdown output }) ) - ).filter(content => content && content.trim() !== '').join('\n'); + ).filter(content => content !== null).join('\n'); // Filter out nulls before joining } + const formattedEntries = manualEntries.length > 0 ? manualEntries.join('\n') : null; + // Prepare context for Jinja template const formattedPoints = points ? points.content.data.map((data: string) => `- ${data}`).join('\n') : null; const formattedTodos = todo @@ -416,8 +431,9 @@ export default class VoiceNotesPlugin extends Plugin { ) .join('\n') : null, - attachments: attachments, + attachments: attachmentsMarkdown ? attachmentsMarkdown : null, // Use the processed markdown string for attachments parent_note: isSubnote ? `[[${parentTitle}]]` : null, + entries: formattedEntries, // Add the new entries variable }; // Render the template using Jinja diff --git a/settings.ts b/settings.ts index e8e2e6f..660f841 100644 --- a/settings.ts +++ b/settings.ts @@ -330,7 +330,7 @@ export class VoiceNotesSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Note Template') .setDesc( - 'Template for synced notes. Available variables: {{recording_id}}, {{title}}, {{date}}, {{duration}}, {{created_at}}, {{updated_at}}, {{tags}}, {{transcript}}, {{embedded_audio_link}}, {{audio_filename}}, {{summary}}, {{tidy}}, {{points}}, {{todo}}, {{email}}, {{tweet}}, {{blog}}, {{custom}}, {{parent_note}} and {{related_notes}}' + 'Template for synced notes. Available variables: {{recording_id}}, {{title}}, {{date}}, {{duration}}, {{created_at}}, {{updated_at}}, {{tags}}, {{transcript}}, {{embedded_audio_link}}, {{audio_filename}}, {{summary}}, {{tidy}}, {{points}}, {{todo}}, {{email}}, {{tweet}}, {{blog}}, {{custom}}, {{parent_note}}, {{related_notes}} and {{entries}}' ) .addTextArea((text: TextAreaComponent) => { text From 1ade7d692a62d8ec53a4f77d5eead4f35ac7657f Mon Sep 17 00:00:00 2001 From: Henri Jamet <42291955+hjamet@users.noreply.github.com> Date: Wed, 21 May 2025 12:18:15 +0200 Subject: [PATCH 4/4] Improve error handling for rate limiting in VoiceNotes API Updated the retry logic for handling 429 status errors by checking both 'retry-after' and 'Retry-After' headers. Set a default retry duration and enhanced logging for better visibility during rate limit scenarios. --- voicenotes-api.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/voicenotes-api.ts b/voicenotes-api.ts index 4a6019b..d43a33d 100644 --- a/voicenotes-api.ts +++ b/voicenotes-api.ts @@ -115,7 +115,13 @@ export default class VoiceNotesApi { throw error; } } else if (error.status === 429) { - const retryAfterSeconds = parseInt(error.headers?.get('retry-after')) || 5; + let retryAfterSeconds = 5; // Default value + if (error.headers) { + const retryAfterHeader = error.headers['retry-after'] || error.headers['Retry-After']; + if (retryAfterHeader) { + retryAfterSeconds = parseInt(retryAfterHeader) || 5; + } + } console.warn(`Rate limited by Voicenotes API. Retrying request to ${paramsWithAuth.url} in ${retryAfterSeconds} seconds. Headers: ${JSON.stringify(error.headers)}`); await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000)); console.log(`Retrying request to ${paramsWithAuth.url} after rate limit delay.`);