Merge pull request #10 from cupcakearmy/0.8

0.8
This commit is contained in:
Nicco 2021-11-23 15:05:01 +01:00 committed by GitHub
commit 2272c1c7ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 8042 additions and 8934 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ yarn-error.log
# Build # Build
.cache .cache
.parcel-cache
dist dist
*.zip *.zip
*.pem *.pem

8
.parcelrc Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "@parcel/config-webextension",
"transformers": {
"*.svelte": [
"parcel-transformer-svelte"
]
}
}

21
CHANGELOG.md Normal file
View File

@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8] 2021-11
### Added
- Options to dismiss
- Option for idle
### Fixed
- Bugs
### Changed
- Dependencies update

View File

@ -2,8 +2,8 @@
## Building ## Building
1. Bump version in `manifest.json` and `src/shared/footer.svelte` 1. Bump version in `manifest.json` and `src/dashboard/components/Footer.svelte`
2. Build with `yarn run dist` 2. Build with `pnpm run dist`
## Firefox ## Firefox

View File

@ -1,9 +0,0 @@
# Roadmap
## Current
## Backlog
- Dark mode support
- Options
- Dashboard

View File

@ -1,7 +1,7 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Ora", "name": "Ora",
"version": "0.7", "version": "0.8",
"description": "See how much time you spend on each website and set limits", "description": "See how much time you spend on each website and set limits",
"icons": { "icons": {
@ -25,7 +25,7 @@
"scripts": ["./src/background/index.js"] "scripts": ["./src/background/index.js"]
}, },
"options_ui": { "options_ui": {
"page": "./src/options/index.html" "page": "./src/dashboard/index.html#/options?nav=false"
}, },
"content_scripts": [ "content_scripts": [
{ {
@ -33,5 +33,5 @@
"js": ["./src/client/index.js"] "js": ["./src/client/index.js"]
} }
], ],
"web_accessible_resources": ["./icons/watch.png", "./icons/watch-alt.png"] "web_accessible_resources": ["./icons/watch.png", "./icons/watch-alt.png", "./src/dashboard/index.html"]
} }

View File

@ -3,43 +3,51 @@
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"author": { "author": {
"name": "Nicco", "name": "Niccolo Borgioli",
"email": "hi@nicco.io", "email": "hi@nicco.io",
"url": "https://nicco.io" "url": "https://nicco.io"
}, },
"homepage": "https://github.com/cupcakearmy/ora", "homepage": "https://github.com/cupcakearmy/ora",
"scripts": { "scripts": {
"clean": "rm -rf dist .cache web-ext-artifacts ora.zip", "clean": "rm -rf dist .parcel-cache web-ext-artifacts ora.zip",
"dev": "parcel watch --no-hmr manifest.json src/dashboard/index.html", "dev": "parcel watch --target dev --no-hmr ./manifest.json",
"build": "parcel build --no-content-hash --no-source-maps --no-minify manifest.json src/dashboard/index.html", "build": "parcel build --target prod ./manifest.json",
"dist": "rm -rf dist && yarn run build && yarn pack:ff && yarn run pack:zip", "dist": "run-s clean build pack:*",
"pack:zip": "zip -r ./ora.zip dist/*", "pack:zip": "zip -r ./ora.zip dist/prod/*",
"pack:ff": "web-ext build -s dist --overwrite-dest" "pack:ff": "web-ext build -s dist/prod --overwrite-dest"
},
"targets": {
"dev": {
"sourceMap": {
"inline": true,
"inlineSources": true
}
},
"prod": {}
}, },
"browserslist": [
"last 2 chrome versions",
"last 2 firefox versions"
],
"dependencies": { "dependencies": {
"d3": "^6.1.1", "d3": "^7",
"dayjs": "^1.8.36", "dayjs": "^1.10.7",
"dexie": "^3.0.2", "dexie": "^3.2.0",
"faker": "^5.1.0", "dexie-export-import": "^1.0.3",
"file-saver": "^2.0.2", "faker": "5.5.2",
"joi": "^17.2.1", "file-saver": "^2.0.5",
"lodash": "^4.17.20", "joi": "^17.4.2",
"lodash": "^4.17.21",
"pretty-bytes": "^5.6.0",
"spectre.css": "^0.5.9", "spectre.css": "^0.5.9",
"svelte-spa-router": "^3.0.0", "svelte-spa-router": "^3.2.0",
"tailwindcss": "^1.8.10", "tailwindcss": "^2",
"webextension-polyfill": "^0.6.0" "webextension-polyfill": "^0.8.0"
}, },
"devDependencies": { "devDependencies": {
"@types/firefox-webext-browser": "^78.0.1", "@parcel/config-webextension": "^2.0.1",
"@types/lodash": "^4.14.161", "@parcel/core": "^2.0.1",
"parcel-bundler": "^1.12.4", "@types/lodash": "^4.14.177",
"parcel-plugin-svelte": "^4.0.6", "npm-run-all": "^4.1.5",
"parcel-plugin-web-extension": "^1.6.1", "parcel": "^2.0.1",
"svelte": "^3.25.1", "parcel-transformer-svelte": "^1.2.3",
"web-ext": "^5.2.0" "svelte": "^3.44.2",
"web-ext": "^6.5.0"
} }
} }

