From c6671c546359241426bbcd5f66f502a87cf05334 Mon Sep 17 00:00:00 2001 From: Shaun Martin Date: Sat, 11 May 2024 17:00:09 -0500 Subject: [PATCH] fix: refactor new note modal, add validation (#21) --- main.ts | 2 + src/components/bouncing-progress-bar.ts | 27 +++++++ .../frontmatter-prop-settings.svelte | 11 --- src/components/validated-text.ts | 67 ++++++++++++++++ src/const.ts | 5 ++ src/lib/logger.ts | 15 ++-- src/lib/util.ts | 33 ++++++-- src/modals/new-note.ts | 80 +++++++++++-------- styles.css | 16 ++++ 9 files changed, 198 insertions(+), 58 deletions(-) create mode 100644 src/components/bouncing-progress-bar.ts create mode 100644 src/components/validated-text.ts diff --git a/main.ts b/main.ts index bb016c5..bd9e8e0 100644 --- a/main.ts +++ b/main.ts @@ -112,6 +112,7 @@ export default class SlurpPlugin extends Plugin { displayError = (err: Error) => new Notice(`Slurp Error! ${err.message}. If this is a bug, please report it from plugin settings.`, 0); async slurp(url: string): Promise { + this.logger.debug("slurping", {url}); try { const doc = new DOMParser().parseFromString(await fetchHtml(url), 'text/html'); @@ -138,6 +139,7 @@ export default class SlurpPlugin extends Plugin { link: url }); } catch (err) { + this.logger.error("Unable to Slurp page", {url, err: (err as Error).message}); this.displayError(err as Error); } } diff --git a/src/components/bouncing-progress-bar.ts b/src/components/bouncing-progress-bar.ts new file mode 100644 index 0000000..f123b4f --- /dev/null +++ b/src/components/bouncing-progress-bar.ts @@ -0,0 +1,27 @@ +import { ProgressBarComponent } from "obsidian"; + +export class BouncingProgressBarComponent extends ProgressBarComponent { + private timerId: number; + + constructor(contentEl: HTMLElement) { + super(contentEl); + this.timerId = -1; + this.setDisabled(true); + this.setValue(0); + } + + private update() { + const cur = this.getValue(); + this.setValue(cur + (cur == 100 ? 1 : -1 )); + }; + + start() { + this.setDisabled(false); + this.timerId = window.setInterval(this.update, 10); + }; + + stop() { + if (this.timerId > 0) window.clearInterval(this.timerId); + } +} + diff --git a/src/components/frontmatter-prop-settings.svelte b/src/components/frontmatter-prop-settings.svelte index 5d60086..6728d77 100644 --- a/src/components/frontmatter-prop-settings.svelte +++ b/src/components/frontmatter-prop-settings.svelte @@ -389,22 +389,11 @@ opacity: 50%; } - .mod-prop .validation-error, - .mod-prop .validation-error { - color: var(--text-error); - } - .mod-prop div.validation-error { - font-size: small; text-align: center; margin: 0.75em 0; } - .mod-prop div.validation-error.hidden { - color: transparent; - background-color: transparent; - } - /* new property button */ #new-property { display: flex; diff --git a/src/components/validated-text.ts b/src/components/validated-text.ts new file mode 100644 index 0000000..7ae215d --- /dev/null +++ b/src/components/validated-text.ts @@ -0,0 +1,67 @@ +import { TextComponent } from "obsidian"; + +type TValidator = (input: string) => string | null; +type TOnValidate = (input: string, err: string[]) => void; + +export class ValidatedTextComponent extends TextComponent { + private readonly _validators = new Set(); + private readonly _errList: HTMLUListElement; + private _onValidateCb: TOnValidate; + private _earlyReturn: boolean; + private _minLen: number; + + + constructor(containerEl: HTMLElement) { + super(containerEl); + this._errList = containerEl.createEl("ul"); + this._errList.addClasses(["validation-error"]); + this._earlyReturn = true; + this._minLen = 3; + this._onValidateCb = () => { }; + this.onChange(() => this.validate()); + containerEl.appendChild(this._errList); + } + + setValidationErrorClass(className: string) { + this._errList.addClass(className); + return this; + } + + setStopOnFirstError(val: boolean = true) { + this._earlyReturn = val; + return this; + } + + setMinimumLength(val: number = 3) { + this._minLen = val; + return this; + } + + addValidator(fn: TValidator) { + this._validators.add(fn); + return this; + } + + onValidate(fn: TOnValidate) { + this._onValidateCb = fn; + return this; + } + + validate() { + const input = this.getValue() || ""; + const errs: string[] = []; + + if (input.length >= this._minLen) { + for (const fn of this._validators) { + const err = fn(input); + if (err !== null) + errs.push(err); + if (errs.length > 0 && this._earlyReturn) + break; + } + } + + this._errList.replaceChildren(...errs); + this._onValidateCb(input, errs); + } +} \ No newline at end of file diff --git a/src/const.ts b/src/const.ts index 01222d7..3b7b922 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,6 +1,11 @@ import { createFrontMatterPropSettings, createFrontMatterProps } from "src/frontmatter"; import type { IFrontMatterPropDefault, ISettings, TFrontMatterPropDefaults } from "./types"; +export const KNOWN_BROKEN_DOMAINS = new Map([ + ["fastcompany.com", "Fast Company prevents programs like Slurp from accessing their articles."], + ["sparksoftcorp.com", null], +]); + export const FRONT_MATTER_ITEM_DEFAULTS: TFrontMatterPropDefaults = new Map([ { id: "link", defaultIdx: 0, defaultKey: "link", diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 77f24d5..cfecfae 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -54,9 +54,10 @@ export class Logger { this.vault = plugin.app.vault; if (plugin.settings && Object.keys(plugin.settings).contains("logs")) { this.settings = plugin.settings.logs; - } else + } else { this.settings = { debug: true, logPath: DEFAULT_SETTINGS.logs.logPath }; - + } + if (this.settings.debug) plugin.registerInterval(window.setInterval( () => this.flush(), 500)); @@ -89,11 +90,11 @@ export class Logger { const msg = this.buffer[idx]; const optJson = []; for (const i of msg.optionalParams || []) { - try{ - optJson.push(JSON.stringify(serialize(i), undefined, 2)); - } catch (err) { - optJson.push(`Unable to stringify: ${i}`); - } + try { + optJson.push(JSON.stringify(serialize(i), undefined, 2)); + } catch (err) { + optJson.push(`Unable to stringify: ${i}`); + } } content += `##### ${msg.timestamp} | ${msg.level.padStart(5).toUpperCase()} | ${msg.msg}\n` + `- Caller: \`${msg.caller}\`\n\n`; diff --git a/src/lib/util.ts b/src/lib/util.ts index 9f29e7f..73936d0 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -58,9 +58,9 @@ export const serialize = (val: unknown) => { }; // murmurhash3 is simple, fast, and doesn't have external dependencies. -export function murmurhash3_32(key: string, seed: number = 0) { +export const murmurhash3_32 = (key: string, seed: number = 0) => { var remainder, bytes, h1, h1b, c1, c2, k1, i; - + // Initialize the variables remainder = key.length & 3; // key.length % 4 bytes = key.length - remainder; @@ -72,7 +72,7 @@ export function murmurhash3_32(key: string, seed: number = 0) { // Process the input data in 4-byte chunks while (i < bytes) { // extract the next 4 bytes and combine into a 32 bit int - k1 = + k1 = ((key.charCodeAt(i) & 0xff)) | ((key.charCodeAt(++i) & 0xff) << 8) | ((key.charCodeAt(++i) & 0xff) << 16) | @@ -103,10 +103,10 @@ export function murmurhash3_32(key: string, seed: number = 0) { case 3: k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; case 2: k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; case 1: k1 ^= (key.charCodeAt(i) & 0xff); - k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; - k1 = (k1 << 15) | (k1 >>> 17); - k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; - h1 ^= k1; + k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; + h1 ^= k1; } // Finalize the hash value @@ -121,4 +121,21 @@ export function murmurhash3_32(key: string, seed: number = 0) { // Return the resulting hash as an unsigned 32-bit integer return h1 >>> 0; -} \ No newline at end of file +}; + +export const extractDomain = (u: string) => { + // naively strap a proto in case it doesn't have one + const url = (u.split(":").length == 1) ? `https://${u}` : u; + try { + const urlObj = new URL(url); + if (urlObj.protocol === "http:" || urlObj.protocol === "https:") { + const parts = urlObj.host.split('.'); + const domain = parts[parts.length - 2] + '.' + parts[parts.length - 1]; + if (!domain.startsWith(".") && !domain.endsWith(".")) return domain; + } + } catch (err) { + // returning null after this anyway... + } + + return null; +}; \ No newline at end of file diff --git a/src/modals/new-note.ts b/src/modals/new-note.ts index 13e5999..528df20 100644 --- a/src/modals/new-note.ts +++ b/src/modals/new-note.ts @@ -1,54 +1,70 @@ import type SlurpPlugin from "main"; -import { Modal, App, TextComponent, ProgressBarComponent, Setting } from "obsidian"; +import { App, ButtonComponent, Modal, Setting } from "obsidian"; +import { BouncingProgressBarComponent } from "../components/bouncing-progress-bar"; +import { ValidatedTextComponent } from "../components/validated-text"; +import { KNOWN_BROKEN_DOMAINS } from "../const"; +import { extractDomain } from "../lib/util"; export class SlurpNewNoteModal extends Modal { - plugin: SlurpPlugin; - url: string; + private readonly plugin: SlurpPlugin; + private readonly WARNING_CLS = "validation"; + private readonly URL_FORMAT_ERR = "Invalid URL format."; constructor(app: App, plugin: SlurpPlugin) { super(app); this.plugin = plugin; - this.url = ""; } - onOpen() { - const { contentEl } = this; + private validateKnownBrokenDomains(url: string) { + const domain = extractDomain(url) || ""; + const defaultReason = "This site is known to be incompatible with Slurp."; - contentEl.createEl("h3", { text: "What would you like to slurp today?" }) + return KNOWN_BROKEN_DOMAINS.has(domain) + ? KNOWN_BROKEN_DOMAINS.get(domain) || defaultReason + : null; + } - const urlField = new TextComponent(contentEl) - .setPlaceholder("URL") - .onChange((val) => this.url = val); - urlField.inputEl.setCssProps({ "width": "100%" }); + private validateUrlFormat(url: string) { + return extractDomain(url) === null ? this.URL_FORMAT_ERR : null; + } - const progressBar = new ProgressBarComponent(contentEl) - progressBar.disabled = true; - progressBar.setValue(0); + onOpen() { + const { contentEl } = this; + let slurpBtn: ButtonComponent; - const doSlurp = async () => { - urlField.setDisabled(true); - progressBar.setDisabled(false); - let progressIncrement = 1; + new Setting(contentEl) + .setName("What would you like to slurp today?") + .setHeading(); - const t = setInterval(() => { - const cur = progressBar.getValue(); - if (cur == 100) progressIncrement *= -1; - progressBar.setValue(cur + progressIncrement); - }, 10) + const urlField = new ValidatedTextComponent(contentEl) + .setPlaceholder("https://www.somesite.com/...") + .setMinimumLength(5) + .addValidator((url: string) => this.validateUrlFormat(url)) + .addValidator((url: string) => this.validateKnownBrokenDomains(url)) + .onValidate((url: string, errs: string[]) => { + slurpBtn.setDisabled(errs.length > 0 || urlField.getValue().length < 5); + }); + + urlField.inputEl.setCssProps({ "width": "100%" }); - try { - this.plugin.slurp(this.url); - } catch (err) { this.plugin.displayError(err as Error); } + const progressBar = new BouncingProgressBarComponent(contentEl); - clearInterval(t); + const doSlurp = () => { + progressBar.start(); + this.plugin.slurp(urlField.getValue()); + progressBar.stop() this.close(); - }; + } new Setting(contentEl) - .addButton((btn) => btn - .setButtonText("Slurp") - .setCta() - .onClick(doSlurp)) + .addButton((btn) => { + btn.setButtonText("Slurp") + .setCta() + .setDisabled(true) + .onClick(doSlurp); + slurpBtn = btn; + return slurpBtn; + }); contentEl.addEventListener("keypress", (k) => (k.key === "Enter") && doSlurp()); } diff --git a/styles.css b/styles.css index 71cc60f..a1a209f 100644 --- a/styles.css +++ b/styles.css @@ -6,3 +6,19 @@ available in the app when your plugin is enabled. If your plugin does not need CSS, delete this file. */ + +.validation-error { + color: var(--text-error); + font-size: small; +} + +.validation-error.hidden { + color: transparent; + background-color: transparent; +} + +ul.validation-error { + list-style: none; + padding: 0; + margin: 0.5em 0.1em; +}