diff --git a/src/customDictionary.ts b/src/customDictionary.ts new file mode 100644 index 0000000..b0d803a --- /dev/null +++ b/src/customDictionary.ts @@ -0,0 +1,227 @@ +import { setIcon, Setting } from "obsidian"; +import SupernotePlugin from "./main"; + +/** Custom dictionary entry type */ +type CustomDictionaryEntry = { + /** The source text from Supernote .note file */ + source: string, + /** String to replace if the source text is matched */ + replace: string, + /** Whether the source text should be matched case-sensitively */ + matchCase?: boolean, + /** Whether the source text should be matched as a whole word */ + matchWord?: boolean +} + +/** Settings for Supernote's plugin custom dictionary options */ +export interface CustomDictionarySettings { + /** Whether the custom dictionary is enabled and text replacement should occur when extracting text to Obsidian */ + isCustomDictionaryEnabled: boolean, + /** The custom dictionary entries */ + customDictionary: CustomDictionaryEntry[] +} + +export const CUSTOM_DICTIONARY_DEFAULT_SETTINGS: CustomDictionarySettings = { + isCustomDictionaryEnabled: false, + customDictionary: [], +} + +// Create the UI for a single dictionary entry +function createDictionaryEntryUI({entry, tbody, plugin}: {entry: CustomDictionaryEntry, tbody: HTMLElement, plugin: SupernotePlugin}) { + const tr = tbody.createEl('tr'); + + // Create source text input + const sourceTd = tr.createEl('td'); + const sourceInput = sourceTd.createEl('input'); + sourceInput.type = 'text'; + sourceInput.value = entry.source; + + // Create replace text input + const replaceTd = tr.createEl('td'); + const replaceInput = replaceTd.createEl('input'); + replaceInput.type = 'text'; + replaceInput.value = entry.replace; + + // Create dictionary entry options + const optionsTd = tr.createEl('td'); + const matchCaseButton = optionsTd.createEl('button'); + matchCaseButton.ariaLabel = 'Match case'; + setIcon(matchCaseButton, 'case-sensitive'); + if (entry.matchCase !== false) { + matchCaseButton.addClass('active'); + } + const matchWordButton = optionsTd.createEl('button'); + matchWordButton.ariaLabel = 'Match whole word'; + setIcon(matchWordButton, 'whole-word'); + if (entry.matchWord !== false) { + matchWordButton.addClass('active'); + } + optionsTd.addClass('supernote-settings-custom-dictionary-entry-options'); + const deleteButton = optionsTd.createEl('button'); + deleteButton.ariaLabel = 'Delete dictionary entry'; + setIcon(deleteButton, 'trash-2'); + + // Update dictionary entry when input changes + const updateDictionaryEntry = async () => { + const index = Array.from(tbody.children).indexOf(tr) + plugin.settings.customDictionary[index] = { + source: sourceInput.value, + replace: replaceInput.value, + matchWord: matchWordButton.hasClass('active'), + matchCase: matchCaseButton.hasClass('active'), + }; + await plugin.saveSettings(); + } + sourceInput.addEventListener('input', updateDictionaryEntry); + replaceInput.addEventListener('input', updateDictionaryEntry); + + // Toggle match case when match case button is clicked + matchCaseButton.addEventListener('click', async () => { + matchCaseButton.toggleClass('active', !matchCaseButton.hasClass('active')); + await updateDictionaryEntry(); + }); + + // Toggle match word when match word button is clicked + matchWordButton.addEventListener('click', async () => { + matchWordButton.toggleClass('active', !matchWordButton.hasClass('active')); + await updateDictionaryEntry(); + }); + + // Delete dictionary entry when delete button is clicked + deleteButton.addEventListener('click', async () => { + const index = Array.from(tbody.children).indexOf(tr); + plugin.settings.customDictionary.splice(index, 1); + await plugin.saveSettings(); + if (plugin.settings.customDictionary.length === 0) { + sourceInput.value = ''; + replaceInput.value = ''; + } else { + tr.remove(); + } + }); +} + +// Create the UI for the dictionary table +function createDictionaryTableUI(containerEl: HTMLElement, plugin: SupernotePlugin) { + const CONTAINER_CLASSNAME = 'supernote-settings-custom-dictionary-entries'; + + // Remove existing dictionary entries to prepare for re-render + containerEl.find(`.${CONTAINER_CLASSNAME}`)?.remove(); + + // Create the dictionary entries container + const dictionaryEntriesContainer = containerEl.createDiv(); + dictionaryEntriesContainer + .addClasses(['setting-item', CONTAINER_CLASSNAME]); + dictionaryEntriesContainer.createDiv({ text: 'Custom Dictionary' }) + .addClass('setting-item-name'); + dictionaryEntriesContainer.createDiv({ text: 'Add an entry for every text string you would like to replace from Supernote\'s recognized text. Note, all case-sensitive matches will run as a group before case-insensitive matches.' }) + .addClasses(['setting-item-description']); + + // Create the dictionary entries table + const table = dictionaryEntriesContainer.createEl('table'); + const thead = table.createEl('thead'); + const trHead = thead.createEl('tr'); + const tbody = table.createEl('tbody'); + + trHead.createEl('th', { text: 'Source Text' }); + trHead.createEl('th', { text: 'Replacement' }); + trHead.createEl('th', { text: 'Options' }); + + const addEmptyRow = () => { + createDictionaryEntryUI({ + entry: {source: '', replace: ''}, + tbody, + plugin + }); + } + + // Create UI for existing dictionary entries + for (const entry of plugin.settings.customDictionary) { + createDictionaryEntryUI({ + entry, + tbody, + plugin + }); + } + + // Add an empty row if there are no dictionary entries + if (plugin.settings.customDictionary.length === 0) { + addEmptyRow(); + } + + // Create the "Add dictionary entry" button + const addEntryButton = dictionaryEntriesContainer.createEl('button', { text: 'Add dictionary entry' }); + addEntryButton.addEventListener('click', addEmptyRow); +} + +// Create the UI for the custom dictionary settings +export function createCustomDictionarySettingsUI(containerEl: HTMLElement, plugin: SupernotePlugin): void { + const customDictionaryContainer = containerEl.createDiv(); + customDictionaryContainer + .addClasses(['supernote-settings-custom-dictionary']); + customDictionaryContainer.createEl('h3', { text: 'Custom Dictionary' }) + // .addClass('setting-item-name'); + customDictionaryContainer.createDiv({ text: 'You can add custom entries to your dictionary to fix errors from Supernote\'s handwriting recognition. This also lets you automatically swap out certain text with your preferred wording, add special markdown characters, etc.' }) + .addClasses(['setting-item-description', 'supernote-settings-custom-dictionary-subtitle']); + + // Create the "Enable Custom Dictionary" setting + new Setting(customDictionaryContainer) + .setName('Enable Custom Dictionary') + .setDesc('Enable or disable the custom dictionary.') + .addToggle(text => text + .setValue(plugin.settings.isCustomDictionaryEnabled) + .onChange(async (value) => { + plugin.settings.isCustomDictionaryEnabled = value; + setCustomDictionaryEntriesDisabledState(customDictionaryContainer, plugin); + await plugin.saveSettings(); + }) + ); + + // Create the dictionary entries setting + createDictionaryTableUI(customDictionaryContainer, plugin); + setCustomDictionaryEntriesDisabledState(customDictionaryContainer, plugin); +} + +// Toggle disabled state of custom dictionary entries based on the custom dictionary setting +function setCustomDictionaryEntriesDisabledState(containerEl: HTMLElement, plugin: SupernotePlugin) { + const customDictionaryContainer = containerEl.find('.supernote-settings-custom-dictionary-entries'); + if (plugin.settings.isCustomDictionaryEnabled) { + customDictionaryContainer?.classList.remove('disabled'); + } else { + customDictionaryContainer?.classList.add('disabled'); + } +} + +// Escape special characters in a string to be used in a regular expression +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Replace text with custom dictionary entries +export function replaceTextWithCustomDictionary(text: string, customDictionary: CustomDictionarySettings['customDictionary']): string { + let newText = text; + + const [caseInsensitiveMatchers, caseSensitiveMatchers] = customDictionary.reduce((acc, entry) => { + const source = entry.source; + const matcher = entry.matchWord ? `\\b${escapeRegExp(source)}\\b` : escapeRegExp(source); + if (entry.matchCase) { + acc[1].push(matcher); + } else { + acc[0].push(matcher); + } + return acc; + }, [[], []] as [string[], string[]]); + + + newText = newText.replace(new RegExp(caseSensitiveMatchers.join('|'), 'g'), (match) => { + const entry = customDictionary.find(entry => entry.source === match); + return entry ? entry.replace : match; + }); + + newText = newText.replace(new RegExp(caseInsensitiveMatchers.join('|'), 'gi'), (match) => { + const entry = customDictionary.find(entry => entry.source.toLocaleUpperCase() === match.toLocaleUpperCase()); + return entry ? entry.replace : match; + }); + + return newText; +} diff --git a/src/main.ts b/src/main.ts index 9e90157..8ff94d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { DownloadListModal, UploadListModal } from './FileListModal'; import { jsPDF } from 'jspdf'; import { SupernoteWorkerMessage, SupernoteWorkerResponse } from './myworker.worker'; import Worker from 'myworker.worker'; +import { replaceTextWithCustomDictionary } from './customDictionary'; function generateTimestamp(): string { const date = new Date(); @@ -34,6 +35,21 @@ function dataUrlToBuffer(dataUrl: string): ArrayBuffer { return bytes.buffer; } +/** + * Processes the Supernote text based on the provided settings. + * + * @param text - The input text to be processed. + * @param settings - The settings for the Supernote plugin. + * @returns The processed text. + */ +function processSupernoteText(text: string, settings: SupernotePluginSettings): string { + let processedText = text; + if (settings.isCustomDictionaryEnabled) { + processedText = replaceTextWithCustomDictionary(processedText, settings.customDictionary); + } + return processedText; +} + export class WorkerPool { private workers: Worker[]; @@ -146,7 +162,7 @@ class VaultWriter { for (let i = 0; i < sn.pages.length; i++) { content += `## Page ${i + 1}\n\n` if (sn.pages[i].text !== undefined && sn.pages[i].text.length > 0) { - content += `${sn.pages[i].text}\n`; + content += `${processSupernoteText(sn.pages[i].text, this.settings)}\n`; } if (imgs) { let subpath = ''; @@ -226,7 +242,7 @@ class VaultWriter { if (sn.pages[i].text !== undefined && sn.pages[i].text.length > 0) { pdf.setFontSize(100); pdf.setTextColor(0, 0, 0, 0); // Transparent text - pdf.text(sn.pages[i].text, 20, 20, { maxWidth: sn.pageWidth }); + pdf.text(processSupernoteText(sn.pages[i].text, this.settings), 20, 20, { maxWidth: sn.pageWidth }); pdf.setTextColor(0, 0, 0, 1); } @@ -343,13 +359,13 @@ export class SupernoteView extends FileView { // If Collapse Text setting is enabled, place the text into an HTML `details` element if (this.settings.collapseRecognizedText) { text = pageContainer.createEl('details', { - text: '\n' + sn.pages[i].text, + text: '\n' + processSupernoteText(sn.pages[i].text,this.settings), cls: 'page-recognized-text', }); text.createEl('summary', { text: `Page ${i + 1} Recognized Text` }); } else { text = pageContainer.createEl('div', { - text: sn.pages[i].text, + text: processSupernoteText(sn.pages[i].text, this.settings), cls: 'page-recognized-text', }); } diff --git a/src/settings.ts b/src/settings.ts index c1a068f..80dfeaf 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,10 +1,11 @@ +import { createCustomDictionarySettingsUI, CUSTOM_DICTIONARY_DEFAULT_SETTINGS, CustomDictionarySettings } from "./customDictionary"; import SupernotePlugin from "./main"; import { App, ExtraButtonComponent, PluginSettingTab, Setting } from 'obsidian'; export const IP_VALIDATION_PATTERN = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/; -export interface SupernotePluginSettings { +export interface SupernotePluginSettings extends CustomDictionarySettings { directConnectIP: string; invertColorsWhenDark: boolean; showTOC: boolean; @@ -20,6 +21,7 @@ export const DEFAULT_SETTINGS: SupernotePluginSettings = { showExportButtons: true, collapseRecognizedText: false, noteImageMaxDim: 800, // Sensible default for Nomad pages to be legible but not too big. Unit: px + ...CUSTOM_DICTIONARY_DEFAULT_SETTINGS, } export class SupernoteSettingTab extends PluginSettingTab { @@ -123,5 +125,8 @@ export class SupernoteSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }) ); + + // Add custom dictionary settings to the settings tab + createCustomDictionarySettingsUI(containerEl, this.plugin); } } diff --git a/styles.css b/styles.css index 9ae6186..948d943 100644 --- a/styles.css +++ b/styles.css @@ -28,4 +28,47 @@ div.page-container { display: inline-block; vertical-align: top; margin: 1.2em; -} \ No newline at end of file +} + +/* Custom dictionary settings styles */ +.supernote-settings-custom-dictionary-subtitle { + padding: 0 0 0.75em; +} + +.supernote-settings-custom-dictionary-entries { + display: block; +} + +.supernote-settings-custom-dictionary-entries.disabled { + display: none; +} + +.supernote-settings-custom-dictionary table { + margin-block-start: var(--size-4-2); + margin-block-end: var(--size-2-1); + font-size: smaller; +} + +.supernote-settings-custom-dictionary table, .supernote-settings-custom-dictionary table input { + width: 100%; +} + +.supernote-settings-custom-dictionary table th { + text-align: start; +} + +.supernote-settings-custom-dictionary-entry-options { + display: grid; + grid-gap: var(--size-4-1); + grid-auto-flow: column; +} + +.supernote-settings-custom-dictionary button { + cursor: pointer; +} + +.supernote-settings-custom-dictionary-entry-options button.active { + color: var(--text-on-accent); + --icon-color: var(--text-on-accent); + background-color: var(--interactive-accent); +}