marvin-minder/Marvin.js

171 lines
4.8 KiB
JavaScript

const fs = require('fs')
const PouchDB = require('pouchdb')
const {DateTime, Interval, Duration} = require('luxon')
const util = require('util')
PouchDB.plugin(require('pouchdb-find'))
const fetch = require('node-fetch')
Duration.zero = Duration.fromMillis(0)
class Marvin {
constructor(credentials=Marvin.parseCredentials()) {
const c = credentials
let u = new URL(c.syncServer)
Object.assign(u, {
username: c.syncUser,
password: c.syncPassword,
pathname: "/" + c.syncDatabase
})
this.credentials = credentials
this.remote = new PouchDB(u.href)
this.db = new PouchDB("marvin")
this.synced = new Promise((resolve, reject) => {
this.remote.replicate.to(this.db)
.on('complete', resolve)
.on('error', reject)
})
this.synced.then(() => {
console.log("sync complete!")
})
this.root = new Taskset(this, 'root', {})
}
async api(url, data=null) {
const response = await fetch("https://serv.amazingmarvin.com/api/" + url, {
method: data ? "POST" : "GET",
body: data ? JSON.stringify(data) : null,
headers: {
"X-API-Token": this.credentials.apiToken,
'Content-Type': 'application/json',
"Accept": "application/json"
// todo: full access token
}
})
if (response.ok) {
if (response.headers.get('content-type').startsWith("application/json")) {
return response.json()
}
return response.text()
}
else throw [response.status, await response.text()]
}
async test() {
// todo: throw?
return (await this.api("test", {})) == "OK"
}
trackedItem() {
// todo: wrap in Task?
return this.api("trackedItem")
}
todayItems() {
return this.api("todayItems")
}
dueItems() {
return this.api("dueItems")
}
categories() {
return this.api("categories")
}
labels() {
return this.api("labels")
}
me() {
return this.api("me")
}
trackInfo(taskIds) {
return this.api("tracks", { taskIds })
}
}
function chunk(xs, n) {
if ((xs.length % n) != 0) console.error("wrong chunk length")
const res = new Array(xs.length / n)
for(let i = 0; i < xs.length; i += n) {
res[i/n] = xs.slice(i, i+n)
}
return res
}
function toInterval([start, end]) {
return Interval.fromDateTimes(DateTime.fromMillis(start), DateTime.fromMillis(end))
}
Duration.sum = function sumDurations(durs) {
return durs.reduce((p, c) => p.plus(c), Duration.zero)
}
class Task {
constructor(task) {
this._task = task
Object.assign(this, {
title: task.title,
day: task.day,
dueDate: task.dueDate,
done: !!task.done,
doneAt: task.doneAt ? DateTime.fromMillis(task.doneAt) : null,
createdAt: DateTime.fromMillis(task.createdAt),
times: task.times ? chunk(task.times, 2).map(toInterval) : []
})
}
timeSpentOn(interval) {
return Duration.sum(this.times.map(time => time.overlaps(interval) ? time.intersection(interval).toDuration() : Duration.zero))
}
[util.inspect.custom](depth, opts) {
return {
title: this.title,
day: this.day,
dueDate: this.dueDate,
done: this.done,
doneAt: this.doneAt ? this.doneAt.toISO() : null,
createdAt: this.createdAt.toISO(),
times: this.times
}
}
}
class Taskset {
constructor(marv, _id, rest) {
Object.assign(this, {marv, _id}, rest)
}
async category(n) {
await this.marv.synced
const d = await this.marv.db.find({
selector: {
db: "Categories",
title: n,
parentId: this._id
}
})
if (d && d.docs) return new Taskset(this.marv, d.docs[0]._id, d.docs[0])
}
async tasks() {
await this.marv.synced
const d = await this.marv.db.find({
selector: {
db: "Tasks",
parentId: this._id
},
})
if (d) {
return d.docs.map(x => new Task(x))
}
}
async tasksOverlapping(interval) {
const tasks = await this.tasks()
return tasks.filter(task =>
task.times.some(x => x.overlaps(interval))
)
}
}
Marvin.parseCredentials = (fileContents = fs.readFileSync("credentials", "utf8")) => {
const res = {}
for(const line of fileContents.split('\n')) {
if (line.startsWith('# ') || !line) continue
const [k,v] = line.split(': ', 2)
res[k] = v
}
return res
}
module.exports = Marvin