From 0e812e585c4d892e8bd16ea0db55636b0d8178e4 Mon Sep 17 00:00:00 2001 From: Cory Sanin Date: Tue, 14 Oct 2025 01:18:42 -0500 Subject: [PATCH] Grocy api --- docker-compose.yml | 6 ++- package-lock.json | 86 +----------------------------- package.json | 14 +++-- src/Grocy.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++ src/Web.ts | 73 ++++++++++++++++---------- src/index.ts | 5 +- 6 files changed, 188 insertions(+), 124 deletions(-) create mode 100644 src/Grocy.ts diff --git a/docker-compose.yml b/docker-compose.yml index 55d174e..75a26f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,13 @@ version: '2' services: - nodejs-web: - container_name: nodejs-web + chore-chart: + container_name: chore-chart build: context: ./ dockerfile: Dockerfile restart: "no" ports: - 8080:8080 + volumes: + - ./config:/usr/src/app/config diff --git a/package-lock.json b/package-lock.json index e25a74e..aaa3018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,22 @@ { - "name": "nodejs-web-template", + "name": "chore-chart", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nodejs-web-template", + "name": "chore-chart", "version": "0.0.1", "license": "MIT", "dependencies": { "ejs": "3.1.10", "express": "5.1.0", - "express-session": "1.18.2", "json5": "2.2.3", "ky": "1.11.0" }, "devDependencies": { "@sindresorhus/tsconfig": "8.0.1", "@types/express": "^5.0.3", - "@types/express-session": "^1.18.2", "@types/node": "^24.7.0", "forking-build-shit": "1.0.4", "typescript": "5.9.3" @@ -393,16 +391,6 @@ "@types/send": "*" } }, - "node_modules/@types/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -835,46 +823,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.1.0", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/express-session/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express-session/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -1305,15 +1253,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1390,15 +1329,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1714,18 +1644,6 @@ "node": ">=0.8.0" } }, - "node_modules/uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "license": "MIT", - "dependencies": { - "random-bytes": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", diff --git a/package.json b/package.json index a5eced3..7df3ca7 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { - "name": "nodejs-web-template", + "name": "chore-chart", "version": "0.0.1", - "description": "A nodejs web project", + "description": "Front end for Grocy chore lists", "keywords": [ "web", - "template" + "grocy" ], - "homepage": "https://github.com/CorySanin/nodejs-web-template#readme", + "homepage": "https://git.sanin.dev/corysanin/chore-chart#readme", "bugs": { - "url": "https://github.com/CorySanin/nodejs-web-template/issues" + "url": "https://git.sanin.dev/corysanin/chore-chart/issues" }, "repository": { "type": "git", - "url": "git+https://github.com/CorySanin/nodejs-web-template.git" + "url": "git+https://git.sanin.dev/corysanin/chore-chart.git" }, "license": "MIT", "author": "Cory Sanin", @@ -26,14 +26,12 @@ "dependencies": { "ejs": "3.1.10", "express": "5.1.0", - "express-session": "1.18.2", "json5": "2.2.3", "ky": "1.11.0" }, "devDependencies": { "@sindresorhus/tsconfig": "8.0.1", "@types/express": "^5.0.3", - "@types/express-session": "^1.18.2", "@types/node": "^24.7.0", "forking-build-shit": "1.0.4", "typescript": "5.9.3" diff --git a/src/Grocy.ts b/src/Grocy.ts new file mode 100644 index 0000000..4a3c13f --- /dev/null +++ b/src/Grocy.ts @@ -0,0 +1,128 @@ +import path from 'path'; +import ky, { HTTPError, type KyInstance } from 'ky'; + +type ISODateString = string; +type Booleaish = 0 | 1; + +interface GrocyConfig { + key: string; + host: string; +} + +interface GrocyUser { + id: number; + username: string; + first_name: string; + last_name: string; + display_name: string; + picture_file_name: string; + row_created_timestamp: ISODateString; +} + +interface GrocyChore { + chore_id: number; + chore_name: string; + last_tracked_time: ISODateString; + track_date_only: Booleaish; + next_estimated_execution_time: ISODateString; + next_execution_assigned_to_user_id: number; + is_rescheduled: Booleaish; + is_reassigned: Booleaish; + next_execution_assigned_user?: GrocyUser +} + +interface GrocyChoresParameters { + query?: string[]; + order?: string; + limit?: number; +} + +interface ChoreExecution { + id: number; + chore_id: number; + tracked_time: ISODateString; + row_created_timestamp: ISODateString; +} + +class Grocy { + private options: GrocyConfig; + private myKy: KyInstance; + + constructor(options: Partial = {}) { + if (typeof options.key !== 'string' || options.key.length <= 0) { + throw new Error('Grocy API key is required'); + } + else if (typeof options.host !== 'string' || options.host.length <= 0) { + throw new Error('Grocy hostname is required'); + } + this.options = options as GrocyConfig; + this.myKy = ky.extend({ + headers: { + "GROCY-API-KEY": options.key + } + }); + } + + private resolve(destination: string, query?: URLSearchParams | undefined): string { + const queryParams = typeof query === 'object' ? query.toString() : ''; + const querySuffix = queryParams.length > 0 ? `?${queryParams}` : ''; + return `${path.join(this.options.host, 'api', destination)}${querySuffix}`; + } + + async users() { + return await this.myKy.get(this.resolve('/users')).json(); + } + + async user(id: number) { + const users = await this.myKy.get(this.resolve(`/users`, new URLSearchParams({ + "query[]": `id=${id}` + }))).json(); + if (users && users.length > 0) { + return users[0] || null; + } + return null; + } + + async chores(params: GrocyChoresParameters = { order: 'next_estimated_execution_time:asc' }) { + const searchParams = new URLSearchParams(); + params?.query?.forEach(q => searchParams.append('query[]', q)); + if ('order' in params) { + searchParams.append('order', params.order); + } + if ('limit' in params) { + searchParams.append('limit', String(params.limit)); + } + return await this.myKy.get(this.resolve('/chores', searchParams)).json(); + } + + async chore(chore_id: number) { + return await this.myKy.get(this.resolve(`/chores/${chore_id}`)).json(); + } + + async doChore(chore_id: number, done_by: number) { + type ChoreExecutionError = { + error_message: string + } + + try { + return await this.myKy.post(this.resolve(`/chores/${chore_id}/execute`), { + json: { + tracked_time: new Date().toISOString(), + done_by, + skipped: false + } + }).json(); + } + catch (error) { + if (!(error instanceof HTTPError)) { + throw error; + } + const errorJson = await (error as HTTPError).response.json(); + throw new Error(`Chore execution failed: ${errorJson?.error_message}`); + } + } +} + +export default Grocy; +export { Grocy }; +export type { GrocyConfig, ISODateString, GrocyUser, GrocyChore }; diff --git a/src/Web.ts b/src/Web.ts index 633a33c..65bcd3a 100644 --- a/src/Web.ts +++ b/src/Web.ts @@ -1,10 +1,8 @@ import * as http from "http"; import crypto from 'crypto'; -import express from 'express'; -import session from 'express-session'; -import ky from 'ky'; +import express, { type Express } from 'express'; import bodyParser from 'body-parser'; -import type { Express } from 'express'; +import { type GrocyUser, type Grocy } from './Grocy.js'; interface WebConfig { sessionSecret?: string; @@ -23,39 +21,22 @@ class Web { private _webserver: http.Server | null = null; private app: Express | null = null; private port: number; - private options: WebConfig; + // private options: WebConfig; + private grocy: Grocy; - constructor(options: WebConfig = {}) { - this.options = options; + constructor(options: WebConfig = {}, grocy: Grocy) { + // this.options = options; this.port = notStupidParseInt(process.env['PORT']) || options['port'] as number || 8080; + this.grocy = grocy } initialize = async () => { - const options = this.options; - const sessionSecret = process.env['SESSIONSECRET'] || options.sessionSecret; const app: Express = this.app = express(); - if(!sessionSecret) { - console.error('sessionSecret is required.'); - throw new Error('sessionSecret is required.'); - } - app.set('trust proxy', 1); app.set('view engine', 'ejs'); app.set('view options', { outputFunctionName: 'echo' }); app.use('/assets', express.static('assets', { maxAge: '30 days' })); - app.use(session({ - name: 'sessionId', - secret: sessionSecret, - resave: true, - saveUninitialized: false, - store: undefined, - cookie: { - maxAge: notStupidParseInt(process.env['COOKIETTL']) || 1000 * 60 * 60 * 24 * 30, // 30 days - httpOnly: true, - secure: !!options.secure - } - })); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use((_req, res, next) => { @@ -84,11 +65,45 @@ class Web { }); }); - app.get('/ky', async (_, res) => { - res.send(await (await ky.get('https://sanin.dev')).text()); + app.get('/api/chores', async (_, res) => { + const chores = await this.grocy.chores(); + const users: Record = {}; + const missing: Set = new Set(); + chores.forEach(chore => { + if ('next_execution_assigned_user' in chore) { + users[chore.next_execution_assigned_to_user_id] = chore.next_execution_assigned_user + } + }); + chores.forEach(chore => { + if (!(chore.next_execution_assigned_to_user_id in users) && typeof chore.next_execution_assigned_to_user_id === 'number') { + missing.add(chore.next_execution_assigned_to_user_id); + } + }); + (await Promise.all([...missing].map(id => this.grocy.user(id)))).forEach(user => { + if (!user) { + return; + } + users[user.id] = user; + }); + chores.forEach(chore => { + if (!('next_execution_assigned_user' in chore) && chore.next_execution_assigned_to_user_id in users) { + chore.next_execution_assigned_user = users[chore.next_execution_assigned_to_user_id] as GrocyUser; + } + }); + + res.send({ + chores + }); }); - this._webserver = this.app.listen(this.port, () => console.log(`archery is running on port ${this.port}`)); + app.get('/api/users/:id', async (req, res) => { + const user = await this.grocy.user(parseInt(req.params.id)) + res.send({ + user + }); + }); + + this._webserver = this.app.listen(this.port, () => console.log(`chore-chart is running on port ${this.port}`)); } close = () => { diff --git a/src/index.ts b/src/index.ts index 1df3b8f..36a3c52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,11 @@ import fs from 'fs'; import path from 'path'; import JSON5 from 'json5'; import { Web, type WebConfig } from './Web.js'; +import { Grocy, type GrocyConfig } from './Grocy.js'; interface compositeConfig { web?: WebConfig + grocy?: GrocyConfig } async function readConfig(): Promise { @@ -20,7 +22,8 @@ async function readConfig(): Promise { const config: compositeConfig = await readConfig(); -const web = new Web(config.web); +const grocy = new Grocy(config.grocy) +const web = new Web(config.web, grocy); await web.initialize(); process.on('SIGTERM', () => {