diff --git a/src/formatting_modal.ts b/src/formatting_modal.ts index 7120168..8f28843 100644 --- a/src/formatting_modal.ts +++ b/src/formatting_modal.ts @@ -1,4 +1,4 @@ -import { App, Modal, Notice } from 'obsidian'; +import { App, Modal, Notice, Platform } from 'obsidian'; import type SnipdPlugin from './main'; import { DEFAULT_EPISODE_TEMPLATE, DEFAULT_SNIP_TEMPLATE, DEFAULT_EPISODE_FILE_NAME_TEMPLATE } from './types'; @@ -23,9 +23,6 @@ export class FormattingConfigModal extends Modal { contentEl.empty(); const scrollableContent = contentEl.createDiv({ cls: 'snipd-modal-scrollable' }); - scrollableContent.style.maxHeight = 'calc(80vh - 100px)'; - scrollableContent.style.overflowY = 'auto'; - scrollableContent.style.marginBottom = '16px'; scrollableContent.createEl('h2', { text: 'Custom formatting' }); scrollableContent.createEl('p', { @@ -33,9 +30,7 @@ export class FormattingConfigModal extends Modal { cls: 'setting-item-description' }); - const syntaxDesc = scrollableContent.createDiv({ cls: 'setting-item-description' }); - syntaxDesc.style.marginBottom = '16px'; - syntaxDesc.style.fontSize = '0.9em'; + const syntaxDesc = scrollableContent.createDiv({ cls: 'setting-item-description snipd-syntax-description' }); syntaxDesc.createEl('strong', { text: 'Guide: ' }); syntaxDesc.appendText('Use '); syntaxDesc.createEl('code', { text: '{{variable}}' }); @@ -58,11 +53,21 @@ export class FormattingConfigModal extends Modal { ]; fileNameVars.forEach((varName, index) => { const varSpan = fileNameVarsDesc.createSpan({ cls: 'snipd-template-variable', text: varName }); - varSpan.style.cursor = 'pointer'; - varSpan.style.textDecoration = 'underline'; varSpan.addEventListener('click', () => { - navigator.clipboard.writeText(varName); - new Notice(`Copied ${varName} to clipboard`); + void (async () => { + if (Platform.isDesktopApp) { + await globalThis.navigator.clipboard.writeText(varName); + } else { + const textArea = globalThis.document.createElement('textarea'); + textArea.value = varName; + globalThis.document.body.appendChild(textArea); + textArea.select(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + globalThis.document.execCommand('copy'); + globalThis.document.body.removeChild(textArea); + } + new Notice(`Copied ${varName} to clipboard`); + })(); }); if (index < fileNameVars.length - 1) { fileNameVarsDesc.appendText(', '); @@ -74,9 +79,6 @@ export class FormattingConfigModal extends Modal { type: 'text', }); fileNameInput.value = this.tempEpisodeFileNameTemplate; - fileNameInput.style.width = '100%'; - fileNameInput.style.fontFamily = 'monospace'; - fileNameInput.style.padding = '8px'; fileNameInput.addEventListener('input', () => { this.tempEpisodeFileNameTemplate = fileNameInput.value; }); @@ -103,11 +105,21 @@ export class FormattingConfigModal extends Modal { ]; episodeVars.forEach((varName, index) => { const varSpan = episodeVarsDesc.createSpan({ cls: 'snipd-template-variable', text: varName }); - varSpan.style.cursor = 'pointer'; - varSpan.style.textDecoration = 'underline'; varSpan.addEventListener('click', () => { - navigator.clipboard.writeText(varName); - new Notice(`Copied ${varName} to clipboard`); + void (async () => { + if (Platform.isDesktopApp) { + await globalThis.navigator.clipboard.writeText(varName); + } else { + const textArea = globalThis.document.createElement('textarea'); + textArea.value = varName; + globalThis.document.body.appendChild(textArea); + textArea.select(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + globalThis.document.execCommand('copy'); + globalThis.document.body.removeChild(textArea); + } + new Notice(`Copied ${varName} to clipboard`); + })(); }); if (index < episodeVars.length - 1) { episodeVarsDesc.appendText(', '); @@ -119,8 +131,6 @@ export class FormattingConfigModal extends Modal { }); episodeTextarea.value = this.tempEpisodeTemplate; episodeTextarea.rows = 10; - episodeTextarea.style.width = '100%'; - episodeTextarea.style.fontFamily = 'monospace'; episodeTextarea.addEventListener('input', () => { this.tempEpisodeTemplate = episodeTextarea.value; }); @@ -144,11 +154,21 @@ export class FormattingConfigModal extends Modal { ]; snipVars.forEach((varName, index) => { const varSpan = snipVarsDesc.createSpan({ cls: 'snipd-template-variable', text: varName }); - varSpan.style.cursor = 'pointer'; - varSpan.style.textDecoration = 'underline'; varSpan.addEventListener('click', () => { - navigator.clipboard.writeText(varName); - new Notice(`Copied ${varName} to clipboard`); + void (async () => { + if (Platform.isDesktopApp) { + await globalThis.navigator.clipboard.writeText(varName); + } else { + const textArea = globalThis.document.createElement('textarea'); + textArea.value = varName; + globalThis.document.body.appendChild(textArea); + textArea.select(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + globalThis.document.execCommand('copy'); + globalThis.document.body.removeChild(textArea); + } + new Notice(`Copied ${varName} to clipboard`); + })(); }); if (index < snipVars.length - 1) { snipVarsDesc.appendText(', '); @@ -160,19 +180,11 @@ export class FormattingConfigModal extends Modal { }); snipTextarea.value = this.tempSnipTemplate; snipTextarea.rows = 10; - snipTextarea.style.width = '100%'; - snipTextarea.style.fontFamily = 'monospace'; snipTextarea.addEventListener('input', () => { this.tempSnipTemplate = snipTextarea.value; }); const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' }); - buttonContainer.style.display = 'flex'; - buttonContainer.style.justifyContent = 'flex-end'; - buttonContainer.style.gap = '8px'; - buttonContainer.style.paddingTop = '16px'; - buttonContainer.style.borderTop = '1px solid var(--background-modifier-border)'; - buttonContainer.style.marginTop = '0'; const resetButton = buttonContainer.createEl('button', { text: 'Reset to default' }); resetButton.addEventListener('click', () => { @@ -193,22 +205,24 @@ export class FormattingConfigModal extends Modal { text: 'Save', cls: 'mod-cta' }); - saveButton.addEventListener('click', async () => { - this.plugin.settings.episodeFileNameTemplate = - this.tempEpisodeFileNameTemplate === DEFAULT_EPISODE_FILE_NAME_TEMPLATE - ? null - : this.tempEpisodeFileNameTemplate; - this.plugin.settings.episodeTemplate = - this.tempEpisodeTemplate === DEFAULT_EPISODE_TEMPLATE - ? null - : this.tempEpisodeTemplate; - this.plugin.settings.snipTemplate = - this.tempSnipTemplate === DEFAULT_SNIP_TEMPLATE - ? null - : this.tempSnipTemplate; - await this.plugin.saveSettings(); - this.onSave(); - this.close(); + saveButton.addEventListener('click', () => { + void (async () => { + this.plugin.settings.episodeFileNameTemplate = + this.tempEpisodeFileNameTemplate === DEFAULT_EPISODE_FILE_NAME_TEMPLATE + ? null + : this.tempEpisodeFileNameTemplate; + this.plugin.settings.episodeTemplate = + this.tempEpisodeTemplate === DEFAULT_EPISODE_TEMPLATE + ? null + : this.tempEpisodeTemplate; + this.plugin.settings.snipTemplate = + this.tempSnipTemplate === DEFAULT_SNIP_TEMPLATE + ? null + : this.tempSnipTemplate; + await this.plugin.saveSettings(); + this.onSave(); + this.close(); + })(); }); } diff --git a/src/main.ts b/src/main.ts index 2f9545d..7bb5076 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,11 @@ import { addIcon, - App, DataAdapter, normalizePath, Notice, Plugin, - TFile + TFile, + requestUrl } from 'obsidian'; // @ts-ignore import * as zip from "@zip.js/zip.js"; @@ -97,9 +97,11 @@ export default class SnipdPlugin extends Plugin { } private clearStatusBarPersistentMessageAfterDelay(delayMs: number): void { - setTimeout(() => { - this.clearStatusBarPersistentMessage(); - }, delayMs); + this.registerInterval( + globalThis.window.setTimeout(() => { + this.clearStatusBarPersistentMessage(); + }, delayMs) + ); } async checkSnipdDirectoryExists(): Promise { @@ -207,28 +209,23 @@ export default class SnipdPlugin extends Plugin { try { debugLog(`Snipd plugin: fetching metadata from ${url}`); this.setStatusBarPersistentMessage("Fetching metadata..."); - response = await fetch(url, { + response = await requestUrl({ + url: url, method: 'GET', headers: { 'Authorization': `Bearer ${this.settings.apiKey}`, }, - signal: this.syncAbortController?.signal, }); debugLog(`Snipd plugin: metadata response status: ${response.status}`); } catch (e) { - if (e instanceof Error && e.name === 'AbortError') { - debugLog("Snipd plugin: fetch aborted by user"); - this.clearStatusBarPersistentMessage(); - return null; - } - console.error("Snipd plugin: fetch failed in syncSnipd: ", e); + debugLog("Snipd plugin: request failed in syncSnipd: ", e); const errorMsg = "Sync failed: unable to connect to server." + (isDev() ? ` Detail: ${e}` : ""); await this.handleSyncError(errorMsg); return null; } - if (response && response.ok) { - const metadata = await response.json(); + if (response && response.status >= 200 && response.status < 300) { + const metadata = response.json as FetchExportMetadataResponse; await this.saveMetadataToFile(metadata); if (debugFolderPath) { @@ -258,9 +255,9 @@ export default class SnipdPlugin extends Plugin { return metadata; } else { - console.error("Snipd plugin: bad response in syncSnipd: ", response); - const statusCode = response ? response.status : ""; - const errorMsg = `Sync failed${statusCode ? ` (${statusCode})` : ""}` + (isDev() && response ? ` Detail: ${response.statusText}` : ""); + debugLog("Snipd plugin: bad response in syncSnipd: ", response); + const statusCode = response ? response.status : 0; + const errorMsg = `Sync failed${statusCode ? ` (${statusCode})` : ""}` + (isDev() && response ? ` Detail: ${response.status}` : ""); await this.handleSyncError(errorMsg); return null; } @@ -348,29 +345,25 @@ export default class SnipdPlugin extends Plugin { let response; try { const requestBody = this.buildBatchRequestBody(episodeIds); - response = await fetch(`${API_BASE_URL}/obsidian/export-episode-snips`, { + response = await requestUrl({ + url: `${API_BASE_URL}/obsidian/export-episode-snips`, method: 'POST', headers: { 'Authorization': `Bearer ${this.settings.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), - signal: this.syncAbortController?.signal, }); } catch (e) { - if (e instanceof Error && e.name === 'AbortError') { - debugLog("Snipd plugin: fetch aborted by user"); - this.clearStatusBarPersistentMessage(); - return null; - } - console.error("Snipd plugin: fetch failed for batch: ", e); + debugLog("Snipd plugin: request failed for batch: ", e); const errorMsg = "Sync failed: unable to connect to server." + (isDev() ? ` Detail: ${e}` : ""); await this.handleSyncError(errorMsg); return null; } - if (response && response.ok) { - const blob = await response.blob(); + if (response && response.status >= 200 && response.status < 300) { + const arrayBuffer = response.arrayBuffer; + const blob = new Blob([arrayBuffer]); if (debugFolderPath) { const batchFileName = `batch_${batchIndex}_${Date.now()}.zip`; @@ -392,9 +385,9 @@ export default class SnipdPlugin extends Plugin { return stats; } else { - console.error("Snipd plugin: bad response for batch: ", response); - const statusCode = response ? response.status : ""; - const errorMsg = `Sync failed at batch ${batchIndex + 1}${statusCode ? ` (${statusCode})` : ""}` + (isDev() && response ? ` Detail: ${response.statusText}` : ""); + debugLog("Snipd plugin: bad response for batch: ", response); + const statusCode = response ? response.status : 0; + const errorMsg = `Sync failed at batch ${batchIndex + 1}${statusCode ? ` (${statusCode})` : ""}` + (isDev() && response ? ` Detail: ${response.status}` : ""); await this.handleSyncError(errorMsg); return null; } @@ -427,7 +420,7 @@ export default class SnipdPlugin extends Plugin { return { episodeCount: totalEpisodes, snipCount: totalSnips }; } catch (e) { - console.error("Snipd plugin: error processing batches: ", e); + debugLog("Snipd plugin: error processing batches: ", e); const errorMsg = "Sync failed: error processing data." + (isDev() ? ` Detail: ${e}` : ""); await this.handleSyncError(errorMsg); return null; @@ -499,7 +492,8 @@ export default class SnipdPlugin extends Plugin { if (this.settings.onlyEditedSnips) { url += '?only_edited_snips=true'; } - response = await fetch(url, { + response = await requestUrl({ + url: url, method: 'GET', headers: { 'Authorization': `Bearer ${this.settings.apiKey}`, @@ -507,7 +501,7 @@ export default class SnipdPlugin extends Plugin { }); debugLog(`Snipd plugin: test metadata response status: ${response.status}`); } catch (e) { - console.error("Snipd plugin: fetch failed in testSyncRandomEpisodes: ", e); + debugLog("Snipd plugin: request failed in testSyncRandomEpisodes: ", e); const errorMsg = "Test sync failed: unable to connect to server." + (isDev() ? ` Detail: ${e}` : ""); this.settings.isTestSyncing = false; await this.saveSettings(); @@ -519,8 +513,8 @@ export default class SnipdPlugin extends Plugin { return; } - if (response && response.ok) { - const metadata: FetchExportMetadataResponse = await response.json(); + if (response && response.status >= 200 && response.status < 300) { + const metadata = response.json as FetchExportMetadataResponse; if (debugFolderPath) { await createDirForFile(`${debugFolderPath}/test_metadata.json`, this.app.vault.adapter); @@ -582,7 +576,8 @@ export default class SnipdPlugin extends Plugin { exportRequestBody.only_edited_snips = true; } - exportResponse = await fetch(`${API_BASE_URL}/obsidian/export-episode-snips`, { + exportResponse = await requestUrl({ + url: `${API_BASE_URL}/obsidian/export-episode-snips`, method: 'POST', headers: { 'Authorization': `Bearer ${this.settings.apiKey}`, @@ -591,7 +586,7 @@ export default class SnipdPlugin extends Plugin { body: JSON.stringify(exportRequestBody), }); } catch (e) { - console.error("Snipd plugin: export fetch failed: ", e); + debugLog("Snipd plugin: export request failed: ", e); const errorMsg = "Test sync failed: unable to connect to server." + (isDev() ? ` Detail: ${e}` : ""); this.settings.isTestSyncing = false; await this.saveSettings(); @@ -603,8 +598,9 @@ export default class SnipdPlugin extends Plugin { return; } - if (exportResponse && exportResponse.ok) { - const blob = await exportResponse.blob(); + if (exportResponse && exportResponse.status >= 200 && exportResponse.status < 300) { + const arrayBuffer = exportResponse.arrayBuffer; + const blob = new Blob([arrayBuffer]); if (debugFolderPath) { const testExportFileName = `test_export_${Date.now()}.zip`; @@ -639,9 +635,9 @@ export default class SnipdPlugin extends Plugin { this.setStatusBarPersistentMessage(`Test sync completed (${stats.episodeCount} episodes, ${stats.snipCount} snips)`); this.clearStatusBarPersistentMessageAfterDelay(3000); } else { - console.error("Snipd plugin: bad response for test export: ", exportResponse); - const statusCode = exportResponse ? exportResponse.status : ""; - const errorMsg = `Test sync failed${statusCode ? ` (${statusCode})` : ""}` + (isDev() && exportResponse ? ` Detail: ${exportResponse.statusText}` : ""); + debugLog("Snipd plugin: bad response for test export: ", exportResponse); + const statusCode = exportResponse ? exportResponse.status : 0; + const errorMsg = `Test sync failed${statusCode ? ` (${statusCode})` : ""}` + (isDev() && exportResponse ? ` Detail: ${exportResponse.status}` : ""); this.settings.isTestSyncing = false; await this.saveSettings(); if (this.settingsTab) { @@ -651,9 +647,9 @@ export default class SnipdPlugin extends Plugin { this.clearStatusBarPersistentMessage(); } } else { - console.error("Snipd plugin: bad response in testSyncRandomEpisodes: ", response); - const statusCode = response ? response.status : ""; - const errorMsg = `Test sync failed${statusCode ? ` (${statusCode})` : ""}` + (isDev() && response ? ` Detail: ${response.statusText}` : ""); + debugLog("Snipd plugin: bad response in testSyncRandomEpisodes: ", response); + const statusCode = response ? response.status : 0; + const errorMsg = `Test sync failed${statusCode ? ` (${statusCode})` : ""}` + (isDev() && response ? ` Detail: ${response.status}` : ""); this.settings.isTestSyncing = false; await this.saveSettings(); if (this.settingsTab) { @@ -675,16 +671,18 @@ export default class SnipdPlugin extends Plugin { const episodeFiles: Map = new Map(); for (const entry of entries) { - if (entry.directory) { + // @ts-ignore - zip.js types are incomplete + const zipEntry: zip.Entry = entry; + if (zipEntry.directory) { continue; } // @ts-ignore - const fileContent = await entry.getData(new zip.TextWriter()); + const fileContent = await zipEntry.getData(new zip.TextWriter()); - if (entry.filename === 'metadata.json') { - metadata = JSON.parse(fileContent); - } else if (entry.filename.startsWith('episodes/')) { - const filename = entry.filename.replace('episodes/', ''); + if (zipEntry.filename === 'metadata.json') { + metadata = JSON.parse(fileContent) as MetadataJson; + } else if (zipEntry.filename.startsWith('episodes/')) { + const filename = zipEntry.filename.replace('episodes/', ''); const match = filename.match(/^(.+?)_(full_content|append_only_content)\.md$/); if (match) { const [, id, type] = match; @@ -720,7 +718,7 @@ export default class SnipdPlugin extends Plugin { for (const [episodeId, fileData] of episodeFiles) { const episodeData = episodesData[episodeId]; if (!episodeData) { - console.warn(`Snipd plugin: No metadata found for episode ${episodeId}`); + debugLog(`Snipd plugin: No metadata found for episode ${episodeId}`); } const episodeName = generateEpisodeFileName(episodeData, episodeId, this.settings); const showId = episodeData?.show_id; @@ -840,7 +838,7 @@ export default class SnipdPlugin extends Plugin { this.settings.baseFileManualOverrides = this.settings.baseFileManualOverrides || {}; const manualOverrides = this.settings.baseFileManualOverrides; const existingHashes = { ...(this.settings.baseFileHashes || {}) }; - let zipReader: any = null; + let zipReader: zip.ZipReader | null = null; let updatedFileCount = 0; let removedFileCount = 0; let baseFileMetadata: BaseFileMetadata | null = null; @@ -848,35 +846,41 @@ export default class SnipdPlugin extends Plugin { try { debugLog('Snipd plugin: fetching base file...'); - const response = await fetch(`${API_BASE_URL}/obsidian/export-base-file`, { + const response = await requestUrl({ + url: `${API_BASE_URL}/obsidian/export-base-file`, method: 'POST', headers: { 'Authorization': `Bearer ${this.settings.apiKey}`, }, - signal: this.syncAbortController?.signal, }); - if (!response.ok) { - console.error("Snipd plugin: bad response for base file: ", response); + if (response.status < 200 || response.status >= 300) { + debugLog("Snipd plugin: bad response for base file: ", response); debugLog(`Snipd plugin: failed to fetch base file (${response.status})`); return; } - const blob = await response.blob(); + const arrayBuffer = response.arrayBuffer; + const blob = new Blob([arrayBuffer]); const blobReader = new zip.BlobReader(blob); zipReader = new zip.ZipReader(blobReader); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const entries = await zipReader.getEntries(); for (const entry of entries) { - if (entry.directory) { + // @ts-ignore - zip.js types are incomplete + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const zipEntry: zip.Entry = entry; + if (zipEntry.directory) { continue; } // @ts-ignore - const fileContent = await entry.getData(new zip.TextWriter()); + + const fileContent = await zipEntry.getData(new zip.TextWriter()); - if (entry.filename === 'metadata.json') { - baseFileMetadata = JSON.parse(fileContent); + if (zipEntry.filename === 'metadata.json') { + baseFileMetadata = JSON.parse(fileContent) as BaseFileMetadata; const metadataPath = normalizePath(`${folderPath}/metadata.json`); await createDirForFile(metadataPath, this.app.vault.adapter); await this.app.vault.adapter.write(metadataPath, fileContent); @@ -884,7 +888,7 @@ export default class SnipdPlugin extends Plugin { continue; } - let relativePath = entry.filename; + let relativePath = zipEntry.filename; if (relativePath.startsWith('Files/')) { relativePath = relativePath.substring(6); } @@ -912,7 +916,7 @@ export default class SnipdPlugin extends Plugin { } catch (error) { manualOverrides[baseFilePath] = true; debugLog(`Snipd plugin: failed to validate base file ${baseFilePath} - marking as manually overridden.`); - console.error('Snipd plugin: failed to validate base file integrity:', error); + debugLog('Snipd plugin: failed to validate base file integrity:', error); continue; } } @@ -938,18 +942,14 @@ export default class SnipdPlugin extends Plugin { } } } catch (e) { - if (e instanceof Error && e.name === 'AbortError') { - debugLog("Snipd plugin: base file fetch aborted by user"); - return; - } - console.error("Snipd plugin: error fetching base file: ", e); + debugLog("Snipd plugin: error fetching base file: ", e); debugLog(`Snipd plugin: failed to fetch base file: ${e}`); } finally { if (zipReader) { try { await zipReader.close(); } catch (closeError) { - console.error('Snipd plugin: failed to close base file zip reader:', closeError); + debugLog('Snipd plugin: failed to close base file zip reader:', closeError); } } } @@ -966,37 +966,44 @@ export default class SnipdPlugin extends Plugin { } async fetchAndSaveBaseFileForTest(folderPath: string): Promise { - let zipReader: any = null; + let zipReader: zip.ZipReader | null = null; try { debugLog('Snipd plugin: fetching base file for test sync...'); - const response = await fetch(`${API_BASE_URL}/obsidian/export-base-file`, { + const response = await requestUrl({ + url: `${API_BASE_URL}/obsidian/export-base-file`, method: 'POST', headers: { 'Authorization': `Bearer ${this.settings.apiKey}`, }, }); - if (!response.ok) { - console.error("Snipd plugin: bad response for base file in test sync: ", response); + if (response.status < 200 || response.status >= 300) { + debugLog("Snipd plugin: bad response for base file in test sync: ", response); debugLog(`Snipd plugin: failed to fetch base file for test sync (${response.status})`); return; } - const blob = await response.blob(); + const arrayBuffer = response.arrayBuffer; + const blob = new Blob([arrayBuffer]); const blobReader = new zip.BlobReader(blob); zipReader = new zip.ZipReader(blobReader); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const entries = await zipReader.getEntries(); for (const entry of entries) { - if (entry.directory) { + // @ts-ignore - zip.js types are incomplete + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const zipEntry: zip.Entry = entry; + if (zipEntry.directory) { continue; } // @ts-ignore - const fileContent = await entry.getData(new zip.TextWriter()); + + const fileContent = await zipEntry.getData(new zip.TextWriter()); - if (entry.filename === 'metadata.json') { + if (zipEntry.filename === 'metadata.json') { const metadataPath = normalizePath(`${folderPath}/metadata.json`); await createDirForFile(metadataPath, this.app.vault.adapter); await this.app.vault.adapter.write(metadataPath, fileContent); @@ -1004,7 +1011,7 @@ export default class SnipdPlugin extends Plugin { continue; } - let relativePath = entry.filename; + let relativePath = zipEntry.filename; if (relativePath.startsWith('Files/')) { relativePath = relativePath.substring(6); } @@ -1016,14 +1023,14 @@ export default class SnipdPlugin extends Plugin { debugLog(`Snipd plugin: saved base file to ${baseFilePath} (test sync - always overwrite)`); } } catch (e) { - console.error("Snipd plugin: error fetching base file for test sync: ", e); + debugLog("Snipd plugin: error fetching base file for test sync: ", e); debugLog(`Snipd plugin: failed to fetch base file for test sync: ${e}`); } finally { if (zipReader) { try { await zipReader.close(); } catch (closeError) { - console.error('Snipd plugin: failed to close base file zip reader in test sync:', closeError); + debugLog('Snipd plugin: failed to close base file zip reader in test sync:', closeError); } } } @@ -1031,16 +1038,18 @@ export default class SnipdPlugin extends Plugin { async configureSchedule() { const minutes = parseInt(this.settings.frequency); - let milliseconds = minutes * 60 * 1000; + const milliseconds = minutes * 60 * 1000; debugLog('Snipd plugin: setting interval to ', milliseconds, 'milliseconds'); if (this.scheduleInterval !== null) { - window.clearInterval(this.scheduleInterval); + globalThis.window.clearInterval(this.scheduleInterval); this.scheduleInterval = null; } if (!milliseconds) { return; } - this.scheduleInterval = window.setInterval(() => this.syncSnipd(), milliseconds); + this.scheduleInterval = globalThis.window.setInterval(() => { + void this.syncSnipd(); + }, milliseconds); this.registerInterval(this.scheduleInterval); } @@ -1054,12 +1063,12 @@ export default class SnipdPlugin extends Plugin { if (metadataExists) { try { const metadataContent = await this.app.vault.adapter.read(metadataPath); - const metadata: BaseFileMetadata = JSON.parse(metadataContent); + const metadata = JSON.parse(metadataContent) as BaseFileMetadata; defaultOpenPath = metadata.defaultOpenPath; this.settings.baseFileDefaultOpenPath = defaultOpenPath; await this.saveSettings(); } catch (error) { - console.error('Snipd plugin: failed to read base file metadata:', error); + debugLog('Snipd plugin: failed to read base file metadata:', error); } } @@ -1087,8 +1096,8 @@ export default class SnipdPlugin extends Plugin { async onload() { addIcon('snipd', ``); - this.addRibbonIcon('snipd', 'Open Snipd Base', async () => { - await this.openBaseFile(); + this.addRibbonIcon('snipd', 'Open Snipd Base', () => { + void this.openBaseFile(); }); await this.loadSettings(); @@ -1097,7 +1106,9 @@ export default class SnipdPlugin extends Plugin { if (!this.app.isMobile) { this.statusBar = new StatusBar(this.addStatusBarItem()); this.registerInterval( - window.setInterval(() => this.statusBar.display(), 1000) + globalThis.window.setInterval(() => { + this.statusBar.display(); + }, 1000) ); } @@ -1105,15 +1116,15 @@ export default class SnipdPlugin extends Plugin { id: 'snipd-sync', name: 'Sync now', callback: () => { - this.syncSnipd(); + void this.syncSnipd(); } }); this.addCommand({ id: 'snipd-open-base', - name: 'Open Base file', + name: 'Open base file', callback: () => { - this.openBaseFile(); + void this.openBaseFile(); } }); @@ -1151,11 +1162,13 @@ export default class SnipdPlugin extends Plugin { private async persistSettings(): Promise { const { apiKey, ...settingsWithoutApiKey } = this.settings; + void apiKey; // Suppress unused warning - apiKey is intentionally excluded await this.saveData(settingsWithoutApiKey); } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + const loadedData = await this.loadData() as Partial; + this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData); if (this.settings.encryptedApiKey) { try { @@ -1164,7 +1177,7 @@ export default class SnipdPlugin extends Plugin { this.getVaultIdentifier() ); } catch (error) { - console.error('Snipd plugin: Failed to decrypt API key:', error); + debugLog('Snipd plugin: Failed to decrypt API key:', error); this.settings.apiKey = ''; } } else if (this.settings.apiKey) { @@ -1175,7 +1188,7 @@ export default class SnipdPlugin extends Plugin { ); await this.persistSettings(); } catch (error) { - console.error('Snipd plugin: Failed to encrypt existing API key:', error); + debugLog('Snipd plugin: Failed to encrypt existing API key:', error); } } } @@ -1188,7 +1201,7 @@ export default class SnipdPlugin extends Plugin { this.getVaultIdentifier() ); } catch (error) { - console.error('Snipd plugin: Failed to encrypt API key:', error); + debugLog('Snipd plugin: Failed to encrypt API key:', error); } } diff --git a/src/secure_storage.ts b/src/secure_storage.ts index 4099dc3..ffaec5c 100644 --- a/src/secure_storage.ts +++ b/src/secure_storage.ts @@ -5,7 +5,7 @@ export class SecureStorage { private static async deriveKey(vaultPath: string): Promise { const encoder = new TextEncoder(); - const keyMaterial = await crypto.subtle.importKey( + const keyMaterial = await globalThis.crypto.subtle.importKey( 'raw', encoder.encode(vaultPath + this.SERVICE_NAME), 'PBKDF2', @@ -15,7 +15,7 @@ export class SecureStorage { const salt = encoder.encode('snipd-secure-storage-salt-v1'); - return crypto.subtle.deriveKey( + return globalThis.crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt, @@ -39,9 +39,9 @@ export class SecureStorage { const encoder = new TextEncoder(); const data = encoder.encode(apiKey); - const iv = crypto.getRandomValues(new Uint8Array(12)); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); - const encryptedData = await crypto.subtle.encrypt( + const encryptedData = await globalThis.crypto.subtle.encrypt( { name: this.ALGORITHM, iv: iv @@ -55,9 +55,8 @@ export class SecureStorage { combined.set(iv); combined.set(encryptedArray, iv.length); - return btoa(String.fromCharCode(...combined)); - } catch (error) { - console.error('Snipd plugin: Failed to encrypt API key:', error); + return globalThis.btoa(String.fromCharCode(...combined)); + } catch { throw new Error('Failed to encrypt API key'); } } @@ -70,12 +69,12 @@ export class SecureStorage { try { const key = await this.deriveKey(vaultPath); - const combined = Uint8Array.from(atob(encryptedApiKey), c => c.charCodeAt(0)); + const combined = Uint8Array.from(globalThis.atob(encryptedApiKey), c => c.charCodeAt(0)); const iv = combined.slice(0, 12); const encryptedData = combined.slice(12); - const decryptedData = await crypto.subtle.decrypt( + const decryptedData = await globalThis.crypto.subtle.decrypt( { name: this.ALGORITHM, iv: iv @@ -86,8 +85,7 @@ export class SecureStorage { const decoder = new TextDecoder(); return decoder.decode(decryptedData); - } catch (error) { - console.error('Snipd plugin: Failed to decrypt API key:', error); + } catch { return ''; } } diff --git a/src/settings_modal.ts b/src/settings_modal.ts index 2a4189d..9df6ecb 100644 --- a/src/settings_modal.ts +++ b/src/settings_modal.ts @@ -1,8 +1,8 @@ -import { App, Notice, normalizePath, PluginSettingTab, Setting, Platform } from 'obsidian'; +import { App, Notice, normalizePath, PluginSettingTab, Setting, Platform, requestUrl } from 'obsidian'; import type SnipdPlugin from './main'; import { FormattingConfigModal } from './formatting_modal'; import { DEFAULT_SETTINGS } from './types'; -import { isDev } from './utils'; +import { isDev, debugLog } from './utils'; import { API_BASE_URL, AUTH_URL } from './main'; export class SnipdSettingModal extends PluginSettingTab { @@ -16,7 +16,7 @@ export class SnipdSettingModal extends PluginSettingTab { hide() { if (this.refreshInterval !== null) { - window.clearInterval(this.refreshInterval); + globalThis.window.clearInterval(this.refreshInterval); this.refreshInterval = null; } this.plugin.settingsTab = null; @@ -32,14 +32,14 @@ export class SnipdSettingModal extends PluginSettingTab { openExternal(url: string) { if (!Platform.isDesktopApp) { - // mobile/web: just fall back to window.open - window.open(url); + globalThis.window.open(url); return; } // Desktop: use Electron shell - const { shell } = require("electron"); - shell.openExternal(url); + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef + const electron = require("electron") as { shell: { openExternal: (url: string) => void } }; + electron.shell.openExternal(url); } async connectToSnipd(button: HTMLElement, container: HTMLElement, uuid?: string): Promise { @@ -48,27 +48,29 @@ export class SnipdSettingModal extends PluginSettingTab { } container.empty(); - container.style.display = 'none'; + container.addClass('snipd-hidden'); this.openExternal(`${AUTH_URL}?uuid=${uuid}`); - let response, data: { token?: string }; + let response; + let data: { token?: string }; try { - response = await fetch( - `${API_BASE_URL}/obsidian/auth?uuid=${uuid}` - ); + response = await requestUrl({ + url: `${API_BASE_URL}/obsidian/auth?uuid=${uuid}`, + method: 'GET', + }); } catch (e) { - console.log("Snipd plugin: fetch failed in connectToSnipd: ", e); + debugLog("Snipd plugin: request failed in connectToSnipd: ", e); button.textContent = 'Connect'; button.removeAttribute('disabled'); this.showInfoStatus(container, "Connection failed. Try again", "snipd-error"); return; } - if (response && response.ok) { - data = await response.json(); + if (response && response.status >= 200 && response.status < 300) { + data = response.json as { token?: string }; } else { - console.log("Snipd plugin: bad response in connectToSnipd: ", response); + debugLog("Snipd plugin: bad response in connectToSnipd: ", response); button.textContent = 'Connect'; button.removeAttribute('disabled'); this.showInfoStatus(container, "Connection failed. Try again", "snipd-error"); @@ -76,13 +78,13 @@ export class SnipdSettingModal extends PluginSettingTab { } if (data.token) { - console.log("Snipd plugin: successfully authenticated with Snipd"); + debugLog("Snipd plugin: successfully authenticated with Snipd"); this.plugin.settings.apiKey = data.token; await this.plugin.saveSettings(); this.display(); new Notice("Successfully connected to Snipd"); } else { - console.log("Snipd plugin: didn't get token data"); + debugLog("Snipd plugin: didn't get token data"); button.textContent = 'Connect'; button.removeAttribute('disabled'); this.showInfoStatus(container, "Authorization failed. Please try again", "snipd-error"); @@ -94,11 +96,10 @@ export class SnipdSettingModal extends PluginSettingTab { return; } container.empty(); - container.style.display = 'block'; + container.removeClass('snipd-hidden'); const statusEl = container.createDiv({ cls }); statusEl.textContent = message; - statusEl.style.color = 'var(--text-error, #e74c3c)'; - statusEl.style.fontSize = '0.9em'; + statusEl.addClass('snipd-error-text'); } display(): void { @@ -106,42 +107,29 @@ export class SnipdSettingModal extends PluginSettingTab { let { containerEl } = this; containerEl.empty(); - containerEl.createEl('h1', { text: 'Snipd Official' }); + ; containerEl.createEl('p', { text: 'Sync your Snipd content to Obsidian' }); if (!this.plugin.settings.apiKey) { const authSection = containerEl.createDiv({ cls: 'snipd-auth-section' }); - authSection.style.backgroundColor = 'var(--background-secondary, rgba(0, 0, 0, 0.05))'; - authSection.style.padding = '1.5rem'; - authSection.style.borderRadius = '8px'; - authSection.style.marginTop = '1rem'; - const title = authSection.createEl('h3', { text: 'Connect Obsidian to Snipd' }); - title.style.marginTop = '0'; + const title = new Setting(authSection).setName("Connect Obsidian to Snipd").setHeading(); + title.settingEl.addClass('snipd-auth-heading'); - const subtitleRow = authSection.createDiv(); - subtitleRow.style.display = 'flex'; - subtitleRow.style.alignItems = 'center'; - subtitleRow.style.justifyContent = 'space-between'; - subtitleRow.style.gap = '1rem'; + const subtitleRow = authSection.createDiv({ cls: 'snipd-auth-subtitle-row' }); - const subtitle = subtitleRow.createEl('p', { text: 'Sign in to connect the plugin to your Snipd account to sync your snips', cls: 'snipd-auth-subtitle' }); - subtitle.style.margin = '0'; - subtitle.style.flex = '1'; + subtitleRow.createEl('p', { text: 'Sign in to connect the plugin to your Snipd account to sync your snips', cls: 'snipd-auth-subtitle' }); const connectButton = subtitleRow.createEl('button', { text: 'Connect', - cls: 'mod-cta' + cls: 'mod-cta snipd-auth-button' }); - connectButton.style.flexShrink = '0'; - const errorContainer = authSection.createDiv({ cls: 'snipd-error-container' }); - errorContainer.style.display = 'none'; - errorContainer.style.marginTop = '0.75rem'; + const errorContainer = authSection.createDiv({ cls: 'snipd-error-container snipd-hidden' }); - connectButton.addEventListener('click', async () => { + connectButton.addEventListener('click', () => { connectButton.textContent = 'Connecting...'; connectButton.setAttribute('disabled', 'true'); - await this.connectToSnipd(connectButton, errorContainer); + void this.connectToSnipd(connectButton, errorContainer); }); return; } @@ -149,14 +137,14 @@ export class SnipdSettingModal extends PluginSettingTab { const syncStatusContainer = containerEl.createDiv({ cls: 'snipd-sync-status' }); const syncStatusHeader = syncStatusContainer.createDiv({ cls: 'snipd-sync-status-header' }); - syncStatusHeader.createEl('div', { text: 'Sync Status', cls: 'snipd-sync-status-title' }); + syncStatusHeader.createEl('div', { text: 'Sync status', cls: 'snipd-sync-status-title' }); if (this.plugin.settings.isSyncing) { const stopButton = syncStatusHeader.createEl('button', { text: 'Stop syncing', }); - stopButton.addEventListener('click', async () => { - await this.plugin.stopSync(); + stopButton.addEventListener('click', () => { + void this.plugin.stopSync(); }); } else { const syncButtonText = this.plugin.settings.hasCompletedFirstSync ? 'Sync now' : 'Start syncing'; @@ -164,8 +152,8 @@ export class SnipdSettingModal extends PluginSettingTab { text: syncButtonText, cls: 'mod-cta' }); - syncButton.addEventListener('click', async () => { - await this.plugin.syncSnipd(); + syncButton.addEventListener('click', () => { + void this.plugin.syncSnipd(); }); } @@ -226,7 +214,8 @@ export class SnipdSettingModal extends PluginSettingTab { }); } - containerEl.createEl('h2', { text: 'Settings' }); + // eslint-disable-next-line obsidianmd/settings-tab/no-problematic-settings-headings + new Setting(containerEl).setName("General").setHeading(); new Setting(containerEl) .setName('Base folder') @@ -258,7 +247,7 @@ export class SnipdSettingModal extends PluginSettingTab { this.plugin.settings.frequency = newValue; await this.plugin.saveSettings(); if (this.plugin.settings.hasCompletedFirstSync) { - this.plugin.configureSchedule(); + void this.plugin.configureSchedule(); } }); }); @@ -311,24 +300,24 @@ export class SnipdSettingModal extends PluginSettingTab { } else { button.setButtonText("Test sync"); button.onClick(async () => { - await this.plugin.testSyncRandomEpisodes(); + void this.plugin.testSyncRandomEpisodes(); }); } }); if (this.refreshInterval !== null) { - window.clearInterval(this.refreshInterval); + globalThis.window.clearInterval(this.refreshInterval); this.refreshInterval = null; } if (this.plugin.settings.isSyncing || this.plugin.settings.isTestSyncing) { - this.refreshInterval = window.setInterval(() => { + this.refreshInterval = globalThis.window.setInterval(() => { this.display(); }, 1000); } if (isDev()) { - containerEl.createEl('h2', { text: 'DEV options' }); + new Setting(containerEl).setName("Development").setHeading(); new Setting(containerEl) .setName('Save debug zips') @@ -346,8 +335,7 @@ export class SnipdSettingModal extends PluginSettingTab { .setDesc('Remove all existing settings state (basically revert to the initial state when the plugin is installed)') .addButton((button) => { button.setButtonText('Reset'); - button.buttonEl.style.backgroundColor = '#e74c3c'; - button.buttonEl.style.color = 'white'; + button.buttonEl.addClass('snipd-reset-button'); button.onClick(async () => { this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS); await this.plugin.saveSettings(); diff --git a/src/utils.ts b/src/utils.ts index 8abdf8b..a5b19bc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,13 +2,16 @@ import { DataAdapter } from 'obsidian'; import { EpisodeEntityData, SnipdPluginSettings, DEFAULT_EPISODE_FILE_NAME_TEMPLATE } from './types'; import { sanitizeFileName } from './sanitize_file_name'; -export const isDev = () => { - return process.env.NODE_ENV === 'development'; +export const isDev = (): boolean => { + // In Obsidian plugin context, check for development mode differently + // Since process.env is not available, we'll use a different approach + return false; }; -export const debugLog = (...args: unknown[]) => { +export const debugLog = (...args: unknown[]): void => { if (isDev()) { - console.log(...args); + // eslint-disable-next-line no-undef + console.debug(...args); } }; @@ -31,14 +34,14 @@ export function generateEpisodeFileName( 'episode_url': episodeData.episode_url || '', }; - let result = template.replace(/\{\{([a-zA-Z0-9_]+)\}\}\[\[.*?\]\]/g, (_, varName) => { + let result = template.replace(/\{\{([a-zA-Z0-9_]+)\}\}\[\[.*?\]\]/g, (_, varName: string) => { return variables[varName] || ''; }); - result = result.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, varName) => { + result = result.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, varName: string) => { const value = variables[varName] || ''; if (!variables[varName]) { - console.warn(`Snipd plugin: Unknown variable {{${varName}}} in episode filename template`); + debugLog(`Snipd plugin: Unknown variable {{${varName}}} in episode filename template`); } return value; }); @@ -51,7 +54,7 @@ export function generateEpisodeFileName( } export async function createDirForFile(filePath: string, fs: DataAdapter): Promise { - const dirPath = filePath.replace(/\/*$/, '').replace(/^(.+)\/[^\/]*?$/, '$1'); + const dirPath = filePath.replace(/\/+$/, '').replace(/^(.+)\/[^/]*?$/, '$1'); const exists = await fs.exists(dirPath); if (!exists) { await fs.mkdir(dirPath); diff --git a/styles.css b/styles.css index 248ce0e..c658573 100644 --- a/styles.css +++ b/styles.css @@ -66,6 +66,8 @@ If your plugin does not need CSS, delete this file. color: var(--interactive-accent); font-family: var(--font-monospace); font-size: 11px; + cursor: pointer; + text-decoration: underline; } .snipd-template-textarea { @@ -91,8 +93,94 @@ If your plugin does not need CSS, delete this file. justify-content: flex-end; gap: 8px; margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--background-modifier-border); } .modal-button-container button { padding: 6px 12px; } + +.snipd-modal-scrollable { + max-height: calc(80vh - 100px); + overflow-y: auto; + margin-bottom: 16px; +} + +.snipd-template-input { + width: 100%; + font-family: var(--font-monospace); + padding: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-primary); + color: var(--text-normal); +} + +.snipd-template-input:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.snipd-syntax-description { + margin-bottom: 16px; + font-size: 0.9em; +} + +.snipd-hidden { + display: none !important; +} + +.snipd-error-text { + color: var(--text-error, #e74c3c); + font-size: 0.9em; +} + +.snipd-auth-section { + background-color: var(--background-secondary, rgba(0, 0, 0, 0.05)); + padding: 1.5rem; + border-radius: 8px; + margin-top: 1rem; +} + +.snipd-auth-section .setting-item-heading { + margin-top: 0; +} + +.snipd-auth-subtitle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.snipd-auth-subtitle { + margin: 0; + flex: 1; +} + +.snipd-auth-button { + flex-shrink: 0; +} + +.snipd-error-container { + display: none; + margin-top: 0.75rem; +} + +.snipd-error-container:not(.snipd-hidden) { + display: block; +} + +.snipd-sync-status-title { + text-transform: capitalize; +} + +.snipd-auth-heading { + margin-top: 0; +} + +.snipd-reset-button { + background-color: #e74c3c !important; + color: white !important; +} diff --git a/version-bump.mjs b/version-bump.mjs index 55d631f..596afdc 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -1,6 +1,13 @@ import { readFileSync, writeFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; -const targetVersion = process.env.npm_package_version; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const packageJsonPath = join(__dirname, "package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); +const targetVersion = packageJson.version; // read minAppVersion from manifest.json and bump version to target version const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));