7351
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,9 @@ import browser from 'webextension-polyfill'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { dashboard } from '../shared/utils' import { dashboard } from '../shared/utils'
import { insertLog, normalizeTimestamp, Limits, DB } from '../shared/db' import { insertLog, normalizeTimestamp, DB } from '../shared/db'
import { getUsageForHost, percentagesToBool } from '../shared/lib' import { getSettingsWithDefaults, getUsageForHost, percentagesToBool } from '../shared/lib'
import { DismissValidator, checkForErrors } from '../shared/validation'
browser.browserAction.onClicked.addListener(() => browser.tabs.create({ url: dashboard, active: true })) browser.browserAction.onClicked.addListener(() => browser.tabs.create({ url: dashboard, active: true }))
@ -18,22 +19,33 @@ async function log() {
const window = windows.find((window) => window.id === tab.windowId) const window = windows.find((window) => window.id === tab.windowId)
return tab.active && window.focused return tab.active && window.focused
}) })
.map(({ id, title, url }) => { .map(({ url, audible, mutedInfo }) => {
const { host } = new URL(url) const { host } = new URL(url)
return { id, title, host } return { host, audio: audible && !mutedInfo.muted }
}) })
.filter((x) => x.host)
await Promise.all( if (active.length === 0) return
active.map(({ host }) => {
if (host) const settings = await getSettingsWithDefaults()
return insertLog({ let idle = false
timestamp: normalizeTimestamp(new Date()), if (settings.idleTimeout > 0) {
host, idle = dayjs(settings.lastActivity).add(settings.idleTimeout, 'minutes').isBefore(dayjs())
seconds: (frequency / 1000) | 0, }
})
const inserted = active
.filter((tab) => !idle || tab.audio)
.map((tab) => {
return insertLog({
timestamp: normalizeTimestamp(new Date()),
host: tab.host,
seconds: (frequency / 1000) | 0,
})
}) })
) await Promise.all(inserted)
} catch {} } catch (e) {
console.error(e)
}
} }
async function deleteOldLogs() { async function deleteOldLogs() {
@ -48,5 +60,19 @@ setInterval(deleteOldLogs, 5 * 60 * 1000) // Delete old logs every 5 minutes
setInterval(log, frequency) setInterval(log, frequency)
browser.runtime.onMessage.addListener((message, sender, sendResponse) => { browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
return getUsageForHost(message).then((percentages) => percentagesToBool(percentages)) switch (message.type) {
case 'check':
return getUsageForHost(message.host).then((percentages) => percentagesToBool(percentages))
case 'report':
DB.settings.put({ key: 'lastActivity', value: new Date() })
break
case 'dismiss':
const entry = {
host: message.host,
timestamp: new Date(),
duration: message.duration,
}
if (!checkForErrors(DismissValidator, entry)) DB.dismiss.put(entry)
break
}
}) })

98
src/client/App.svelte Normal file
View File

