diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 5d9d57e..35af595 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -32,6 +32,21 @@ module.exports = { excludedFiles: ['**/*.ts*', '**/*.*js*'], parser: 'eslint-plugin-markdownlint/parser', extends: ['plugin:markdownlint/recommended'] + }, + { + files: ['jest.setup.js', 'test/**/*.test.ts', '**/__tests__/**/*.[jt]s?(x)'], + env: { + jest: true, + browser: true + }, + globals: { + HTMLMediaElement: 'readonly', + HTMLElement: 'readonly', + jest: 'readonly', + describe: 'readonly', + it: 'readonly', + expect: 'readonly' + } } ] } diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index ec004b6..dec9ab0 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -1,4 +1,4 @@ -name: Mocha.js CI - tests +name: Jest CI on: push: diff --git a/.lintstagedrc b/.lintstagedrc deleted file mode 100644 index 08dfb72..0000000 --- a/.lintstagedrc +++ /dev/null @@ -1,27 +0,0 @@ -{ - "*.{mjs,js,jsx}": [ - "npx prettier --write '*.{mjs,js,jsx}'", - "npx eslint '*.{mjs,js,jsx}' --fix", - "node scripts/lint.js --js --fix --only --", - "npx standard \"*.{mjs,js,jsx}\" --fix" - ], - "{!*.d.ts, *.{mts,ts,tsx}}": [ - "npx prettier --write '*.{mts,ts,tsx}'", - "npx eslint '*.{mts,ts,tsx}' --fix", - "npx ts-standard \"*.{mts,ts,tsx}\" --fix" - ], - "*.d.ts": ["npx prettier --write '**/*.d.ts'"], - "{!CHANGELOG.md, *.md}": [ - "npx textlint *.md --fix", - "npx markdownlint-cli2 '!{CHANGELOG.md,node_modules/**,out/**}' '*.{md,mdown,markdown}' --fix" - ], - "*.{gn,gni}": ["npm run gn-check", "npm run gn-format"], - "*.{png,jpeg,jpg,gif,svg}": "imagemin-lint-staged", - "*.css": ["stylelint --fix", "prettier --write '*.css'"], - "{*.patch,.patches}": [ - "node scripts/lint.js --patches --only --", - "ts-node scripts/js/check-patch-diff.ts" - ], - "*.{yml,yaml}": "npx prettier '*.{yml,yaml}' --write", - "*.json": "npx eslint '*.json' --fix" -} diff --git a/.lintstagedrc-python b/.lintstagedrc-python deleted file mode 100644 index 7957ba7..0000000 --- a/.lintstagedrc-python +++ /dev/null @@ -1,28 +0,0 @@ -{ - "*.{js,jsx}": [ - "npx prettier --write '*.{js,jsx}'", - "npx eslint '*.{js,jsx}' --fix", - "node scripts/lint.js --js --fix --only --", - "npx standard \"*.{js,jsx}\" --fix" - ], - "{!*.d.ts, *.{ts,tsx}}": [ - "npx prettier --write '*.{ts,tsx}'", - "npx eslint '*.{ts,tsx}' --fix", - "npx ts-standard \"*.{ts,tsx}\" --fix" - ], - "*.d.ts": ["npx prettier --write '**/*.d.ts'"], - "{!CHANGELOG.md, *.md}": [ - "npx textlint *.md --fix", - "npx markdownlint-cli2 '!{CHANGELOG.md,node_modules/**,out/**}' '*.{md,mdown,markdown}' --fix" - ], - "*.{gn,gni}": ["npm run gn-check", "npm run gn-format"], - "*.{png,jpeg,jpg,gif,svg}": "imagemin-lint-staged", - "*.css": ["stylelint --fix", "prettier --write '*.css'"], - "{*.patch,.patches}": [ - "node scripts/lint.js --patches --only --", - "ts-node scripts/js/check-patch-diff.ts" - ], - "*.{yml,yaml}": "npx prettier '*.{yml,yaml}' --write", - "*.json": "npx eslint '*.json' --fix", - "*.{py,ipynb}": ["black . --exclude '**/venv/'", "flake8", "mypy .", "pylint ."] -} diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 9b652fb..e961c7f 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,8 +1,10 @@ +const isMockOrTest = (f) => + f.includes('__mocks__') || f.endsWith('.test.ts') || f.endsWith('.spec.ts') + /** * @filename: lint-staged.config.js * @type {import('lint-staged').Configuration} */ - export default { /** Javascript codefiles: */ '*.{mjs,js,jsx,cjs}': (files) => { @@ -24,7 +26,7 @@ export default { const found = files .filter( (f) => - f.endsWith('.ts') || + (!isMockOrTest(f) && f.endsWith('.ts')) || f.endsWith('.tsx') || f.endsWith('.mts') || (f.endsWith('.cts') && !f.endsWith('.d.ts')) diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts new file mode 100644 index 0000000..3334d08 --- /dev/null +++ b/__mocks__/obsidian.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/strict-boolean-expressions */ +export class Plugin { + constructor (public app?: any, public manifest?: any) { } + onload () { } + onunload () { } +} +export interface App { + workspace: any + vault: any + metadataCache: any +} +export interface TFile { path: string } +export class PluginSettingTab { + constructor (public app: App, public plugin: any) { } + display (): void { } +} +export class Setting { + public settingEl = document.createElement('div') + constructor (public containerEl: HTMLElement) { + this.settingEl.classList.add('setting-item') + this.containerEl.appendChild(this.settingEl) + } + + setName () { return this } + setDesc () { return this } + addToggle () { return this } + addSlider () { return this } +} +export class TextAreaComponent { + public inputEl = document.createElement('textarea') + constructor (public containerEl: HTMLElement) { + this.inputEl.classList.add('setting-item') + this.containerEl.appendChild(this.inputEl) + } + + setPlaceholder () { return this } + setValue () { return this } + onChange () { return this } +} diff --git a/coverage/.gitignore b/coverage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/coverage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/index.js b/index.js index d81b6f0..be78723 100644 --- a/index.js +++ b/index.js @@ -5,16 +5,29 @@ if you want to view the source, please visit the github repository of this plugi */ 'use strict' -var __create = Object.create var __defProp = Object.defineProperty +var __defProps = Object.defineProperties var __getOwnPropDesc = Object.getOwnPropertyDescriptor +var __getOwnPropDescs = Object.getOwnPropertyDescriptors var __getOwnPropNames = Object.getOwnPropertyNames +var __getOwnPropSymbols = Object.getOwnPropertySymbols var __getProtoOf = Object.getPrototypeOf var __hasOwnProp = Object.prototype.hasOwnProperty +var __propIsEnum = Object.prototype.propertyIsEnumerable +var __reflectGet = Reflect.get var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : (obj[key] = value) +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]) + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]) + } + return a +} +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)) var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }) } @@ -29,22 +42,10 @@ var __copyProps = (to, from, except, desc) => { } return to } -var __toESM = (mod, isNodeMode, target) => ( - (target = mod != null ? __create(__getProtoOf(mod)) : {}), - __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule - ? __defProp(target, 'default', { value: mod, enumerable: true }) - : target, - mod - ) -) var __toCommonJS = (mod) => __copyProps(__defProp({}, '__esModule', { value: true }), mod) var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== 'symbol' ? key + '' : key, value) +var __superGet = (cls, obj, key) => __reflectGet(__getProtoOf(cls), key, obj) var __async = (__this, __arguments, generator) => { return new Promise((resolve, reject) => { var fulfilled = (value) => { @@ -67,227 +68,625 @@ var __async = (__this, __arguments, generator) => { }) } -// index.ts -var index_exports = {} -__export(index_exports, { - CONFIG: () => CONFIG, - LOCALE_LOGGER: () => LOCALE_LOGGER, - ask: () => ask, - default: () => LOCALE_MODULE +// source/main.ts +var main_exports = {} +__export(main_exports, { + default: () => Whisperer }) -module.exports = __toCommonJS(index_exports) -var os = __toESM(require('os')) -var path = __toESM(require('path')) -var fs = __toESM(require('fs-extra')) -var readline = __toESM(require('readline')) -var colors = __toESM(require('colors/safe')) -if (os.type() === 'Darwin') process.abort() -var LOCALE_LOGGER = class { - constructor() { - /** - * A process ID which represents session of localized logger instance. - * @type {number} - */ - __publicField(this, 'session_id', process.ppid) +module.exports = __toCommonJS(main_exports) +var import_obsidian3 = require('obsidian') + +// source/settings.ts +var import_obsidian2 = require('obsidian') + +// source/settings/settings_class.ts +var import_obsidian = require('obsidian') +var SettingsConstructor = class _SettingsConstructor { + constructor(_plugin, _containerEl) { + __publicField(this, 'defaults') + __publicField(this, '_SETTING_AMBIENCE') + __publicField(this, '_SETTING_VAULT_PATH_TITLE') + __publicField(this, '_SETTING_VAULT_PATH_INPUT') + __publicField(this, '_SETTING_AMBIENCE_PER_FILE') + __publicField(this, '_SETTING_AMBIENCE_PER_FILE_COMMENT') + __publicField(this, '_SETTING_MUSICE_VOLUME') + __publicField(this, '_SETTING_DEBUG_FRAMES') + __publicField(this, 'plugin') + __publicField(this, 'containerEl') + this.plugin = _plugin + this.containerEl = _containerEl } - /** - * Logs the info message. - * @param {...unknown} data - The data to be logged. - */ - info(...data) { - console.info(colors.blue(this.parseData(data))) - } - /** - * Logs the warn message. - * @param {...unknown} data - The data to be logged. - */ - warn(...data) { - console.warn(colors.yellow(this.parseData(data))) - } - /** - * Logs the error message. - * @param {...unknown} data - The data to be logged. - */ - error(...data) { - console.error(colors.bgRed(colors.white(this.parseData(data)))) - } - /** - * Logs the success message. - * @param {...unknown} data - The data to be logged. - */ - success(...data) { - console.log(colors.green(this.parseData(data))) - } - /** - * Logs the message with custom color. - * @param {(str: string) => string} color - The color function. - * @param {...unknown} data - The data to be logged. - */ - raw(color, ...data) { - console.debug(color(this.parseData(data))) - } - /** - * Formats a message with custom color. - * @param {(str: string) => string} color - The color function. - * @param {string} message - The message to be formatted. - * @returns {string} The formatted message. - */ - msg(color, message) { - return color(message) - } - parseData(...data) { - const ctx = data - .map((item) => (typeof item === 'object' ? JSON.stringify(item, null, 2) : String(item))) - .join(' ') - return `[${/* @__PURE__ */ new Date().toLocaleString()}] < ${this.session_id} > - ${ctx}` - } -} -var LOCALE_MODULE = class { - constructor() { - /** - * The root directory of the module. - * @type {string} - */ - __publicField(this, 'ROOT_DIRECTORY', __dirname) - /** - * Directories to be excluded from traversal. - * @type {string[]} - */ - __publicField(this, 'EXCLUDING_FOLDERS', [ - 'node_modules', - 'dist', - 'venv', - '.git', - '$git', - '$', - 'out', - 'bin' - ]) - /** - * Values to be excluded from file content search. - * @type {string[]} - */ - __publicField(this, 'EXCLUDING_VALUES', [ - 'FALCION', - 'PATTERNU', - 'PATTERNUGIT', - 'PATTERNUGIT.NET' - ]) - __publicField(this, 'LOGGER', new LOCALE_LOGGER()) - } - /** - * Updates the exclusion settings based on user input. - * @param {string[]} entries - Entries to be added to the exclusion list. - * @param {string} actions - User action (Y or N). - */ - update(entries, actions) { - if (actions.length > 1) { - throw new RangeError('Action input must be a char.') + get SETTING_AMBIENCE() { + if (this._SETTING_AMBIENCE == null) { + this._SETTING_AMBIENCE = new import_obsidian.Setting(this.containerEl) + .setName('Vault ambience:') + .setDesc( + "If enabled, plugin would try to apply your embed URL as audio for the vault's ambience." + ) + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.vault_ambience).onChange((value) => + __async(this, null, function* () { + const next = __spreadProps(__spreadValues({}, this.plugin.settings), { + vault_ambience: value + }) + yield this.plugin.updateSettings(next) + _SettingsConstructor.updateDisplays( + [this.SETTING_VAULT_PATH_TITLE, this.SETTING_VAULT_PATH_INPUT], + [value, value] + ) + }) + ) + return toggle + }) } - if (actions === 'Y') { - for (const entry of entries) { - this.EXCLUDING_VALUES.push(entry) + return this._SETTING_AMBIENCE + } + get SETTING_VAULT_PATH_TITLE() { + if (this._SETTING_VAULT_PATH_TITLE == null) { + this._SETTING_VAULT_PATH_TITLE = new import_obsidian.Setting(this.containerEl) + .setName("Path/URL to the audio of Vault's ambience (supports YouTube, SoundCloud):") + .setDesc( + "Be aware, that some videos on YouTube are blocked to be played as embed, so they may be not working in the Obsidian's Vault." + ) + } + return this._SETTING_VAULT_PATH_TITLE + } + get SETTING_VAULT_PATH_INPUT() { + if (this._SETTING_VAULT_PATH_INPUT == null) { + const result = new import_obsidian.TextAreaComponent(this.containerEl) + .setPlaceholder('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + .setValue(this.plugin.settings.vault_ambience_path) + .onChange((value) => + __async(this, null, function* () { + const next = __spreadValues({}, this.plugin.settings) + next.vault_ambience_path = value + yield this.plugin.updateSettings(next) + }) + ) + result.inputEl.addClass('input-field') + this._SETTING_VAULT_PATH_INPUT = result + } + return this._SETTING_VAULT_PATH_INPUT + } + get SETTING_AMBIENCE_PER_FILE() { + if (this._SETTING_AMBIENCE_PER_FILE == null) { + this._SETTING_AMBIENCE_PER_FILE = new import_obsidian.Setting(this.containerEl) + .setName('Ambience per files:') + .setDesc( + "If enabled, plugin would try to apply your embed URL as audio for the files's ambience specifically: meaning one file got one ambience and etc., prioritised over vault's ambience (vault ambience would stop playing)." + ) + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.ambience_per_files).onChange((value) => + __async(this, null, function* () { + const next = __spreadProps(__spreadValues({}, this.plugin.settings), { + ambience_per_files: value + }) + yield this.plugin.updateSettings(next) + }) + ) + return toggle + }) + } + return this._SETTING_AMBIENCE_PER_FILE + } + get SETTING_AMBIENCE_PER_FILE_COMMENT() { + if (this._SETTING_AMBIENCE_PER_FILE_COMMENT == null) { + this._SETTING_AMBIENCE_PER_FILE_COMMENT = new import_obsidian.Setting(this.containerEl) + .setName('') + .setDesc( + 'To apply custom ambience to file, insert into the metadata cache of the file (YAML) "music" variable with URL or path to your audio.' + ) + } + return this._SETTING_AMBIENCE_PER_FILE_COMMENT + } + get SETTING_MUSIC_VOLUME() { + if (this._SETTING_MUSICE_VOLUME == null) { + this._SETTING_MUSICE_VOLUME = new import_obsidian.Setting(this.containerEl) + .setName('Music volume') + .setDesc( + "Control volume of YouTube/Local audios. Audio from SoundCloud doesn't support this feature." + ) + .addSlider((slider) => { + slider + .setDynamicTooltip() + .setLimits( + this.plugin.settings.MIN_VOLUME, + this.plugin.settings.MAX_VOLUME, + this.plugin.settings.VOLUME_STEP + ) + .onChange((value) => + __async(this, null, function* () { + const next = __spreadProps(__spreadValues({}, this.plugin.settings), { + music_volume: value + }) + this.plugin.updateVolumeSettings(next) + }) + ) + }) + } + return this._SETTING_MUSICE_VOLUME + } + get SETTING_DEBUG_FRAMES() { + if (this._SETTING_DEBUG_FRAMES == null) { + this._SETTING_DEBUG_FRAMES = new import_obsidian.Setting(this.containerEl) + .setName('Debug frames:') + .setDesc( + 'Allows you to view frames generated by this plugin, meaning you can view mini-players for music/video.' + ) + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.debug_frames).onChange((value) => + __async(this, null, function* () { + const next = __spreadProps(__spreadValues({}, this.plugin.settings), { + debug_frames: value + }) + yield this.plugin.updateSettings(next) + }) + ) + }) + } + return this._SETTING_DEBUG_FRAMES + } + static updateDisplays(elements, values) { + if (elements.length !== values.length) { + throw new Error('Elements and their values both are out of range in some other ways.') + } + for (let i = 0; i < elements.length; i++) { + const element = elements[i] + if (element instanceof import_obsidian.Setting) + element.settingEl.style.display = values[i] ? 'block' : 'none' + if (element instanceof import_obsidian.TextAreaComponent) { + element.inputEl.style.display = values[i] ? 'block' : 'none' } } - if (actions === 'N') { - this.EXCLUDING_FOLDERS = entries + } + _updateState(data, prev, next) { + if (prev !== next) { + if (prev) { + if (this.defaults == null) { + this.defaults = { + color: data.inputEl.style.color, + borderColor: data.inputEl.style.borderColor, + borderWidth: data.inputEl.style.borderWidth + } + } + data.inputEl.style.color = 'red' + data.inputEl.style.borderColor = 'red' + data.inputEl.style.borderWidth = '4px' + } else if (this.defaults != null) { + data.inputEl.style.color = this.defaults.color + data.inputEl.style.borderColor = this.defaults.borderColor + data.inputEl.style.borderWidth = this.defaults.borderWidth + } } - if (CONFIG.USE_GITIGNORE) { - const gitignore = fs.readFileSync('.gitignore').toString().split('\n') - gitignore.forEach((line) => { - if (line[0] !== '#' && line[0] !== '!') { - this.EXCLUDING_FOLDERS.push(line) + } +} + +// source/settings.ts +var WhispererSettingsTab = class extends import_obsidian2.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin) + __publicField(this, 'plugin') + this.plugin = plugin + } + display() { + const { containerEl } = this + containerEl.empty() + const settingsConstructor = new SettingsConstructor(this.plugin, containerEl) + const items = [ + settingsConstructor.SETTING_AMBIENCE, + settingsConstructor.SETTING_VAULT_PATH_TITLE, + settingsConstructor.SETTING_VAULT_PATH_INPUT, + settingsConstructor.SETTING_AMBIENCE_PER_FILE, + settingsConstructor.SETTING_AMBIENCE_PER_FILE_COMMENT, + settingsConstructor.SETTING_DEBUG_FRAMES + ] + SettingsConstructor.updateDisplays( + [items[1], items[2]], + [this.plugin.settings.vault_ambience, this.plugin.settings.vault_ambience] + ) + } +} + +// source/settings/settings.ts +var DEFAULT_SETTINGS = { + vault_ambience: false, + vault_ambience_path: '', + ambience_per_files: true, + music_volume: 50, + debug_frames: false, + MIN_VOLUME: 0, + MAX_VOLUME: 100, + VOLUME_STEP: 1 +} + +// source/utils/functions.ts +function isUrl(str) { + return /^(https?:\/\/)/.test(str) +} +function getEmbedUrl(url) { + if (url.includes('youtube.com') || url.includes('youtu.be')) { + const videoId = url.includes('youtu.be') + ? url.split('/').pop() + : new URL(url).searchParams.get('v') + return `https://www.youtube.com/embed/${videoId}?autoplay=1&start=0` + } else if (new URL(url).host === 'soundcloud.com' || new URL(url).host === 'www.soundcloud.com') { + return `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=true` + } + throw new Error('Unsupported URL type.') +} +function extractId(url) { + const match = url.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|.*v=))([\w-]{11})/) + return match ? match[1] : '' +} + +// source/player/player_per_file.ts +var PlayerPerFile = class { + constructor(_plugin) { + __publicField(this, 'plugin') + this.plugin = _plugin + } + handleFileOpen(file) { + return __async(this, null, function* () { + var _a + if (file == null) return + if (!this.plugin.settings.ambience_per_files) return + const fileCache = this.plugin.app.metadataCache.getFileCache(file) + const musicPath = + (_a = fileCache == null ? void 0 : fileCache.frontmatter) == null ? void 0 : _a.music + if (musicPath) { + this.pauseVaultAmbience() + if (this.plugin.activeFile) { + this.pauseFileAmbience(this.plugin.activeFile) + } + this.plugin.activeFile = file.path + this.playFileAmbience(musicPath, file.path) + } else { + if (this.plugin.activeFile) { + this.pauseFileAmbience(this.plugin.activeFile) + } + if ( + document + .getElementsByClassName('obsidian-app')[0] + .querySelector('.vault-ambience-player') == null + ) { + if (this.plugin.settings.vault_ambience) this.plugin.playerPerGlobal.playAmbience() + } + } + }) + } + playFileAmbience(musicPath, filePath) { + const container = document.getElementsByClassName('obsidian-app')[0] + if (!container) return + const player = document.createElement('div') + player.className = 'file-ambience-player' + if (isUrl(musicPath)) { + if (musicPath.includes('youtube.com') || musicPath.includes('youtu.be')) { + const videoId = extractId(musicPath) + const iframe = document.createElement('iframe') + iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&loop=1&playlist=${videoId}` + iframe.width = '300' + iframe.height = '166' + iframe.allow = 'autoplay' + iframe.addClass(this.plugin.settings.debug_frames ? 'visible' : 'hidden-frame') + player.appendChild(iframe) + } else { + try { + const parsedUrl = new URL(musicPath) + const allowedHosts = ['soundcloud.com'] + if (allowedHosts.includes(parsedUrl.host)) { + const embedUrl = getEmbedUrl(musicPath) + const iframe = document.createElement('iframe') + iframe.src = embedUrl + iframe.width = '300' + iframe.height = '166' + iframe.allow = 'autoplay' + iframe.addClass(this.plugin.settings.debug_frames ? 'visible' : 'hidden-frame') + player.appendChild(iframe) + } + } catch (e) { + console.error('Invalid URL:', musicPath, e) + } + } + } else { + const audio = document.createElement('audio') + audio.src = this.plugin.app.vault.adapter.getResourcePath(musicPath) + audio.controls = true + audio.autoplay = true + audio.loop = true + audio.volume = (this.plugin.settings.music_volume || 50) / 100 + player.appendChild(audio) + audio.addEventListener('timeupdate', () => { + const playbackData = this.plugin.fileAmbiencePlayers.get(filePath) + if (playbackData != null) { + playbackData.position = audio.currentTime } }) } - fs.ensureFileSync(CONFIG.LOGS_FILE) + container.appendChild(player) + this.plugin.fileAmbiencePlayers.set(filePath, { player, position: 0 }) } - /** - * Searches for specified words in file contents. - * @param {string} filepath - The path of the file to search. - * @param {string[]} data - Words to search for. - * @returns {Promise} A promise representing the search operation. - */ - search(filepath, data) { - return __async(this, null, function* () { - const buffer = yield fs.readFile(filepath, { encoding: 'utf-8' }) - const stream = fs.createWriteStream(CONFIG.LOGS_FILE, { flags: 'a' }) - const contents = buffer.split(os.EOL) - for (let i = 0; i < contents.length; i++) { - const line = contents[i].toUpperCase() - for (const target of data) { - if (line.includes(target)) { - this.LOGGER.raw(colors.green, `Found "${target}" in L#${i} of: `) - this.LOGGER.raw(colors.cyan, filepath) - stream.write(`Found "${target}" in L#${i} of:` + os.EOL) - stream.write(` ${filepath}` + os.EOL) + pauseFileAmbience(filePath) { + var _a, _b + const playbackData = this.plugin.fileAmbiencePlayers.get(filePath) + if (playbackData == null) return + const { player } = playbackData + if (player instanceof HTMLAudioElement) { + playbackData.position = player.currentTime + player.pause() + } else if (player instanceof HTMLIFrameElement) { + if (player.src.includes('youtube.com') || player.src.includes('youtu.be')) { + ;(_a = player.contentWindow) == null + ? void 0 + : _a.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*') + } else { + try { + const parsedUrl = new URL(player.src) + const allowedHosts = ['soundcloud.com'] + if (allowedHosts.includes(parsedUrl.host)) { + ;(_b = player.contentWindow) == null + ? void 0 + : _b.postMessage(JSON.stringify({ method: 'pause' }), '*') } + } catch (e) { + console.error('Invalid URL in player.src:', player.src, e) } } - stream.end() - }) - } - /** - * Traverses directories and searches files for specified words. - * @param {string} directory - The directory to start traversal from. - * @returns {Promise} A promise representing the traversal operation. - */ - traverse() { - return __async(this, arguments, function* (directory = __dirname) { - try { - const items = yield fs.readdir(directory) - for (const item of items) { - const itempath = path.join(directory, item) - const itemstats = yield fs.stat(itempath) - if (itemstats.isDirectory()) { - if (!this.EXCLUDING_FOLDERS.includes(item)) { - yield this.traverse(itempath) - } - } else if (itemstats.isFile()) { - yield this.search(itempath, this.EXCLUDING_VALUES) - } else { - continue - } - } - } catch (err) { - this.LOGGER.error(err) - } - }) - } -} -var CONFIG = { - USE_GITIGNORE: true, - GITIGNORE_PATH: './.gitignore', - LOGS_FILE: `preparations-${/* @__PURE__ */ new Date().toLocaleDateString()}.logs` -} -var ask = (rl, question) => - __async(null, null, function* () { - return yield new Promise((resolve) => { - rl.question(question, resolve) - }) - }) -void (() => - __async(null, null, function* () { - const RL = readline.createInterface({ - input: process.stdin, - output: process.stdout - }) - try { - const finder = new LOCALE_MODULE() - const mode = yield ask(RL, colors.bgBlue(colors.yellow('Add custom entries (Y/N/IGNORE): '))) - if (mode.toUpperCase() === 'Y') { - const params = yield ask(RL, 'Enter parameters (comma-separated): ') - const diction = params.split(',').map((str) => str.trim()) - finder.update(diction, mode.toUpperCase()) - yield finder.traverse() - } else if (mode.toUpperCase() === 'N') { - yield finder.traverse() - } - } catch (error) { - console.error( - colors.red(typeof error === 'object' ? JSON.stringify(error, null, 2) : String(error)) - ) - } finally { - RL.close() } - }))() + player.remove() + this.plugin.fileAmbiencePlayers.delete(filePath) + } + pauseVaultAmbience() { + this.plugin.unapply() + } +} + +// source/player/factory/player_factory.ts +var PlayerFactory = class { + constructor(_plugin) { + __publicField(this, 'plugin') + this.plugin = _plugin + } + createPlayer(player, type) { + switch (type) { + case 'youtube': + case 'yt': + case 'ytube': + this.setupYouTubePlayer(player) + break + case 'sc': + case 'scloud': + case 'soundcloud': + this.setupSoundCloudPlayer(player) + break + case 'local': + case 'audio': + case 'fs': + this.setupLocalAudioPlayer(player) + break + default: + throw new Error('Got an unknown type of player!') + } + } + setupYouTubePlayer(player) { + const videoId = extractId(this.plugin.settings.vault_ambience_path) + const iframe = document.createElement('iframe') + iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&loop=1&enablejsapi=1&playlist=${videoId}` + iframe.width = '300' + iframe.height = '166' + iframe.allow = 'autoplay' + iframe.allowFullscreen = true + iframe.onload = () => { + var _a + const volume = this.plugin.settings.music_volume || 50 + ;(_a = iframe.contentWindow) == null + ? void 0 + : _a.postMessage( + JSON.stringify({ + event: 'command', + func: 'setVolume', + args: [volume] + }), + '*' + ) + } + player.appendChild(iframe) + } + setupSoundCloudPlayer(player) { + const embedUrl = getEmbedUrl(this.plugin.settings.vault_ambience_path) + const apiEnabledUrl = `${embedUrl}&visual=true&show_artwork=false` + const iframe = document.createElement('iframe') + iframe.src = apiEnabledUrl + iframe.width = '300' + iframe.height = '166' + iframe.allow = 'autoplay' + player.appendChild(iframe) + } + setupLocalAudioPlayer(player) { + const audio = document.createElement('audio') + audio.src = this.plugin.app.vault.adapter.getResourcePath( + this.plugin.settings.vault_ambience_path + ) + audio.controls = true + audio.autoplay = true + audio.loop = true + audio.volume = (this.plugin.settings.music_volume || 50) / 100 + audio.addEventListener('volumechange', () => { + this.plugin.settings.music_volume = audio.volume * 100 + this.plugin.saveData(this.plugin.settings) + }) + player.appendChild(audio) + } +} + +// source/player/player_per_global.ts +var PlayerPerGlobal = class { + constructor(_plugin) { + __publicField(this, 'plugin') + this.plugin = _plugin + } + playAmbience() { + const container = document.getElementsByClassName('obsidian-app')[0] + if (!container) return + let player = container.querySelector('.vault-ambience-player') + if (player != null) player.remove() + if (container.getElementsByClassName('file-ambience-player').length > 0) return + player = document.createElement('div') + player.className = 'vault-ambience-player' + player.addClass(this.plugin.settings.debug_frames ? 'visible' : 'hidden-frame') + if (isUrl(this.plugin.settings.vault_ambience_path)) { + const url = new URL(this.plugin.settings.vault_ambience_path) + const allowedYouTubeHosts = ['youtube.com', 'youtu.be'] + const allowedSoundCloudHosts = ['soundcloud.com'] + const playerFactory = new PlayerFactory(this.plugin) + if (allowedYouTubeHosts.includes(url.host)) { + playerFactory.createPlayer(player, 'yt') + } else if (allowedSoundCloudHosts.includes(url.host)) { + playerFactory.createPlayer(player, 'sc') + } + playerFactory.createPlayer(player, 'local') + } + container.appendChild(player) + this.plugin.players.push(player) + } +} + +// source/main.ts +var Whisperer = class _Whisperer extends import_obsidian3.Plugin { + constructor() { + super(...arguments) + __publicField(this, '_settings', DEFAULT_SETTINGS) + __publicField(this, 'players', []) + __publicField(this, 'fileAmbiencePlayers', /* @__PURE__ */ new Map()) + __publicField(this, 'activeFile', null) + __publicField(this, 'playerPerFile', new PlayerPerFile(this)) + __publicField(this, 'playerPerGlobal', new PlayerPerGlobal(this)) + } + get settings() { + return this._settings + } + onload() { + return __async(this, null, function* () { + yield __superGet(_Whisperer.prototype, this, 'onload').call(this) + yield this.loadSettings() + this.addSettingTab(new WhispererSettingsTab(this.app, this)) + this.registerEvent( + this.app.workspace.on('file-open', this.playerPerFile.handleFileOpen.bind(this)) + ) + const container = document.getElementsByClassName('obsidian-app')[0] + const script = document.createElement('script') + script.src = 'https://w.soundcloud.com/player/api.js' + script.id = 'whisperer-md-sc-widget' + container.appendChild(script) + this.apply() + }) + } + onunload() { + return __async(this, null, function* () { + __superGet(_Whisperer.prototype, this, 'onunload').call(this) + const script = document.getElementById('whisperer-md-sc-widget') + if (script != null) { + script.src = '' + script.remove() + } + this.unapply() + }) + } + loadSettings() { + return __async(this, null, function* () { + this._settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData()) + }) + } + updateSettings(settings) { + return __async(this, null, function* () { + this.unapply() + this._settings = settings + yield this.saveData(this.settings) + this.apply() + }) + } + updateVolumeSettings(settings) { + return __async(this, null, function* () { + if (this.players.length === 0) { + yield this.updateSettings(settings) + } else { + this._settings = settings + yield this.saveData(this.settings) + this.players.forEach((player) => { + var _a, _b + if (player.tagName === 'IFRAME') { + const iframe = player + try { + const url = new URL(iframe.src) + const allowedYouTubeHosts = ['youtube.com', 'www.youtube.com', 'youtu.be'] + if (allowedYouTubeHosts.includes(url.hostname)) { + ;(_a = iframe.contentWindow) == null + ? void 0 + : _a.postMessage( + JSON.stringify({ + event: 'command', + func: 'setVolume', + args: [this.settings.music_volume] + }), + '*' + ) + } + } catch (e) { + console.error('Invalid iframe src URL:', iframe.src, e) + } + try { + const url = new URL(iframe.src) + const allowedSoundCloudHosts = ['soundcloud.com', 'w.soundcloud.com'] + if (allowedSoundCloudHosts.includes(url.hostname)) { + ;(_b = iframe.contentWindow) == null + ? void 0 + : _b.postMessage( + JSON.stringify({ + method: 'setVolume', + value: this.settings.music_volume / 100 + }), + 'https://w.soundcloud.com' + ) + } + } catch (e) { + console.error('Invalid iframe src URL:', iframe.src, e) + } + } else if (player.tagName === 'AUDIO') { + const audio = player + audio.volume = (this.settings.music_volume | 50) / 100 + } + }) + } + }) + } + updateVisibleSettings(settings) { + return __async(this, null, function* () { + if (this.players.length === 0) { + yield this.updateSettings(settings) + } else { + this._settings = settings + yield this.saveData(this.settings) + this.players.forEach((player) => { + player.removeClass(this.settings.debug_frames ? 'hidden-frame' : 'visible') + player.addClass(this.settings.debug_frames ? 'visible' : 'hidden-frame') + }) + } + }) + } + unapply() { + this.players.forEach((player) => { + player.remove() + const iframe = player.querySelector('iframe') + if (iframe != null) { + iframe.src = '' + } + }) + this.players = [] + for (const playerData of this.fileAmbiencePlayers.values()) { + playerData.player.remove() + } + } + apply() { + if (this.settings.vault_ambience) this.playerPerGlobal.playAmbience() + } +} +//! Volume adjustment is not directly supported by SoundCloud embedded players. diff --git a/jest.config.mjs b/jest.config.mjs new file mode 100644 index 0000000..f738480 --- /dev/null +++ b/jest.config.mjs @@ -0,0 +1,12 @@ +// jest.config.mjs +/** @type {import('jest').Config} */ +export default { + preset: 'ts-jest', + testEnvironment: 'jsdom', // requires `npm install --save-dev jest-environment-jsdom` + moduleNameMapper: { + '^obsidian$': '/__mocks__/obsidian.ts', + '^source/(.*)$': '/source/$1' + }, + setupFilesAfterEnv: ['/jest.setup.js'], + extensionsToTreatAsEsm: ['.ts'] // if you need to treat .ts as ESM :contentReference[oaicite:3]{index=3} +} diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..0fd5e72 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,17 @@ +HTMLMediaElement.prototype.pause = function () { + /* no-op */ +} +HTMLMediaElement.prototype.play = function () { + /* no-op */ +} + +HTMLElement.prototype.empty = function () { + while (this.firstChild) this.removeChild(this.firstChild) +} + +HTMLElement.prototype.addClass = function (c) { + this.classList.add(c) +} +HTMLElement.prototype.removeClass = function (c) { + this.classList.remove(c) +} diff --git a/package-lock.json b/package-lock.json index cb916e9..91e9215 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 9b97288..8536950 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "prepare:python-root": "python3 scripts/python/clonevenv.py", "prepare:template": "ts-node index.ts", "update-analytics": "ts-node scripts/js/analytics-workflow.ts", - "test": "mocha --timeout 10000 --import=tsx test/**/*.test.ts --reporter mocha-github-actions-reporter --exit", + "test": "jest --coverage", "buildtsc": "tsc", - "build": "tsc -noEmit -skipLibCheck source/main.ts && node esbuild.config.mjs production", + "build": "tsc -noEmit -skipLibCheck --downlevelIteration source/main.ts && node esbuild.config.mjs production", "pack": "python scripts/python/pack.py", "release-tags": "cd scripts/python && python update_versions_table.py", "release-utilities": "cd scripts/js && ts-node utilities.ts", @@ -235,6 +235,7 @@ "husky": "^9.1.7", "imagemin-lint-staged": "^0.5.1", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "jscpd": "^4.0.5", "jsdom": "^26.1.0", "lint": "^1.1.2", @@ -279,14 +280,16 @@ "ignore": [ "**/out/", "**/lib/*", - "**/venv/" + "**/venv/", + "**/jest.setup.js" ] }, "ts-standard": { "ignore": [ "**/out/", "**/lib/*", - "**/venv/" + "**/venv/", + "**/jest.setup.ts" ] } } diff --git a/test/functions.test.ts b/test/functions.test.ts new file mode 100644 index 0000000..1a90814 --- /dev/null +++ b/test/functions.test.ts @@ -0,0 +1,46 @@ +import { isUrl, getEmbedUrl, extractId } from 'source/utils/functions' + +describe('Utils › functions', () => { + describe('isUrl()', () => { + it('returns true for http(s) URLs', () => { + expect(isUrl('https://example.com')).toBe(true) + expect(isUrl('http://foo.bar')).toBe(true) + }) + it('returns false otherwise', () => { + expect(isUrl('ftp://example.com')).toBe(false) + expect(isUrl('')).toBe(false) + }) + }) + + describe('getEmbedUrl()', () => { + it('embeds youtube.com URLs', () => { + expect(getEmbedUrl('https://www.youtube.com/watch?v=ABCDEFGHIJK')) + .toBe('https://www.youtube.com/embed/ABCDEFGHIJK?autoplay=1&start=0') + }) + it('embeds youtu.be URLs', () => { + expect(getEmbedUrl('https://youtu.be/ABCDEFGHIJK')) + .toBe('https://www.youtube.com/embed/ABCDEFGHIJK?autoplay=1&start=0') + }) + it('embeds soundcloud URLs', () => { + const url = 'https://soundcloud.com/artist/track' + const out = getEmbedUrl(url) + expect(out).toContain('w.soundcloud.com/player') + expect(out).toContain(encodeURIComponent(url)) + }) + it('throws on unsupported URLs', () => { + expect(() => getEmbedUrl('https://vimeo.com/123')).toThrow('Unsupported URL type.') + }) + }) + + describe('extractId()', () => { + it('extracts from youtube.com', () => { + expect(extractId('https://www.youtube.com/watch?v=ABCDEFGHIJK')).toBe('ABCDEFGHIJK') + }) + it('extracts from youtu.be', () => { + expect(extractId('https://youtu.be/ABCDEFGHIJK')).toBe('ABCDEFGHIJK') + }) + it('returns empty string when no ID present', () => { + expect(extractId('https://example.com')).toBe('') + }) + }) +}) diff --git a/test/logger.test.ts b/test/logger.test.ts deleted file mode 100644 index 3e4f4c5..0000000 --- a/test/logger.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect } from 'chai' -import { LOCALE_LOGGER } from 'index' -import sinon from 'sinon' -import colors from 'colors/safe' - -describe('LOCALE_LOGGER', () => { - let logger: LOCALE_LOGGER - let consoleStub: sinon.SinonStub - - beforeEach(() => { - logger = new LOCALE_LOGGER() - consoleStub = sinon.stub(console, 'info') - }) - - afterEach(() => { - consoleStub.restore() - }) - - it('should format info messages correctly', () => { - logger.info('Test message', 123, { key: 'value' }, {}) - - const output = consoleStub.firstCall.args[0] - expect(output).to.include('Test message') - expect(output).to.include('123') - expect(output).to.include(colors.blue('')) - expect(output).to.include('{}') - }) -}) diff --git a/test/module.test.ts b/test/module.test.ts deleted file mode 100644 index 52d3dc9..0000000 --- a/test/module.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect } from 'chai' -import LOCALE_MODULE from 'index' -import mock from 'mock-fs' -import fs from 'fs-extra' - -describe('LOCALE_MODULE', () => { - let module: LOCALE_MODULE - - beforeEach(() => { - mock({ - 'test-dir': { - 'file1.txt': 'PATTERNU should be found', - subdir: { - 'file2.ts': '// PATTERNU in comment' - }, - '.gitignore': 'subdir\n' - } - }) - - module = new LOCALE_MODULE('test-dir/test-logs.log') - module.ROOT_DIRECTORY = 'test-dir' - }) - - it('should find excluded patterns', async () => { - await module.traverse() - const logContent = fs.readFileSync(module.CONFIG.LOGS_FILE, 'utf-8') - expect(logContent).to.include('PATTERNU') - }) -}) diff --git a/test/player_factory.test.ts b/test/player_factory.test.ts new file mode 100644 index 0000000..1e1f5f5 --- /dev/null +++ b/test/player_factory.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-undef */ +/* eslint-env browser, jest */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import PlayerFactory from 'source/player/factory/player_factory' +import Whisperer from 'source/main' + +describe('Factory › PlayerFactory', () => { + let plugin: Whisperer, container: HTMLElement + let factory: PlayerFactory + + beforeEach(() => { + container = document.createElement('div') + plugin = new Whisperer({} as any, {} as any) + plugin.settings.vault_ambience_path = 'https://youtu.be/ABCDEF12345' + plugin.settings.music_volume = 30 + // stub local resource path + plugin.app = { vault: { adapter: { getResourcePath: (p: string) => '/res/' + p } } } as any + factory = new PlayerFactory(plugin) + }) + + it('creates YouTube iframe', () => { + factory.createPlayer(container, 'youtube') + const iframe = container.querySelector('iframe')! + expect(iframe.src).toContain('embed/ABCDEF12345') + expect(iframe.allow).toBe('autoplay') + }) + + it('creates SoundCloud iframe', () => { + plugin.settings.vault_ambience_path = 'https://soundcloud.com/x/y' + factory.createPlayer(container, 'sc') + const iframe = container.querySelector('iframe')! + expect(iframe.src).toContain('w.soundcloud.com/player') + }) + + it('creates local audio element', () => { + factory.createPlayer(container, 'local') + const audio = container.querySelector('audio')! + expect(audio.src).toContain('/res/' + plugin.settings.vault_ambience_path) + expect(audio.volume).toBeCloseTo(0.3) + }) + + it('throws on unknown type', () => { + expect(() => factory.createPlayer(container, 'unknown')).toThrow() + }) +}) diff --git a/test/player_per_file.test.ts b/test/player_per_file.test.ts new file mode 100644 index 0000000..ef39eee --- /dev/null +++ b/test/player_per_file.test.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-undef */ +/* eslint-env browser, jest */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import PlayerPerFile from 'source/player/player_per_file' +import Whisperer from 'source/main' +import { TFile } from 'obsidian' + +describe('PlayerPerFile', () => { + let plugin: Whisperer, handler: PlayerPerFile + + beforeEach(() => { + document.body.innerHTML = '
' + + const fakeApp = { + metadataCache: { + getFileCache: jest.fn().mockReturnValue({ + frontmatter: { music: 'https://youtu.be/XXX111YYY22' } + }) + }, + vault: { + adapter: { getResourcePath: (p: string) => 'r/' + p } + }, + workspace: { on: jest.fn() } + } as any + + plugin = new Whisperer(fakeApp, {} as any) + plugin.settings.ambience_per_files = true + handler = new PlayerPerFile(plugin) + }) + + it('plays a new file ambience', async () => { + await handler.handleFileOpen({ path: 'file1.md' } as TFile) + expect(plugin.activeFile).toBe('file1.md') + expect(document.querySelector('.file-ambience-player')).toBeTruthy() + }) + + it('pauses previous and vault ambience', async () => { + plugin.activeFile = 'old.md' + plugin.fileAmbiencePlayers.set('old.md', { + player: document.createElement('audio'), + position: 0 + }) + plugin.settings.vault_ambience = true + const spy = jest.spyOn(plugin, 'unapply') + await handler.handleFileOpen({ path: 'new.md' } as TFile) + expect(spy).toHaveBeenCalled() + expect(plugin.fileAmbiencePlayers.has('old.md')).toBe(false) + }) + + it('does nothing when ambience_per_files = false', async () => { + plugin.settings.ambience_per_files = false + const spy = jest.spyOn(handler, 'playFileAmbience') + await handler.handleFileOpen({ path: 'x.md' } as TFile) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/test/player_per_global.test.ts b/test/player_per_global.test.ts new file mode 100644 index 0000000..ddd1f23 --- /dev/null +++ b/test/player_per_global.test.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-undef */ +/* eslint-env browser, jest */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import PlayerPerGlobal from 'source/player/player_per_global' +import Whisperer from 'source/main' + +describe('PlayerPerGlobal', () => { + let plugin: Whisperer + + beforeEach(() => { + document.body.innerHTML = '
' + plugin = new Whisperer({} as any, {} as any) + plugin.settings.vault_ambience = true + plugin.settings.vault_ambience_path = 'https://youtu.be/ZZZZZ123456' + // stub local fallback + plugin.app.vault = { adapter: { getResourcePath: (p: string) => '/m/' + p } } as any + }) + + it('appends vault ambience player', () => { + new PlayerPerGlobal(plugin).playAmbience() + const nodes = document.querySelectorAll('.vault-ambience-player') + expect(nodes.length).toBe(1) + expect(plugin.players).toContainEqual(nodes[0] as HTMLElement) + }) + + it('does not duplicate when file ambience exists', () => { + document.querySelector('.obsidian-app')!.innerHTML = + '
' + new PlayerPerGlobal(plugin).playAmbience() + expect(document.querySelectorAll('.vault-ambience-player').length).toBe(0) + }) +}) diff --git a/test/settings_tab.test.ts b/test/settings_tab.test.ts new file mode 100644 index 0000000..fa21ee3 --- /dev/null +++ b/test/settings_tab.test.ts @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-undef */ +/* eslint-env browser, jest */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import WhispererSettingsTab from 'source/settings' +import Whisperer from 'source/main' +import { App } from 'obsidian' + +describe('Settings Tab', () => { + let plugin: Whisperer, tab: WhispererSettingsTab, container: HTMLElement + + beforeEach(() => { + container = document.createElement('div') + container.empty() // from jest.setup.js + + plugin = new Whisperer({} as any, {} as any) + tab = new WhispererSettingsTab({} as App, plugin); + // override the tab's containerEl + (tab as any).containerEl = container + }) + + it('renders at least one setting item', () => { + tab.display() + const items = container.querySelectorAll('.setting-item') + expect(items.length).toBeGreaterThan(0) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index f998370..016a583 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ "sourceMap": false }, "exclude": ["node_modules", "out", "git", "venv"], - "include": ["*.ts", "**/*.ts"] + "include": ["*.ts", "**/*.ts", "jest.setup.ts"] } diff --git a/typings/index.d.ts b/typings/index.d.ts index 10a2f50..6f50c16 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -28,32 +28,32 @@ declare module '@patternu/preparation' { * Log informational message * @param data Flexible arguments to log */ - info(...data: unknown[]): void + info (...data: unknown[]): void /** * Log warning message * @param data Flexible arguments to log */ - warn(...data: unknown[]): void + warn (...data: unknown[]): void /** * Log error message * @param data Flexible arguments to log */ - error(...data: unknown[]): void + error (...data: unknown[]): void /** * Log success message * @param data Flexible arguments to log */ - success(...data: unknown[]): void + success (...data: unknown[]): void /** * Raw formatted log output * @param color Colorization function * @param data Flexible arguments to log */ - raw(color: (str: string) => string, ...data: unknown[]): void + raw (color: (str: string) => string, ...data: unknown[]): void /** * Create colored message string @@ -61,7 +61,7 @@ declare module '@patternu/preparation' { * @param message Message to format * @returns Colored message string */ - msg(color: (str: string) => string, message: string): string + msg (color: (str: string) => string, message: string): string } /** @@ -72,7 +72,7 @@ declare module '@patternu/preparation' { public ROOT_DIRECTORY: string /** Logger instance */ public readonly LOGGER: LOCALE_LOGGER - private EXCLUDING_FOLDERS: string[] + private readonly EXCLUDING_FOLDERS: string[] private readonly EXCLUDING_VALUES: string[] /** @@ -80,20 +80,20 @@ declare module '@patternu/preparation' { * @param entries Entries to add to exclusion lists * @param actions Update action type (Y/N) */ - public update(entries: string[], actions: string): void + public update (entries: string[], actions: string): void /** * Search file contents for target strings * @param filepath Path to search * @param data Target strings to find */ - public search(filepath: string, data: string[]): Promise + public search (filepath: string, data: string[]): Promise /** * Recursive directory traversal * @param directory Starting directory */ - public traverse(directory?: string): Promise + public traverse (directory?: string): Promise } /** @@ -102,5 +102,5 @@ declare module '@patternu/preparation' { * @param question Prompt to display * @returns Promise resolving to user input */ - export function ask(rl: readline.Interface, question: string): Promise + export function ask (rl: readline.Interface, question: string): Promise }