diff --git a/main.ts b/main.ts index 2882d49..906960a 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}} @@ -42,6 +43,12 @@ Date: {{ date }} {{ attachments }} {% endif %} +{% if entries %} +## Manual Entries + +{{ entries }} +{% endif %} + {% if tidy %} ## Tidy Transcript @@ -120,6 +127,7 @@ Date: {{ date }} `, excludeTags: [], dateFormat: 'YYYY-MM-DD', + showImageDescriptions: true, }; export default class VoiceNotesPlugin extends Plugin { @@ -139,6 +147,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,41 +332,62 @@ 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 - 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}`); - await this.vnApi.downloadFile(this.fs, data.url, attachmentPath); - return `- ![[${filename}]]`; + 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 imageMarkdown = `- ![[${filename}]]`; + if (this.settings.showImageDescriptions && data.description && 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 null; // These won't be part of the {{attachments}} variable directly } + return null; // Return null for unhandled types or if no direct markdown output }) ) - ).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 ? 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,23 +416,24 @@ 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, + 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 @@ -426,7 +460,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 +495,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 +511,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..660f841 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(); + + 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.'); - const userInfo = await this.vnApi.getUserInfo(); + // 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) => text.setPlaceholder(userInfo.name).setDisabled(true)); + 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,21 +251,31 @@ 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(); }) ); + 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)') - .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 +284,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 +297,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 +312,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(); }); @@ -263,13 +330,13 @@ 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) => { + .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 +350,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..7020427 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; @@ -20,6 +19,7 @@ export interface VoiceNotesPluginSettings { filenameTemplate: string; excludeTags: string[]; dateFormat: string; + showImageDescriptions: boolean; } export interface UserSettings { diff --git a/voicenotes-api.ts b/voicenotes-api.ts index 925cbd3..d43a33d 100644 --- a/voicenotes-api.ts +++ b/voicenotes-api.ts @@ -1,131 +1,180 @@ -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) { + 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.`); + 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`, + }); } }