@ -0,0 +1,98 @@
<script>
import { blocked } from './blocked'
import { init } from './reporter'
import { buttons, dismiss } from './dismiss'
init()
</script>
<div class="wrapper" class:hidden={!$blocked}>
<div>
<h1>Overtime</h1>
<div>You have no time left on this website.</div>
<hr />
<div class="dismiss">
<div>
<i> dismiss for... </i>
</div>
<div class="links">
{#each buttons as button}
<!-- svelte-ignore a11y-missing-attribute -->
<a on:click={() => dismiss(button.duration)}>{button.label}</a>
{/each}
</div>
</div>
</div>
</div>
<style>
.wrapper {
position: fixed;
color: #000;
bottom: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: hsla(0, 0%, 100%, 0.975);
z-index: 999999999;
font-size: 1rem;
padding: 1rem;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
transition: all 300ms ease-out;
}
.wrapper.hidden {
/* display: none; */
bottom: -100vh;
}
@media (prefers-color-scheme: dark) {
.wrapper {
background-color: hsl(0, 0%, 0%, 0.925);
color: #ffffff;
}
}
.wrapper > div {
margin: 3rem auto;
max-width: 25rem;
}
.wrapper h1 {
margin-bottom: 2rem;
}
.dismiss {
display: flex;
flex-direction: row;
}
.dismiss .links {
margin-left: 1rem;
margin-top: -0.1rem;
}
.dismiss .links a {
color: inherit;
text-decoration: inherit;
display: block;
cursor: pointer;
padding: 0.1em;
opacity: 0.6;
transition: all 100ms ease-in-out;
}
.dismiss .links a:hover {
transform: translateX(-0.25em);
opacity: 1;
}
hr {
outline: none;
background: transparent;
border: 1px inset currentColor;
margin: 1em 0;
}
</style>

17
src/client/blocked.js Normal file
View File

@ -0,0 +1,17 @@
import browser from 'webextension-polyfill'
import { writable } from 'svelte/store'
async function check(set) {
if (window.document.hidden) return
const isBlocked = await browser.runtime.sendMessage({
type: 'check',
host: window.location.host,
})
set(isBlocked)
}
export const blocked = new writable(false, (set) => {
check(set)
const interval = setInterval(() => check(set), 1000)
return () => clearInterval(interval)
})

25
src/client/dismiss.js Normal file
View File

@ -0,0 +1,25 @@
import dayjs from 'dayjs'
import * as duration from 'dayjs/plugin/duration'
dayjs.extend(duration)
import browser from 'webextension-polyfill'
import { isDev } from '../shared/utils'
import { blocked } from './blocked'
export const buttons = [
...(isDev ? [{ label: 'Dev', duration: dayjs.duration({ seconds: 5 }) }] : []),
{ label: '1 minute', duration: dayjs.duration({ minutes: 1 }) },
{ label: '5 minutes', duration: dayjs.duration({ minutes: 5 }) },
{ label: '15 minutes', duration: dayjs.duration({ minutes: 15 }) },
{ label: '1 hour', duration: dayjs.duration({ hours: 1 }) },
]
export function dismiss(duration) {
browser.runtime.sendMessage({
type: 'dismiss',
duration: duration.asMilliseconds(),
host: window.location.host,
})
blocked.set(false)
}

View File

@ -1,43 +1,5 @@
import browser from 'webextension-polyfill' import App from './App.svelte'
let wrapper wrapper = window.document.createElement('div')
window.document.body.appendChild(wrapper)
function init() { new App({ target: wrapper })
wrapper = window.document.createElement('div')
Object.assign(wrapper.style, {
display: 'none',
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
backgroundColor: '#ffffff',
zIndex: '999999999',
})
wrapper.classList.add('ora--wrapper')
wrapper.classList.add('hidden')
const inner = window.document.createElement('div')
Object.assign(inner.style, {
fontFamily: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif`,
margin: '3em auto',
width: '100%',
maxWidth: '20em',
})
inner.innerHTML = `
<h1>Overtime </h1>
<p>You have no time left on this website 🥺</p>
`
wrapper.appendChild(inner)
window.document.body.appendChild(wrapper)
}
async function check() {
if (window.document.hidden) return
const isBlocked = await browser.runtime.sendMessage(window.location.host)
wrapper.style.display = isBlocked ? 'initial' : 'none'
}
init()
setInterval(check, 5000)
check()

19
src/client/reporter.js Normal file
View File

@ -0,0 +1,19 @@
import browser from 'webextension-polyfill'
let lastReported = 0
function logActivity() {
const now = Date.now()
// Limit reports to once every second
if (now - lastReported < 1000) return
lastReported = now
browser.runtime.sendMessage({
type: 'report',
})
}
export function init() {
window.document.addEventListener('mousemove', logActivity, false)
window.document.addEventListener('keydown', logActivity, false)
window.document.addEventListener('scroll', logActivity, false)
}

View File

@ -1,20 +1,41 @@
<script> <script>
import Router, { link } from 'svelte-spa-router' import Router, { link, querystring } from 'svelte-spa-router'
import Toasts from './components/Toasts.svelte'
import Dev from './components/Dev.svelte' import Dev from './components/Dev.svelte'
import Dashboard from './pages/Dashboard.svelte' import Dashboard from './pages/Dashboard.svelte'
import Limits from './pages/Limits.svelte' import Limits from './pages/Limits.svelte'
import Footer from '../shared/footer.svelte' import Footer from './components/Footer.svelte'
import Options from './pages/Options.svelte'
import { isDev } from '../shared/utils' import { isDev } from '../shared/utils'
const routes = { const routes = {
'/': Dashboard, '/': Dashboard,
'/options': Options,
'/limits': Limits, '/limits': Limits,
} }
$: nav = new URLSearchParams($querystring).get('nav') !== 'false'
</script> </script>
{#if isDev}
<Dev />
{/if}
<main>
{#if nav}
<div class="mb-8">
<a use:link href="/"><button class="btn">Dashboard</button></a>
<a use:link href="/limits"><button class="btn">Limits</button></a>
<a use:link href="/options"><button class="btn">Options</button></a>
</div>
{/if}
<Router {routes} />
<Toasts />
<Footer />
</main>
<style> <style>
main { main {
padding: 1em; padding: 1em;
@ -23,18 +44,3 @@
max-width: 50em; max-width: 50em;
} }
</style> </style>
{#if isDev}
<Dev />
{/if}
<main>
<div class="mb-8">
<a href="../options/index.html"><button class="btn">Options</button></a>
<a use:link={'/'}><button class="btn">Dashboard</button></a>
<a use:link={'/limits'}><button class="btn">Limits</button></a>
</div>
<Router {routes} />
<Footer />
</main>

View File

@ -89,6 +89,8 @@
onMount(render) onMount(render)
</script> </script>
<svg bind:this={wrapper} preserveAspectRatio="xMidYMid meet" />
<style> <style>
svg { svg {
width: 100%; width: 100%;
@ -98,5 +100,3 @@
font-family: monospace; font-family: monospace;
} }
</style> </style>
<svg bind:this={wrapper} preserveAspectRatio="xMidYMid meet" />

View File

@ -1,5 +1,4 @@
<script> <script>
import { onMount } from 'svelte'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const format = 'YYYY-MM-DD' const format = 'YYYY-MM-DD'
@ -16,10 +15,10 @@
$: output(internal) $: output(internal)
</script> </script>
<input class="form-input input-sm" type="date" bind:value={internal} {name} />
<style> <style>
input { input {
width: 7rem !important; width: 7rem !important;
} }
</style> </style>
<input class="form-input input-sm" type="date" bind:value={internal} {name} />

View File

@ -1,9 +1,9 @@
<script> <script>
import faker from 'faker' import faker from 'faker/dist/faker.js'
import day from 'dayjs' import day from 'dayjs'
import { range, random } from 'lodash' import { range, random } from 'lodash'
import { insertLog, normalizeTimestamp, DB, clear as clearDB } from '../../shared/db' import { insertLog, normalizeTimestamp, clear as clearDB } from '../../shared/db'
let loading = false let loading = false
@ -21,6 +21,8 @@
await insertLog({ host, timestamp, seconds }) await insertLog({ host, timestamp, seconds })
} }
} }
} catch (e) {
console.error(e)
} finally { } finally {
loading = false loading = false
} }
@ -36,6 +38,11 @@
} }
</script> </script>
<div class="p-2">
<button class="btn btn-sm" class:loading disabled={loading} on:click={fill}>Add Random Data</button>
<button class="btn btn-sm btn-error" class:loading disabled={loading} on:click={clear}>Delete data</button>
</div>
<style> <style>
div { div {
position: absolute; position: absolute;
@ -43,8 +50,3 @@
right: 0; right: 0;
} }
</style> </style>
<div class="p-2">
<button class="btn btn-sm" class:loading disabled={loading} on:click={fill}>Add Random Data</button>
<button class="btn btn-sm btn-error" class:loading disabled={loading} on:click={clear}>Delete data</button>
</div>

View File

@ -0,0 +1,14 @@
<script>
export let value
</script>
{#if Array.isArray(value)}
<input type="number" class="form-input" placeholder="1" bind:value={value[0]} />
<select class="form-select" bind:value={value[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
{/if}

View File

@ -0,0 +1,28 @@
<script>
let text = 'Select File'
export let file
let input
function validate() {
if (!input || !input.files.length) return
file = input.files[0]
text = file.name
}
</script>
<label class="btn">
{text}
<input bind:this={input} on:change={validate} class="input" accept="application/json" type="file" />
</label>
<style>
label {
width: 18em;
max-width: 100%;
display: inline-block;
}
label input[type='file'] {
display: none;
}
</style>

View File

@ -0,0 +1,16 @@
<footer>
<small>
Made with ❤️ by
<a href="https://nicco.io" target="_blank" rel="noreferrer">🐘</a>
— v0.8 —
<a href="https://github.com/cupcakearmy/ora" target="_blank" rel="noreferrer">Source Code</a>
</small>
</footer>
<style>
footer {
margin-top: 3rem;
text-align: center;
font-family: monospace;
}
</style>

View File

@ -19,23 +19,23 @@
end = new Date() end = new Date()
} }
const intervals = [
{ label: 'Year', set: set('year', 1) },
{ label: 'Month', set: set('month', 1) },
{ label: 'Week', set: set('week', 1) },
{ label: '3 Days', set: set('day', 3) },
{ label: 'Today', set: set('day', 0) },
]
// Init // Init
onMount(() => set('day', 0)()) onMount(() => set('day', 0)())
</script> </script>
<style>
.spacer {
width: 0.5em;
}
</style>
<div class="flex items-center"> <div class="flex items-center">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm" on:click={all}>All</button> {#each intervals as interval (interval.label)}
<button class="btn btn-sm" on:click={set('month', 1)}>Month</button> <button class="btn btn-sm" on:click={interval.set}>{interval.label}</button>
<button class="btn btn-sm" on:click={set('week', 1)}>Week</button> {/each}
<button class="btn btn-sm" on:click={set('day', 3)}>3 Days</button>
<button class="btn btn-sm" on:click={set('day', 0)}>Today</button>
</div> </div>
<div class="spacer" /> <div class="spacer" />
<div class="input-group"> <div class="input-group">
@ -43,3 +43,9 @@
<DateInput bind:date={end} /> <DateInput bind:date={end} />
</div> </div>
</div> </div>
<style>
.spacer {
width: 0.5em;
}
</style>

View File

@ -21,7 +21,8 @@
style={`width:${percentage}%;`} style={`width:${percentage}%;`}
aria-valuenow={percentage} aria-valuenow={percentage}
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" /> aria-valuemax="100"
/>
</div> </div>
{/await} {/await}
{/each} {/each}

View File

@ -3,11 +3,14 @@
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { DB } from '../../shared/db' import { DB } from '../../shared/db'
import { checkForErrors, LimitValidator } from '../../shared/validation'
import DurationInput from './DurationInput.svelte'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const init = { limit: ['1', 'h'], every: [1, 'd'] } const init = { limit: ['1', 'h'], every: [1, 'd'] }
export let limit = null export let limit = null
export let error = null
$: active = limit !== null $: active = limit !== null
function add() { function add() {
@ -23,6 +26,11 @@
} }
async function save() { async function save() {
const errors = checkForErrors(LimitValidator, limit)
if (errors) {
error = errors
return
}
await DB.limits.put(limit) await DB.limits.put(limit)
dispatch('update') dispatch('update')
close() close()
@ -45,25 +53,11 @@
</label> </label>
<div class="form-label">Rules</div> <div class="form-label">Rules</div>
{#each limit.rules as { limit, every }, i} {#each limit.rules as rule, i}
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-input" placeholder="1" bind:value={limit[0]} /> <DurationInput bind:value={rule.limit} />
<select class="form-select" bind:value={limit[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
<span class="input-group-addon">every</span> <span class="input-group-addon">every</span>
<input type="text" class="form-input" bind:value={every[0]} /> <DurationInput bind:value={rule.every} />
<select class="form-select" bind:value={every[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
<button class="btn btn-error input-group-btn" on:click={del(i)}>X</button> <button class="btn btn-error input-group-btn" on:click={del(i)}>X</button>
</div> </div>
{/each} {/each}
@ -73,6 +67,9 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div> <div>
{#if error}
<span class="text-error">{error}</span>
{/if}
<button on:click={close} class="btn">Cancel</button> <button on:click={close} class="btn">Cancel</button>
<button on:click={save} class="btn btn-primary">Save</button> <button on:click={save} class="btn btn-primary">Save</button>
</div> </div>

View File

@ -0,0 +1,17 @@
<script>
import pretty from 'pretty-bytes'
import { onMount } from 'svelte'
let usage = null
onMount(async () => {
const estimate = await window.navigator.storage.estimate()
usage = pretty(estimate.usage)
})
</script>
{#if usage === null}
<span class="loading" />
{:else}
<span>Storage used: <span class="font-mono">{usage}</span></span>
{/if}

View File

@ -0,0 +1,19 @@
<script>
import { onMount } from 'svelte'
import { scale } from 'svelte/transition'
export let toast
let show = true
onMount(() => {
setTimeout(() => {
show = false
}, 3000)
})
</script>
{#if show}
<div class="toast toast-{toast.type}" transition:scale>
{toast.message}
</div>
{/if}

View File

@ -0,0 +1,24 @@
<script>
import { toasts } from '../toasts'
import Toast from './Toast.svelte'
</script>
<div class="wrapper">
{#each $toasts as toast}
<div class="mt-2">
<Toast {toast} />
</div>
{/each}
</div>
<style>
.wrapper {
position: fixed;
bottom: 0;
right: 0;
z-index: 100;
padding: 1rem;
display: flex;
flex-direction: column-reverse;
}
</style>

View File

@ -3,16 +3,26 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" /> <link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" />
<link href="../../node_modules/tailwindcss/dist/tailwind.css" rel="stylesheet" /> <link href="../../node_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
<style> <style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
text-shadow: rgba(0, 0, 0, 0.01) 0 0 1px;
}
#root { #root {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
</style> </style>
<title>Ora</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root" />
<script src="./main.js"></script> <script type="module" src="./main.js"></script>
</body> </body>
</html> </html>

View File

@ -53,21 +53,6 @@
onMount(calculate) onMount(calculate)
</script> </script>
<style>
table td,
table th {
padding: 0.25rem 0.06rem;
}
table td.same {
opacity: 0.25;
}
table td :global(a:visited) {
color: inherit;
}
</style>
<div class="flex justify-between items-center mb-8"> <div class="flex justify-between items-center mb-8">
<h2 class="text-2xl">Dashboard</h2> <h2 class="text-2xl">Dashboard</h2>
<RangeChooser bind:start bind:end /> <RangeChooser bind:start bind:end />
@ -91,3 +76,18 @@
{/each} {/each}
</table> </table>
{/if} {/if}
<style>
table td,
table th {
padding: 0.25rem 0.06rem;
}
table td.same {
opacity: 0.25;
}
table td :global(a:visited) {
color: inherit;
}
</style>

View File

@ -1,5 +1,6 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { cloneDeep } from 'lodash'
import RulesEditor from '../components/RulesEditor.svelte' import RulesEditor from '../components/RulesEditor.svelte'
import Rules from '../components/Rules.svelte' import Rules from '../components/Rules.svelte'
@ -15,7 +16,7 @@
} }
function edit(id) { function edit(id) {
limit = limits.find((limit) => limit.id === id) limit = cloneDeep(limits.find((limit) => limit.id === id))
} }
async function load() { async function load() {
@ -30,12 +31,6 @@
onMount(load) onMount(load)
</script> </script>
<style>
td {
vertical-align: top;
}
</style>
<RulesEditor bind:limit on:update={load} /> <RulesEditor bind:limit on:update={load} />
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
@ -70,3 +65,9 @@
{:else} {:else}
<div class="loading loading-lg" /> <div class="loading loading-lg" />
{/if} {/if}
<style>
td {
vertical-align: top;
}
</style>

View File

@ -0,0 +1,7 @@
<script>
import YourData from '../views/YourData.svelte'
import Settings from '../views/Settings.svelte'
</script>
<Settings />
<YourData />

7
src/dashboard/toasts.js Normal file
View File

@ -0,0 +1,7 @@
import { writable } from 'svelte/store'
export const toasts = writable([])
export function notify(message, type = 'success') {
toasts.update((toasts) => [...toasts, { message, type }])
}

View File

@ -0,0 +1,47 @@
<script>
import { onMount } from 'svelte'
import { DB } from '../../shared/db'
import { getSettingsWithDefaults } from '../../shared/lib'
import { notify } from '../toasts'
let settings = null
async function load() {
settings = await getSettingsWithDefaults()
}
async function save() {
for (const [key, value] of Object.entries(settings)) {
await DB.settings.put({ key, value })
}
notify('Saved')
}
onMount(load)
</script>
<h2 class="mt-8 text-2xl">Settings</h2>
{#if settings}
<form class="mt-2" on:submit|preventDefault={save}>
<div class="form-group">
<label class="form-label">
Retention
<small>(Days)</small>
<input class="form-input" type="number" min="3" max="365" step="1" bind:value={settings.retention} />
</label>
<label class="form-label">
Idle Timeout
<small>(Minutes)</small>
<input class="form-input" type="number" min="0" step="1" bind:value={settings.idleTimeout} />
<p>Stop tracking after a certain period of idle behavior. <span class="font-mono">0</span> to disable.</p>
</label>
<div class="mt-2">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
{:else}
<div class="loading loading-lg" />
{/if}

View File

@ -0,0 +1,53 @@
<script>
import { saveAs } from 'file-saver'
import dj from 'dayjs'
import FileUpload from '../components/FileUpload.svelte'
import { clear, DB } from '../../shared/db'
import { longPress } from '../../shared/lib'
import { notify } from '../toasts'
let file
let loading = false
async function exportDB() {
try {
loading = true
const blob = await DB.export()
const filename = `Ora [${dj().format('YYYY-MM-DD HH-mm-ss')}].json`
saveAs(blob, filename)
notify('Exported')
} finally {
loading = false
}
}
async function clearDB() {
await clear()
window.location.reload()
}
async function importDB() {
try {
loading = true
await clear()
await DB.import(file)
notify('Imported')
} catch (e) {
notify('Error importing', 'error')
} finally {
loading = false
}
}
</script>
<h2 class="mt-8 text-2xl">Your Data</h2>
<div class="mt-2">
<FileUpload bind:file />
<button class="btn btn-primary" class:loading on:click={importDB} disabled={!file}>Import</button>
<button class="btn btn-primary" class:loading on:click={exportDB}>Export</button>
<button class="btn btn-error tooltip" class:loading data-tooltip="Hold to delete" use:longPress={clearDB}>
Delete all data
</button>
</div>

View File

@ -1,109 +0,0 @@
<script>
import { onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { saveAs } from 'file-saver'
import dj from 'dayjs'
import FileUpload from './FileUpload.svelte'
import Footer from '../shared/footer.svelte'
import { dashboard, isDev } from '../shared/utils'
import { dump as dumpDB, load as loadDB, clear as clearDB, validate } from '../shared/db'
import { longPress } from '../shared/lib'
const DEFAULT = {
retention: 90,
}
let settings = DEFAULT
let uploaded
let disabled = true
async function read() {
settings = {
...DEFAULT,
...(await browser.storage.local.get()),
}
}
function write() {
return browser.storage.local.set(settings)
}
async function reset() {
await browser.storage.local.clear()
await read()
}
async function dump() {
const data = await dumpDB()
const blob = new Blob([JSON.stringify(data)], { type: 'application/json;charset=utf-8' })
const filename = `Ora [${dj().format('YYYY-MM-DD HH-mm-ss')}].json`
saveAs(blob, filename)
}
async function clear() {
await clearDB()
alert('Done')
}
async function load() {
try {
await loadDB(uploaded)
alert('Imported')
} catch {
alert('Error importing')
}
}
$: {
disabled = !validate(uploaded)
}
onMount(read)
</script>
<style>
main {
padding: 1em;
margin: auto;
width: 100%;
max-width: 50em;
}
</style>
<main>
<a href={dashboard} target={isDev ? '' : '_blank'}><button class="btn">Dashboard</button></a>
<h2 class="mt-8 text-2xl">Settings</h2>
<form class="mt-2" on:submit|preventDefault={write}>
<div class="form-group">
<label class="form-label">
Retention
<small>(Days)</small>
<input
id="retention"
class="form-input"
type="number"
min="3"
max="365"
step="1"
bind:value={settings.retention} />
</label>
<div class="mt-2">
<button type="reset" class="btn" on:click={reset}>Reset</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
<h2 class="mt-8 text-2xl">Your Data</h2>
<div class="mt-2">
<FileUpload bind:value={uploaded} />
<button class="btn btn-primary" on:click={load} {disabled}>Import</button>
<button class="btn btn-primary" on:click={dump}>Export</button>
<button class="btn btn-error tooltip" data-tooltip="Hold to delete" use:longPress={clear}>Delete all data</button>
</div>
<Footer />
</main>

View File

@ -1,47 +0,0 @@
<script>
import { isEqual } from 'lodash'
let text = 'Select File'
export let value = undefined
let input
let error
function validate() {
if (!input || !input.files.length) return
const file = input.files[0]
text = file.name
const reader = new FileReader()
reader.onload = (data) => {
try {
error = false
const text = data.target.result
const parsed = JSON.parse(text)
value = parsed
} catch {
error = true
value = undefined
}
}
reader.readAsText(file)
}
</script>
<style>
label {
width: 18em;
max-width: 100%;
display: inline-block;
}
label input[type='file'] {
display: none;
}
</style>
<label class="btn">
{#if error}Invalid file{:else}{text}{/if}
<input bind:this={input} on:change={validate} class="input" accept="application/json" type="file" />
</label>

View File

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" />
<link href="./main.css" rel="stylesheet" />
</head>
<body>
<main>
<h1>Ora</h1>
<p>Ora helps you track down time consuming websites</p>
<a href="../dashboard/index.html" target="_blank"><button class="btn btn-primary btn-lg">Go to the Dashboard</button></a>
<br />
<br />
<br />
<form id="form">
<h4>Settings</h4>
<div class="form-group">
<label class="form-label">
Frequency <small>(Minutes)</small>
<input id="frequency" class="form-input" type="number" min="3" step="1" />
</label>
<label class="form-label">
Retention <small>(Days)</small>
<input id="retention" class="form-input" type="number" min="3" max="365" step="1" />
</label>
<button id="reset" type="submit" class="btn">Reset</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</main>
<div id="root"></div>
<script src="./main.js"></script>
</body>
</html>

View File

@ -1,42 +0,0 @@
import browser from 'webextension-polyfill'
const DEFAULT = {
frequency: 3,
retention: 90,
}
async function load() {
try {
return await browser.storage.local.get()
} catch {
return DEFAULT
}
}
async function save(settings) {
return browser.storage.local.set(settings)
}
function init() {
const frequency = window.document.getElementById('frequency')
const retention = window.document.getElementById('retention')
const reset = window.document.getElementById('reset')
const form = window.document.getElementById('form')
form.addEventListener('submit', (e) => {
e.preventDefault()
save({ frequency: frequency.value, retention: retention.value })
})
reset.addEventListener('click', async () => {
await browser.storage.local.clear()
window.location.reload()
})
load().then((saved) => {
frequency.value = saved.frequency
retention.value = saved.retention
})
}
window.document.addEventListener('DOMContentLoaded', init)

View File

@ -1,18 +1,26 @@
import dj from 'dayjs' import dj from 'dayjs'
import Dexie from 'dexie'
import RelativeTime from 'dayjs/plugin/relativeTime' import RelativeTime from 'dayjs/plugin/relativeTime'
import Duration from 'dayjs/plugin/duration' import Duration from 'dayjs/plugin/duration'
import Joi from 'joi' import Dexie from 'dexie'
import 'dexie-export-import'
import { LogValidator, checkForErrors } from '../shared/validation'
dj.extend(Duration) dj.extend(Duration)
dj.extend(RelativeTime) dj.extend(RelativeTime)
export const DB = new Dexie('ora') export const DB = new Dexie('ora')
DB.version(2).stores({ DB.version(2).stores({
logs: `++id, host, timestamp`, logs: `++id, host, timestamp`,
limits: `++id, host`, limits: `++id, host`,
}) })
DB.version(3).stores({
settings: `key, value`,
dismiss: `host, timestamp, duration`,
})
export function normalizeTimestamp(timestamp) { export function normalizeTimestamp(timestamp) {
// Normalize every dato to 15 minutes // Normalize every dato to 15 minutes
const t = dj(timestamp) const t = dj(timestamp)
@ -28,58 +36,11 @@ export async function insertLog({ timestamp, host, seconds }) {
const saved = await DB.logs.where({ host, timestamp }).first() const saved = await DB.logs.where({ host, timestamp }).first()
const data = Object.assign({ host, timestamp, seconds: 0 }, saved) const data = Object.assign({ host, timestamp, seconds: 0 }, saved)
data.seconds += seconds data.seconds += seconds
const error = checkForErrors(LogValidator, data)
if (error) throw new Error(error)
await DB.logs.put(data) await DB.logs.put(data)
} }
export async function clear() { export async function clear() {
await DB.limits.clear() return Promise.allSettled(DB.tables.map((table) => DB.table(table.name).clear()))
await DB.logs.clear()
}
export async function dump() {
return {
limits: await DB.limits.toArray(),
logs: await DB.logs.toArray(),
}
}
export function validate(data) {
const schema = Joi.object({
limits: Joi.array().items(
Joi.object({
host: Joi.string(),
id: Joi.number(),
rules: Joi.array().items(
Joi.object({
limit: Joi.array().items(Joi.string(), Joi.number()),
every: Joi.array().items(Joi.string(), Joi.number()),
})
),
})
),
logs: Joi.array().items(
Joi.object({
host: Joi.string(),
id: Joi.number(),
seconds: Joi.number(),
timestamp: Joi.string(),
})
),
})
const validated = schema.validate(data, { presence: 'required' })
return !validated.error
}
export async function load(data) {
if (!validate(data)) throw new Error('Invalid data')
await clear()
await DB.limits.bulkAdd(data.limits)
await DB.logs.bulkAdd(
data.logs.map((log) => ({
...log,
timestamp: new Date(log.timestamp),
}))
)
} }

View File

@ -1,22 +0,0 @@
<script>
// import manifest from '../../'
</script>
<style>
footer {
margin-top: 3rem;
text-align: center;
font-family: monospace;
}
</style>
<footer>
<a
href="https://github.com/cupcakearmy/ora"
target="_blank"
rel="noreferrer">Source Code</a>
- v0.7
<br />
Made with ❤️ by
<a href="https://nicco.io" target="_blank" rel="noreferrer">🐘</a>
</footer>

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link href="../../node_modules/spectre.css/dist/spectre.min.css" rel="stylesheet" />
<link href="../../node_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
text-shadow: rgba(0, 0, 0, 0.01) 0 0 1px;
}
#root {
width: 100vw;
height: 100vh;
}
</style>
<title>Ora</title>
</head>
<body>
<div id="root" />
<script src="./main.js"></script>
</body>
</html>

View File

@ -2,6 +2,7 @@ import { groupBy, orderBy, sum } from 'lodash'
import dj from 'dayjs' import dj from 'dayjs'
import { DB } from './db.js' import { DB } from './db.js'
import { SettingsValidator } from './validation.js'
export async function data({ start, end }) { export async function data({ start, end }) {
const logs = await getLogsBetweenDates({ start, end }) const logs = await getLogsBetweenDates({ start, end })
@ -51,7 +52,14 @@ export function getUsageForRules(host, rules) {
} }
export async function getUsageForHost(host) { export async function getUsageForHost(host) {
const dismiss = await DB.dismiss.where({ host }).first()
if (dismiss) {
const isDismissed = dj().isBefore(dj(dismiss.timestamp).add(dismiss.duration, 'ms'))
if (isDismissed) return []
}
const limit = await DB.limits.where({ host }).first() const limit = await DB.limits.where({ host }).first()
if (!limit) return []
return await Promise.all(getUsageForRules(host, limit.rules)) return await Promise.all(getUsageForRules(host, limit.rules))
} }
@ -59,3 +67,9 @@ export function percentagesToBool(percentages) {
const blocked = percentages.map((p) => p >= 100).includes(true) const blocked = percentages.map((p) => p >= 100).includes(true)
return blocked return blocked
} }
export async function getSettingsWithDefaults() {
const values = await DB.settings.toArray()
const fromDB = Object.fromEntries(values.map((v) => [v.key, v.value]))
return SettingsValidator.validate(fromDB).value
}

View File

@ -1,3 +0,0 @@
import App from './App.svelte'
new App({ target: window.document.getElementById('root') })

50
src/shared/validation.js Normal file
View File

@ -0,0 +1,50 @@
import Joi from 'joi'
export const DurationTupleValidator = Joi.array().length(2).ordered(Joi.number(), Joi.string())
export const LimitValidator = Joi.object({
host: Joi.string().domain(),
id: Joi.number().optional(),
rules: Joi.array().items(
Joi.object({
limit: DurationTupleValidator,
every: DurationTupleValidator,
})
),
})
export const LogValidator = Joi.object({
host: Joi.string(),
id: Joi.number().optional(),
seconds: Joi.number(),
timestamp: Joi.date(),
})
export const DismissValidator = Joi.object({
host: Joi.string(),
timestamp: Joi.date(),
duration: Joi.number(),
})
export const SettingsValidator = Joi.object({
lastActivity: Joi.date()
.default(() => new Date())
.optional(), // Last user activity, to calculate idle time
retention: Joi.number().default(90).optional(), // Days to keep logs
idleTimeout: DurationTupleValidator.default(5).optional(), // Idle timeout in minutes
})
export const DBValidator = Joi.object({
limits: Joi.array().items(LimitValidator).optional(),
logs: Joi.array().items(LogValidator).optional(),
settings: SettingsValidator.optional(),
dismiss: Joi.array().items(DismissValidator).optional(),
})
export function checkForErrors(validator, data) {
const validated = validator.validate(data, { presence: 'required' })
if (validated.error) {
console.error('Validation error', validated.error)
}
return validated.error
}

8409
yarn.lock

File diff suppressed because it is too large Load Diff