From 8e246979d97ca65be0af4a715d29520132b3a532 Mon Sep 17 00:00:00 2001 From: Antoine Drouhin Date: Tue, 17 Nov 2020 18:37:42 +0100 Subject: [PATCH 1/2] Refactor the code to follow a more modular design - Put all goodreads api related methods in a dedicated class - Put all github api related methods in a dedicated class - Put all output formatting in a dedicated class - Prepare the ground for dependency injection and more increased testability - Didn't change any behavior This commit is intended to prepare future work. Design choices made here are also a matter of preference. --- src/api.ts | 58 ---------------------------------- src/bar.ts | 18 ----------- src/formatter.ts | 43 +++++++++++++++++++++++++ src/github.ts | 26 +++++++++++++++ src/goodreads.ts | 70 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 82 +++++++++++++----------------------------------- 6 files changed, 161 insertions(+), 136 deletions(-) delete mode 100644 src/api.ts delete mode 100644 src/bar.ts create mode 100644 src/formatter.ts create mode 100644 src/github.ts create mode 100644 src/goodreads.ts diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index c1b9430..0000000 --- a/src/api.ts +++ /dev/null @@ -1,58 +0,0 @@ -import got from 'got'; -import xml from 'fast-xml-parser'; -import dotenv from 'dotenv'; -import { Octokit } from '@octokit/rest'; - -dotenv.config(); - -const octokit = new Octokit({ auth: process.env.GH_TOKEN }); - -const request = got.extend({ - prefixUrl: 'https://www.goodreads.com', - searchParams: { - v: 2, - key: process.env.GOODREADS_API_KEY, - }, -}); - -export async function getReviewList( - shelf = 'currently-reading' -): Promise { - const res = await request - .get('review/list', { - searchParams: { - id: process.env.GOODREADS_USER_ID, - shelf, - }, - }) - .text(); - const reviewList: Goodreads.ReviewList = xml.parse(res); - const reviews = reviewList.GoodreadsResponse.reviews.review; - return reviews; -} - -export async function getReviewShow(id: number): Promise { - const res = await request.get('review/show', { searchParams: { id } }).text(); - const reviewShow: Goodreads.ReviewShow = xml.parse(res); - const review = reviewShow.GoodreadsResponse.review; - return review; -} - -export async function updateGist( - title: string, - content: string -): Promise { - const gist_id = process.env.GIST_ID || ''; - const gist = await octokit.gists.get({ gist_id }); - const filename = Object.keys(gist.data.files)[0]; - await octokit.gists.update({ - gist_id, - files: { - [filename]: { - filename: title, - content, - }, - }, - }); - return gist.url; -} diff --git a/src/bar.ts b/src/bar.ts deleted file mode 100644 index 640f9c9..0000000 --- a/src/bar.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) 2019, Matan Kushner - * https://github.com/matchai/waka-box/blob/master/index.js - */ -export function generateBarChart(percent: number, size: number): string { - const syms = '░▏▎▍▌▋▊▉█'; - - const frac = Math.floor((size * 8 * percent) / 100); - const barsFull = Math.floor(frac / 8); - if (barsFull >= size) { - return syms.substring(8, 9).repeat(size); - } - const semi = frac % 8; - - return [syms.substring(8, 9).repeat(barsFull), syms.substring(semi, semi + 1)] - .join('') - .padEnd(size, syms.substring(0, 1)); -} diff --git a/src/formatter.ts b/src/formatter.ts new file mode 100644 index 0000000..ccb0f98 --- /dev/null +++ b/src/formatter.ts @@ -0,0 +1,43 @@ + + +export default class Formatter { + + public generateLines( + books: Book[], + MAX_LENGTH = 54, + MAX_LINES = 5): String[] { + const barWidth = Math.floor(MAX_LENGTH / 4) + const lines = books.slice(0, MAX_LINES).map(({ title, percent }) => { + const bar = this.generateBarChart(percent, barWidth) + const percentage = `${percent}%`.padStart(4, ' ') + const length = MAX_LENGTH - bar.length - percentage.length - 1 + let text = title + if (title.length > length) { + text = title.substring(0, length - 3).concat('...') + } else { + text = title.padEnd(length, ' ') + } + return `${text} ${bar}${percentage}` + }) + return lines + } + + /** + * Copyright (c) 2019, Matan Kushner + * https://github.com/matchai/waka-box/blob/master/index.js + */ + private generateBarChart(percent: number, size: number): string { + const syms = '░▏▎▍▌▋▊▉█'; + + const frac = Math.floor((size * 8 * percent) / 100); + const barsFull = Math.floor(frac / 8); + if (barsFull >= size) { + return syms.substring(8, 9).repeat(size); + } + const semi = frac % 8; + + return [syms.substring(8, 9).repeat(barsFull), syms.substring(semi, semi + 1)] + .join('') + .padEnd(size, syms.substring(0, 1)); + } +} diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..0aa1cea --- /dev/null +++ b/src/github.ts @@ -0,0 +1,26 @@ +import { Octokit } from '@octokit/rest' + + +export default class Github { + + private octokit = new Octokit({ auth: process.env.GH_TOKEN }); + + async updateGist( + title: string, + content: string + ): Promise { + const gist_id = process.env.GIST_ID || ''; + const gist = await this.octokit.gists.get({ gist_id }); + const filename = Object.keys(gist.data.files)[0]; + await this.octokit.gists.update({ + gist_id, + files: { + [filename]: { + filename: title, + content, + }, + }, + }); + return gist.url; + } +} diff --git a/src/goodreads.ts b/src/goodreads.ts new file mode 100644 index 0000000..72dfb9a --- /dev/null +++ b/src/goodreads.ts @@ -0,0 +1,70 @@ +import xml from 'fast-xml-parser' +import he from 'he' +import got from 'got' + +export default class Goodreads { + + private request = got.extend({ + prefixUrl: 'https://www.goodreads.com', + searchParams: { + v: 2, + key: process.env.GOODREADS_API_KEY, + }, + }) + + public async getBooks(): Promise { + let reviewList: Goodreads.Review[] + try { + reviewList = await this.getReviewList() + } catch (error) { + throw new Error("Error while fetching review list: " + error) + } + + const reviews = await Promise.all( + reviewList.map(({ id }) => this.getReviewShow(id)) + ) + + const books = reviews + .map(({ id, book, date_updated, user_statuses }) => { + const latestStatus = Array.isArray(user_statuses?.user_status) + ? user_statuses?.user_status[0] + : user_statuses?.user_status + return { + id: id, + title: he.decode(book.title), + percent: latestStatus?.percent || 0, + date: new Date(latestStatus?.updated_at || date_updated), + } + }) + .sort((a, b) => { + if (process.env.BOOKS_SORT_BY === 'percent') { + return b.percent - a.percent + } + return b.date.getTime() - a.date.getTime() + }) + return books + } + + private async getReviewList( + shelf = 'currently-reading' + ): Promise { + const res = await this.request + .get('review/list', { + searchParams: { + id: process.env.GOODREADS_USER_ID, + shelf, + }, + }) + .text() + const reviewList: Goodreads.ReviewList = xml.parse(res) + const reviews = reviewList.GoodreadsResponse.reviews.review + return reviews + } + + private async getReviewShow(id: number): Promise { + const res = await this.request.get('review/show', { searchParams: { id } }).text() + const reviewShow: Goodreads.ReviewShow = xml.parse(res) + const review = reviewShow.GoodreadsResponse.review + return review + } +} diff --git a/src/index.ts b/src/index.ts index 02e969f..a4c4f9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,69 +1,31 @@ -import dotenv from 'dotenv'; -import he from 'he'; +import dotenv from 'dotenv' +import Formatter from './formatter' +import Github from './github' +import Goodreads from './goodreads' -import * as api from './api'; -import { generateBarChart } from './bar'; +dotenv.config() -dotenv.config(); - -const MAX_LENGTH = 54; -const MAX_LINES = 5; +async function main() { + try { + const goodreads = new Goodreads() -async function getBooks(): Promise { - const reviewList = await api.getReviewList(); - const reviews = await Promise.all( - reviewList.map(({ id }) => api.getReviewShow(id)) - ); - const books = reviews - .map(({ id, book, date_updated, user_statuses }) => { - const latestStatus = Array.isArray(user_statuses?.user_status) - ? user_statuses?.user_status[0] - : user_statuses?.user_status; - return { - id: id, - title: he.decode(book.title), - percent: latestStatus?.percent || 0, - date: new Date(latestStatus?.updated_at || date_updated), - }; - }) - .sort((a, b) => { - if (process.env.BOOKS_SORT_BY === 'percent') { - return b.percent - a.percent; - } - return b.date.getTime() - a.date.getTime(); - }); - return books; -} + const books = await goodreads.getBooks() + console.log(`Found ${books.length} book(s)`) -function generateLines(books: Book[]) { - const barWidth = Math.floor(MAX_LENGTH / 4); - const lines = books.slice(0, MAX_LINES).map(({ title, percent }) => { - const bar = generateBarChart(percent, barWidth); - const percentage = `${percent}%`.padStart(4, ' '); - const length = MAX_LENGTH - bar.length - percentage.length - 1; - let text = title; - if (title.length > length) { - text = title.substring(0, length - 3).concat('...'); - } else { - text = title.padEnd(length, ' '); - } - return `${text} ${bar}${percentage}`; - }); - return lines; -} + const formatter = new Formatter() + const lines = formatter.generateLines(books) -(async () => { - try { - const books = await getBooks(); - console.log(`Found ${books.length} book(s)`); - const lines = generateLines(books); - const url = await api.updateGist( + const github = new Github() + const url = await github.updateGist( `📚 Currently reading books (${lines.length}/${books.length})`, lines.join('\n') - ); - console.log(`Updated: ${url}`); + ) + + console.log(`Updated: ${url}`) } catch (error) { - console.error(error); - process.exit(1); + console.error(error) + process.exit(1) } -})(); +} + +main() From e18e7ee8ed8c4bc0a88d9c0b77afe15df5bb450b Mon Sep 17 00:00:00 2001 From: Antoine Drouhin Date: Tue, 17 Nov 2020 20:39:07 +0100 Subject: [PATCH 2/2] fix - handle single review result When getting reviews from goodreads api, if a single result was returned, it was not returned as an array. I fixed this by casting the result to an array. --- src/goodreads.ts | 5 ++++- src/type-utils.ts | 7 +++++++ src/types.d.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 src/type-utils.ts diff --git a/src/goodreads.ts b/src/goodreads.ts index 72dfb9a..e5652fc 100644 --- a/src/goodreads.ts +++ b/src/goodreads.ts @@ -1,6 +1,7 @@ import xml from 'fast-xml-parser' import he from 'he' import got from 'got' +import { ensureArray } from './type-utils' export default class Goodreads { @@ -56,8 +57,10 @@ export default class Goodreads { }, }) .text() + const reviewList: Goodreads.ReviewList = xml.parse(res) - const reviews = reviewList.GoodreadsResponse.reviews.review + // reviews.review can be an array or an item + const reviews = ensureArray(reviewList.GoodreadsResponse.reviews.review) return reviews } diff --git a/src/type-utils.ts b/src/type-utils.ts new file mode 100644 index 0000000..4a2aa29 --- /dev/null +++ b/src/type-utils.ts @@ -0,0 +1,7 @@ + +export function ensureArray(v: T | T[]): T[] { + if (Array.isArray(v)) { + return v + } + return [v] +} diff --git a/src/types.d.ts b/src/types.d.ts index 6eaf2b2..d9222d7 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -19,7 +19,7 @@ declare namespace Goodreads { interface ReviewList { GoodreadsResponse: { reviews: { - review: Review[]; + review: Review[] | Review; }; }; }