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