diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2018-07-28 20:05:06 +0900 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-07-28 20:05:06 +0900 |
commit | ed2bd7d75ee1e7aa1db7d03c3f908c740ded1983 (patch) | |
tree | 6ac3f5ac5126e1a07c958549c782aedd586c6534 /src/background/usecases | |
parent | 84a9655bb39e5902b417e124a0eb23d80808a6a7 (diff) | |
parent | 4bd2084ba7b23327c26a2d8b24dc4169c14bfa17 (diff) |
Merge pull request #440 from ueokande/background-clean-architecture
Background clean architecture
Diffstat (limited to 'src/background/usecases')
-rw-r--r-- | src/background/usecases/addon-enabled.js | 29 | ||||
-rw-r--r-- | src/background/usecases/command.js | 108 | ||||
-rw-r--r-- | src/background/usecases/completions.js | 150 | ||||
-rw-r--r-- | src/background/usecases/filters.js | 72 | ||||
-rw-r--r-- | src/background/usecases/find.js | 15 | ||||
-rw-r--r-- | src/background/usecases/link.js | 27 | ||||
-rw-r--r-- | src/background/usecases/operation.js | 192 | ||||
-rw-r--r-- | src/background/usecases/parsers.js | 55 | ||||
-rw-r--r-- | src/background/usecases/setting.js | 31 | ||||
-rw-r--r-- | src/background/usecases/version.js | 41 |
10 files changed, 720 insertions, 0 deletions
diff --git a/src/background/usecases/addon-enabled.js b/src/background/usecases/addon-enabled.js new file mode 100644 index 0000000..d83192f --- /dev/null +++ b/src/background/usecases/addon-enabled.js @@ -0,0 +1,29 @@ +import IndicatorPresenter from '../presenters/indicator'; +import TabPresenter from '../presenters/tab'; +import ContentMessageClient from '../infrastructures/content-message-client'; + +export default class AddonEnabledInteractor { + constructor() { + this.indicatorPresentor = new IndicatorPresenter(); + + this.indicatorPresentor.onClick(tab => this.onIndicatorClick(tab.id)); + + this.tabPresenter = new TabPresenter(); + this.tabPresenter.onSelected(info => this.onTabSelected(info.tabId)); + + this.contentMessageClient = new ContentMessageClient(); + } + + indicate(enabled) { + return this.indicatorPresentor.indicate(enabled); + } + + onIndicatorClick(tabId) { + return this.contentMessageClient.toggleAddonEnabled(tabId); + } + + async onTabSelected(tabId) { + let enabled = await this.contentMessageClient.getAddonEnabled(tabId); + return this.indicatorPresentor.indicate(enabled); + } +} diff --git a/src/background/usecases/command.js b/src/background/usecases/command.js new file mode 100644 index 0000000..3dbf768 --- /dev/null +++ b/src/background/usecases/command.js @@ -0,0 +1,108 @@ +import * as parsers from './parsers'; +import TabPresenter from '../presenters/tab'; +import WindowPresenter from '../presenters/window'; +import SettingRepository from '../repositories/setting'; +import BookmarkRepository from '../repositories/bookmark'; +import ConsolePresenter from '../presenters/console'; +import ContentMessageClient from '../infrastructures/content-message-client'; +import * as properties from 'shared/settings/properties'; + +export default class CommandIndicator { + constructor() { + this.tabPresenter = new TabPresenter(); + this.windowPresenter = new WindowPresenter(); + this.settingRepository = new SettingRepository(); + this.bookmarkRepository = new BookmarkRepository(); + this.consolePresenter = new ConsolePresenter(); + + this.contentMessageClient = new ContentMessageClient(); + } + + async open(keywords) { + let url = await this.urlOrSearch(keywords); + return this.tabPresenter.open(url); + } + + async tabopen(keywords) { + let url = await this.urlOrSearch(keywords); + return this.tabPresenter.create(url); + } + + async winopen(keywords) { + let url = await this.urlOrSearch(keywords); + return this.windowPresenter.create(url); + } + + async buffer(keywords) { + if (keywords.length === 0) { + return; + } + if (!isNaN(keywords)) { + let index = parseInt(keywords, 10) - 1; + return tabs.selectAt(index); + } + + let current = await this.tabPresenter.getCurrent(); + let tabs = await this.tabPresenter.getByKeyword(keywords); + if (tabs.length === 0) { + throw new RangeError('No matching buffer for ' + keywords); + } + for (let tab of tabs) { + if (tab.index > current.index) { + return this.tabPresenter.select(tab.id); + } + } + return this.tabPresenter.select(tabs[0].id); + } + + async bdelete(force, keywords) { + let excludePinned = !force; + let tabs = await this.tabPresenter.getByKeyword(keywords, excludePinned); + if (tabs.length === 0) { + throw new Error('No matching buffer for ' + keywords); + } else if (tabs.length > 1) { + throw new Error('More than one match for ' + keywords); + } + return this.tabPresenter.remove([tabs[0].id]); + } + + async bdeletes(force, keywords) { + let excludePinned = !force; + let tabs = await this.tabPresenter.getByKeyword(keywords, excludePinned); + let ids = tabs.map(tab => tab.id); + return this.tabPresenter.remove(ids); + } + + async quit() { + let tab = await this.tabPresenter.getCurrent(); + return this.tabPresenter.remove([tab.id]); + } + + async quitAll() { + let tabs = await this.tabPresenter.getAll(); + let ids = tabs.map(tab => tab.id); + this.tabPresenter.remove(ids); + } + + async addbookmark(title) { + let tab = await this.tabPresenter.getCurrent(); + let item = await this.bookmarkRepository.create(title, tab.url); + let message = 'Saved current page: ' + item.url; + return this.consolePresenter.showInfo(tab.id, message); + } + + async set(keywords) { + if (keywords.length === 0) { + return; + } + let [name, value] = parsers.parseSetOption(keywords, properties.types); + await this.settingRepository.setProperty(name, value); + + return this.contentMessageClient.broadcastSettingsChanged(); + } + + async urlOrSearch(keywords) { + let settings = await this.settingRepository.get(); + return parsers.normalizeUrl(keywords, settings.search); + } +} diff --git a/src/background/usecases/completions.js b/src/background/usecases/completions.js new file mode 100644 index 0000000..2bf5b3b --- /dev/null +++ b/src/background/usecases/completions.js @@ -0,0 +1,150 @@ +import CompletionItem from '../domains/completion-item'; +import CompletionGroup from '../domains/completion-group'; +import Completions from '../domains/completions'; +import CommandDocs from '../domains/command-docs'; +import CompletionRepository from '../repositories/completions'; +import * as filters from './filters'; +import SettingRepository from '../repositories/setting'; +import * as properties from '../../shared/settings/properties'; + +const COMPLETION_ITEM_LIMIT = 10; + +export default class CompletionsInteractor { + constructor() { + this.completionRepository = new CompletionRepository(); + this.settingRepository = new SettingRepository(); + } + + queryConsoleCommand(prefix) { + let keys = Object.keys(CommandDocs); + let items = keys + .filter(name => name.startsWith(prefix)) + .map(name => ({ + caption: name, + content: name, + url: CommandDocs[name], + })); + + if (items.length === 0) { + return Promise.resolve(Completions.empty()); + } + return Promise.resolve( + new Completions([new CompletionGroup('Console Command', items)]) + ); + } + + async queryOpen(name, keywords) { + let groups = []; + let engines = await this.querySearchEngineItems(name, keywords); + if (engines.length > 0) { + groups.push(new CompletionGroup('Search Engines', engines)); + } + let histories = await this.queryHistoryItems(name, keywords); + if (histories.length > 0) { + groups.push(new CompletionGroup('History', histories)); + } + let bookmarks = await this.queryBookmarkItems(name, keywords); + if (bookmarks.length > 0) { + groups.push(new CompletionGroup('Bookmarks', bookmarks)); + } + return new Completions(groups); + } + + queryBuffer(name, keywords) { + return this.queryTabs(name, false, keywords); + } + + queryBdelete(name, keywords) { + return this.queryTabs(name, true, keywords); + } + + queryBdeleteForce(name, keywords) { + return this.queryTabs(name, false, keywords); + } + + querySet(name, keywords) { + let items = Object.keys(properties.docs).map((key) => { + if (properties.types[key] === 'boolean') { + return [ + new CompletionItem({ + caption: key, + content: name + ' ' + key, + url: 'Enable ' + properties.docs[key], + }), + new CompletionItem({ + caption: 'no' + key, + content: name + ' no' + key, + url: 'Disable ' + properties.docs[key], + }), + ]; + } + return [ + new CompletionItem({ + caption: key, + content: name + ' ' + key, + url: 'Set ' + properties.docs[key], + }) + ]; + }); + items = items.reduce((acc, val) => acc.concat(val), []); + items = items.filter((item) => { + return item.caption.startsWith(keywords); + }); + if (items.length === 0) { + return Promise.resolve(Completions.empty()); + } + return Promise.resolve( + new Completions([new CompletionGroup('Properties', items)]) + ); + } + + async queryTabs(name, excludePinned, args) { + let tabs = await this.completionRepository.queryTabs(args, excludePinned); + let items = tabs.map(tab => new CompletionItem({ + caption: tab.title, + content: name + ' ' + tab.title, + url: tab.url, + icon: tab.favIconUrl + })); + if (items.length === 0) { + return Promise.resolve(Completions.empty()); + } + return new Completions([new CompletionGroup('Buffers', items)]); + } + + async querySearchEngineItems(name, keywords) { + let settings = await this.settingRepository.get(); + let engines = Object.keys(settings.search.engines) + .filter(key => key.startsWith(keywords)); + return engines.map(key => new CompletionItem({ + caption: key, + content: name + ' ' + key, + })); + } + + async queryHistoryItems(name, keywords) { + let histories = await this.completionRepository.queryHistories(keywords); + histories = [histories] + .map(filters.filterBlankTitle) + .map(filters.filterHttp) + .map(filters.filterByTailingSlash) + .map(pages => filters.filterByPathname(pages, COMPLETION_ITEM_LIMIT)) + .map(pages => filters.filterByOrigin(pages, COMPLETION_ITEM_LIMIT))[0] + .sort((x, y) => x.visitCount < y.visitCount) + .slice(0, COMPLETION_ITEM_LIMIT); + return histories.map(page => new CompletionItem({ + caption: page.title, + content: name + ' ' + page.url, + url: page.url + })); + } + + async queryBookmarkItems(name, keywords) { + let bookmarks = await this.completionRepository.queryBookmarks(keywords); + return bookmarks.map(page => new CompletionItem({ + caption: page.title, + content: name + ' ' + page.url, + url: page.url + })); + } +} diff --git a/src/background/usecases/filters.js b/src/background/usecases/filters.js new file mode 100644 index 0000000..d057dca --- /dev/null +++ b/src/background/usecases/filters.js @@ -0,0 +1,72 @@ +const filterHttp = (items) => { + let httpsHosts = items.map(x => new URL(x.url)) + .filter(x => x.protocol === 'https:') + .map(x => x.host); + httpsHosts = new Set(httpsHosts); + + return items.filter((item) => { + let url = new URL(item.url); + return url.protocol === 'https:' || !httpsHosts.has(url.host); + }); +}; + +const filterBlankTitle = (items) => { + return items.filter(item => item.title && item.title !== ''); +}; + +const filterByTailingSlash = (items) => { + let urls = items.map(item => new URL(item.url)); + let simplePaths = urls + .filter(url => url.hash === '' && url.search === '') + .map(url => url.origin + url.pathname); + simplePaths = new Set(simplePaths); + + return items.filter((item) => { + let url = new URL(item.url); + if (url.hash !== '' || url.search !== '' || + url.pathname.slice(-1) !== '/') { + return true; + } + return !simplePaths.has(url.origin + url.pathname.slice(0, -1)); + }); +}; + +const filterByPathname = (items, min) => { + let hash = {}; + for (let item of items) { + let url = new URL(item.url); + let pathname = url.origin + url.pathname; + if (!hash[pathname]) { + hash[pathname] = item; + } else if (hash[pathname].url.length > item.url.length) { + hash[pathname] = item; + } + } + let filtered = Object.values(hash); + if (filtered.length < min) { + return items; + } + return filtered; +}; + +const filterByOrigin = (items, min) => { + let hash = {}; + for (let item of items) { + let origin = new URL(item.url).origin; + if (!hash[origin]) { + hash[origin] = item; + } else if (hash[origin].url.length > item.url.length) { + hash[origin] = item; + } + } + let filtered = Object.values(hash); + if (filtered.length < min) { + return items; + } + return filtered; +}; + +export { + filterHttp, filterBlankTitle, filterByTailingSlash, + filterByPathname, filterByOrigin +}; diff --git a/src/background/usecases/find.js b/src/background/usecases/find.js new file mode 100644 index 0000000..eae480d --- /dev/null +++ b/src/background/usecases/find.js @@ -0,0 +1,15 @@ +import FindRepository from '../repositories/find'; + +export default class FindInteractor { + constructor() { + this.findRepository = new FindRepository(); + } + + getKeyword() { + return this.findRepository.getKeyword(); + } + + setKeyword(keyword) { + return this.findRepository.setKeyword(keyword); + } +} diff --git a/src/background/usecases/link.js b/src/background/usecases/link.js new file mode 100644 index 0000000..f9e474a --- /dev/null +++ b/src/background/usecases/link.js @@ -0,0 +1,27 @@ +import SettingRepository from '../repositories/setting'; +import TabPresenter from '../presenters/tab'; + +export default class LinkInteractor { + constructor() { + this.settingRepository = new SettingRepository(); + this.tabPresenter = new TabPresenter(); + } + + openToTab(url, tabId) { + this.tabPresenter.open(url, tabId); + + } + + async openNewTab(url, openerId, background) { + let settings = await this.settingRepository.get(); + let { adjacenttab } = settings.properties; + if (adjacenttab) { + return this.tabPresenter.create(url, { + openerTabId: openerId, active: !background + }); + } + return this.tabPresenter.create(url, { + openerTabId: openerId, active: !background + }); + } +} diff --git a/src/background/usecases/operation.js b/src/background/usecases/operation.js new file mode 100644 index 0000000..86f39ca --- /dev/null +++ b/src/background/usecases/operation.js @@ -0,0 +1,192 @@ +import MemoryStorage from '../infrastructures/memory-storage'; +import TabPresenter from '../presenters/tab'; +import ConsolePresenter from '../presenters/console'; + +const CURRENT_SELECTED_KEY = 'tabs.current.selected'; +const LAST_SELECTED_KEY = 'tabs.last.selected'; + +const ZOOM_SETTINGS = [ + 0.33, 0.50, 0.66, 0.75, 0.80, 0.90, 1.00, + 1.10, 1.25, 1.50, 1.75, 2.00, 2.50, 3.00 +]; + +export default class OperationInteractor { + constructor() { + this.tabPresenter = new TabPresenter(); + this.tabPresenter.onSelected(info => this.onTabSelected(info.tabId)); + + this.consolePresenter = new ConsolePresenter(); + + this.cache = new MemoryStorage(); + } + + async close(force) { + let tab = await this.tabPresenter.getCurrent(); + if (!force && tab.pinned) { + return; + } + return this.tabPresenter.remove([tab.id]); + } + + reopen() { + return this.tabPresenter.reopen(); + } + + async selectPrev(count) { + let tabs = await this.tabPresenter.getAll(); + if (tabs.length < 2) { + return; + } + let tab = tabs.find(t => t.active); + if (!tab) { + return; + } + let select = (tab.index - count + tabs.length) % tabs.length; + return this.tabPresenter.select(tabs[select].id); + } + + async selectNext(count) { + let tabs = await this.tabPresenter.getAll(); + if (tabs.length < 2) { + return; + } + let tab = tabs.find(t => t.active); + if (!tab) { + return; + } + let select = (tab.index + count) % tabs.length; + return this.tabPresenter.select(tabs[select].id); + } + + async selectFirst() { + let tabs = await this.tabPresenter.getAll(); + return this.tabPresenter.select(tabs[0].id); + } + + async selectLast() { + let tabs = await this.tabPresenter.getAll(); + return this.tabPresenter.select(tabs[tabs.length - 1].id); + } + + async selectPrevSelected() { + let tabId = await this.cache.get(LAST_SELECTED_KEY); + if (tabId === null || typeof tabId === 'undefined') { + return; + } + this.tabPresenter.select(tabId); + } + + async reload(cache) { + let tab = await this.tabPresenter.getCurrent(); + return this.tabPresenter.reload(tab.id, cache); + } + + async setPinned(pinned) { + let tab = await this.tabPresenter.getCurrent(); + return this.tabPresenter.setPinned(tab.id, pinned); + } + + async togglePinned() { + let tab = await this.tabPresenter.getCurrent(); + return this.tabPresenter.setPinned(tab.id, !tab.pinned); + } + + async duplicate() { + let tab = await this.tabPresenter.getCurrent(); + return this.tabPresenter.duplicate(tab.id); + } + + async openPageSource() { + let tab = await this.tabPresenter.getCurrent(); + let url = 'view-source:' + tab.url; + return this.tabPresenter.create(url); + } + + async zoomIn(tabId) { + let tab = await this.tabPresenter.getCurrent(); + let current = await this.tabPresenter.getZoom(tab.id); + let factor = ZOOM_SETTINGS.find(f => f > current); + if (factor) { + return this.tabPresenter.setZoom(tabId, factor); + } + } + + async zoomOut(tabId) { + let tab = await this.tabPresenter.getCurrent(); + let current = await this.tabPresenter.getZoom(tab.id); + let factor = [].concat(ZOOM_SETTINGS).reverse().find(f => f < current); + if (factor) { + return this.tabPresenter.setZoom(tabId, factor); + } + } + + zoomNutoral(tabId) { + return this.tabPresenter.setZoom(tabId, 1); + } + + async showCommand() { + let tab = await this.tabPresenter.getCurrent(); + this.consolePresenter.showCommand(tab.id, ''); + } + + async showOpenCommand(alter) { + let tab = await this.tabPresenter.getCurrent(); + let command = 'open '; + if (alter) { + command += tab.url; + } + return this.consolePresenter.showCommand(tab.id, command); + } + + async showTabopenCommand(alter) { + let tab = await this.tabPresenter.getCurrent(); + let command = 'tabopen '; + if (alter) { + command += tab.url; + } + return this.consolePresenter.showCommand(tab.id, command); + } + + async showWinopenCommand(alter) { + let tab = await this.tabPresenter.getCurrent(); + let command = 'winopen '; + if (alter) { + command += tab.url; + } + return this.consolePresenter.showCommand(tab.id, command); + } + + async showBufferCommand() { + let tab = await this.tabPresenter.getCurrent(); + let command = 'buffer '; + return this.consolePresenter.showCommand(tab.id, command); + } + + async showAddbookmarkCommand(alter) { + let tab = await this.tabPresenter.getCurrent(); + let command = 'addbookmark '; + if (alter) { + command += tab.title; + } + return this.consolePresenter.showCommand(tab.id, command); + } + + async findStart() { + let tab = await this.tabPresenter.getCurrent(); + this.consolePresenter.showFind(tab.id); + } + + async hideConsole() { + let tab = await this.tabPresenter.getCurrent(); + this.consolePresenter.hide(tab.id); + } + + onTabSelected(tabId) { + let lastId = this.cache.get(CURRENT_SELECTED_KEY); + if (lastId) { + this.cache.set(LAST_SELECTED_KEY, lastId); + } + this.cache.set(CURRENT_SELECTED_KEY, tabId); + } +} + diff --git a/src/background/usecases/parsers.js b/src/background/usecases/parsers.js new file mode 100644 index 0000000..cda26c3 --- /dev/null +++ b/src/background/usecases/parsers.js @@ -0,0 +1,55 @@ +const trimStart = (str) => { + // NOTE String.trimStart is available on Firefox 61 + return str.replace(/^\s+/, ''); +}; + +const normalizeUrl = (keywords, searchSettings) => { + try { + return new URL(keywords).href; + } catch (e) { + if (keywords.includes('.') && !keywords.includes(' ')) { + return 'http://' + keywords; + } + let template = searchSettings.engines[searchSettings.default]; + let query = keywords; + + let first = trimStart(keywords).split(' ')[0]; + if (Object.keys(searchSettings.engines).includes(first)) { + template = searchSettings.engines[first]; + query = trimStart(trimStart(keywords).slice(first.length)); + } + return template.replace('{}', encodeURIComponent(query)); + } +}; + +const mustNumber = (v) => { + let num = Number(v); + if (isNaN(num)) { + throw new Error('Not number: ' + v); + } + return num; +}; + +const parseSetOption = (word, types) => { + let [key, value] = word.split('='); + if (value === undefined) { + value = !key.startsWith('no'); + key = value ? key : key.slice(2); + } + let type = types[key]; + if (!type) { + throw new Error('Unknown property: ' + key); + } + if (type === 'boolean' && typeof value !== 'boolean' || + type !== 'boolean' && typeof value === 'boolean') { + throw new Error('Invalid argument: ' + word); + } + + switch (type) { + case 'string': return [key, value]; + case 'number': return [key, mustNumber(value)]; + case 'boolean': return [key, value]; + } +}; + +export { normalizeUrl, parseSetOption }; diff --git a/src/background/usecases/setting.js b/src/background/usecases/setting.js new file mode 100644 index 0000000..656fc3f --- /dev/null +++ b/src/background/usecases/setting.js @@ -0,0 +1,31 @@ +import Setting from '../domains/setting'; +import PersistentSettingRepository from '../repositories/persistent-setting'; +import SettingRepository from '../repositories/setting'; + +export default class SettingInteractor { + constructor() { + this.persistentSettingRepository = new PersistentSettingRepository(); + this.settingRepository = new SettingRepository(); + } + + save(settings) { + this.persistentSettingRepository.save(settings); + } + + get() { + return this.settingRepository.get(); + } + + async reload() { + let settings = await this.persistentSettingRepository.load(); + if (!settings) { + settings = Setting.defaultSettings(); + } + + let value = settings.value(); + + this.settingRepository.update(value); + + return value; + } +} diff --git a/src/background/usecases/version.js b/src/background/usecases/version.js new file mode 100644 index 0000000..a71f90d --- /dev/null +++ b/src/background/usecases/version.js @@ -0,0 +1,41 @@ +import manifest from '../../../manifest.json'; +import VersionRepository from '../repositories/version'; +import TabPresenter from '../presenters/tab'; +import Notifier from '../infrastructures/notifier'; + +export default class VersionInteractor { + constructor() { + this.versionRepository = new VersionRepository(); + this.tabPresenter = new TabPresenter(); + this.notifier = new Notifier(); + } + + async notifyIfUpdated() { + if (!await this.checkUpdated()) { + return; + } + + let title = 'Vim Vixen ' + manifest.version + ' has been installed'; + let message = 'Click here to see release notes'; + this.notifier.notify(title, message, () => { + let url = this.releaseNoteUrl(manifest.version); + this.tabPresenter.create(url); + }); + this.versionRepository.update(manifest.version); + } + + async checkUpdated() { + let prev = await this.versionRepository.get(); + if (!prev) { + return true; + } + return manifest.version !== prev; + } + + releaseNoteUrl(version) { + if (version) { + return 'https://github.com/ueokande/vim-vixen/releases/tag/' + version; + } + return 'https://github.com/ueokande/vim-vixen/releases/'; + } +} |