From 9cf601ad429d29cd619d939fc5a7697a6bbfcc62 Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Fri, 24 May 2024 17:31:45 +0200 Subject: [PATCH] bookmark list output is not a valid JSON #150 Reworked the cli to switch over to json output --- apps/cli/src/commands/bookmarks.ts | 96 +++++++++++++++++++----------- apps/cli/src/commands/lists.ts | 91 ++++++++++++++++++++-------- apps/cli/src/commands/tags.ts | 50 +++++++++++----- apps/cli/src/commands/whoami.ts | 11 +++- apps/cli/src/index.ts | 1 + apps/cli/src/lib/globals.ts | 1 + apps/cli/src/lib/output.ts | 74 +++++++++++++++++++++++ 7 files changed, 246 insertions(+), 78 deletions(-) create mode 100644 apps/cli/src/lib/output.ts diff --git a/apps/cli/src/commands/bookmarks.ts b/apps/cli/src/commands/bookmarks.ts index 0f557120..b3ab5f22 100644 --- a/apps/cli/src/commands/bookmarks.ts +++ b/apps/cli/src/commands/bookmarks.ts @@ -1,7 +1,12 @@ import * as fs from "node:fs"; +import { + printError, + printObject, + printStatusMessage, + printSuccess, +} from "@/lib/output"; import { getAPIClient } from "@/lib/trpc"; import { Command } from "@commander-js/extra-typings"; -import chalk from "chalk"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@hoarder/shared/types/bookmarks"; @@ -30,6 +35,10 @@ function normalizeBookmark(bookmark: ZBookmark) { return ret; } +function printBookmark(bookmark: ZBookmark) { + printObject(normalizeBookmark(bookmark)); +} + bookmarkCmd .command("add") .description("creates a new bookmark") @@ -51,29 +60,39 @@ bookmarkCmd const promises = [ ...opts.link.map((url) => - api.bookmarks.createBookmark.mutate({ type: "link", url }), + api.bookmarks.createBookmark + .mutate({ type: "link", url }) + .then(printBookmark) + .catch(printError(`Failed to add a link bookmark for url "${url}"`)), ), ...opts.note.map((text) => - api.bookmarks.createBookmark.mutate({ type: "text", text }), + api.bookmarks.createBookmark + .mutate({ type: "text", text }) + .then(printBookmark) + .catch( + printError( + `Failed to add a text bookmark with text "${text.substring(0, 50)}"`, + ), + ), ), ]; if (opts.stdin) { const text = fs.readFileSync(0, "utf-8"); promises.push( - api.bookmarks.createBookmark.mutate({ type: "text", text }), + api.bookmarks.createBookmark + .mutate({ type: "text", text }) + .then(printBookmark) + .catch( + printError( + `Failed to add a text bookmark with text "${text.substring(0, 50)}"`, + ), + ), ); } - const results = await Promise.allSettled(promises); - - for (const res of results) { - if (res.status == "fulfilled") { - console.log(normalizeBookmark(res.value)); - } else { - console.log(chalk.red(`Error: ${res.reason}`)); - } - } + // TODO: if you choose JSON output, the output would be multiple JSONs and not a single one, so it would not be parsable --> change behavior? + await Promise.allSettled(promises); }); bookmarkCmd @@ -82,8 +101,10 @@ bookmarkCmd .argument("", "The id of the bookmark to get") .action(async (id) => { const api = getAPIClient(); - const resp = await api.bookmarks.getBookmark.query({ bookmarkId: id }); - console.log(normalizeBookmark(resp)); + await api.bookmarks.getBookmark + .query({ bookmarkId: id }) + .then(printBookmark) + .catch(printError(`Failed to get the bookmark with id "${id}"`)); }); bookmarkCmd @@ -98,13 +119,15 @@ bookmarkCmd .argument("", "the id of the bookmark to get") .action(async (id, opts) => { const api = getAPIClient(); - const resp = await api.bookmarks.updateBookmark.mutate({ - bookmarkId: id, - archived: opts.archive, - favourited: opts.favourite, - title: opts.title, - }); - console.log(resp); + await api.bookmarks.updateBookmark + .mutate({ + bookmarkId: id, + archived: opts.archive, + favourited: opts.favourite, + title: opts.title, + }) + .then(printObject) + .catch(printError(`Failed to update bookmark with id "${id}"`)); }); bookmarkCmd @@ -126,18 +149,21 @@ bookmarkCmd useCursorV2: true, }; - let resp = await api.bookmarks.getBookmarks.query(request); - let results: ZBookmark[] = resp.bookmarks; + try { + let resp = await api.bookmarks.getBookmarks.query(request); + let results: ZBookmark[] = resp.bookmarks; - while (resp.nextCursor) { - resp = await api.bookmarks.getBookmarks.query({ - ...request, - cursor: resp.nextCursor, - }); - results = [...results, ...resp.bookmarks]; + while (resp.nextCursor) { + resp = await api.bookmarks.getBookmarks.query({ + ...request, + cursor: resp.nextCursor, + }); + results = [...results, ...resp.bookmarks]; + } + printObject(results.map(normalizeBookmark), { maxArrayLength: null }); + } catch (e) { + printStatusMessage(false, "Failed to query bookmarks"); } - - console.dir(results.map(normalizeBookmark), { maxArrayLength: null }); }); bookmarkCmd @@ -146,6 +172,8 @@ bookmarkCmd .argument("", "the id of the bookmark to delete") .action(async (id) => { const api = getAPIClient(); - await api.bookmarks.deleteBookmark.mutate({ bookmarkId: id }); - console.log(`Bookmark ${id} got deleted`); + await api.bookmarks.deleteBookmark + .mutate({ bookmarkId: id }) + .then(printSuccess(`Bookmark with id '${id}' got deleted`)) + .catch(printError(`Failed to delete bookmark with id "${id}"`)); }); diff --git a/apps/cli/src/commands/lists.ts b/apps/cli/src/commands/lists.ts index c7b2a5f0..2f85ae7b 100644 --- a/apps/cli/src/commands/lists.ts +++ b/apps/cli/src/commands/lists.ts @@ -1,3 +1,10 @@ +import { getGlobalOptions } from "@/lib/globals"; +import { + printError, + printErrorMessageWithReason, + printObject, + printSuccess, +} from "@/lib/output"; import { getAPIClient } from "@/lib/trpc"; import { Command } from "@commander-js/extra-typings"; import { getBorderCharacters, table } from "table"; @@ -14,19 +21,30 @@ listsCmd .action(async () => { const api = getAPIClient(); - const resp = await api.lists.list.query(); - const { allPaths } = listsToTree(resp.lists); + try { + const resp = await api.lists.list.query(); - const data: string[][] = [["Id", "Name"]]; + if (getGlobalOptions().json) { + printObject(resp); + } else { + const { allPaths } = listsToTree(resp.lists); + const data: string[][] = [["Id", "Name"]]; - allPaths.forEach((path) => { - const name = path.map((p) => `${p.icon} ${p.name}`).join(" / "); - const id = path[path.length - 1].id; - data.push([id, name]); - }); - console.log( - table(data, { border: getBorderCharacters("ramac"), singleLine: true }), - ); + allPaths.forEach((path) => { + const name = path.map((p) => `${p.icon} ${p.name}`).join(" / "); + const id = path[path.length - 1].id; + data.push([id, name]); + }); + console.log( + table(data, { + border: getBorderCharacters("ramac"), + singleLine: true, + }), + ); + } + } catch (error) { + printErrorMessageWithReason("Failed to list all lists", error as object); + } }); listsCmd @@ -36,10 +54,12 @@ listsCmd .action(async (id) => { const api = getAPIClient(); - await api.lists.delete.mutate({ - listId: id, - }); - console.log("Successfully deleted list with id:", id); + await api.lists.delete + .mutate({ + listId: id, + }) + .then(printSuccess(`Successfully deleted list with id "${id}"`)) + .catch(printError(`Failed to delete list with id "${id}"`)); }); listsCmd @@ -50,11 +70,21 @@ listsCmd .action(async (opts) => { const api = getAPIClient(); - await api.lists.addToList.mutate({ - listId: opts.list, - bookmarkId: opts.bookmark, - }); - console.log("Successfully added bookmark from list"); + await api.lists.addToList + .mutate({ + listId: opts.list, + bookmarkId: opts.bookmark, + }) + .then( + printSuccess( + `Successfully added bookmark "${opts.bookmark}" to list with id "${opts.list}"`, + ), + ) + .catch( + printError( + `Failed to add bookmark "${opts.bookmark}" to list with id "${opts.list}"`, + ), + ); }); listsCmd @@ -65,10 +95,19 @@ listsCmd .action(async (opts) => { const api = getAPIClient(); - await api.lists.removeFromList.mutate({ - listId: opts.list, - bookmarkId: opts.bookmark, - }); - - console.log("Successfully removed bookmark from list"); + await api.lists.removeFromList + .mutate({ + listId: opts.list, + bookmarkId: opts.bookmark, + }) + .then( + printSuccess( + `Successfully removed bookmark "${opts.bookmark}" from list with id "${opts.list}"`, + ), + ) + .catch( + printError( + `Failed to remove bookmark "${opts.bookmark}" from list with id "${opts.list}"`, + ), + ); }); diff --git a/apps/cli/src/commands/tags.ts b/apps/cli/src/commands/tags.ts index 410f1abd..c2c1dd3a 100644 --- a/apps/cli/src/commands/tags.ts +++ b/apps/cli/src/commands/tags.ts @@ -1,3 +1,10 @@ +import { getGlobalOptions } from "@/lib/globals"; +import { + printError, + printErrorMessageWithReason, + printObject, + printSuccess, +} from "@/lib/output"; import { getAPIClient } from "@/lib/trpc"; import { Command } from "@commander-js/extra-typings"; import { getBorderCharacters, table } from "table"; @@ -12,17 +19,27 @@ tagsCmd .action(async () => { const api = getAPIClient(); - const tags = (await api.tags.list.query()).tags; - tags.sort((a, b) => b.count - a.count); - - const data: string[][] = [["Id", "Name", "Num bookmarks"]]; - - tags.forEach((tag) => { - data.push([tag.id, tag.name, tag.count.toString()]); - }); - console.log( - table(data, { border: getBorderCharacters("ramac"), singleLine: true }), - ); + try { + const tags = (await api.tags.list.query()).tags; + tags.sort((a, b) => b.count - a.count); + if (getGlobalOptions().json) { + printObject(tags); + } else { + const data: string[][] = [["Id", "Name", "Num bookmarks"]]; + + tags.forEach((tag) => { + data.push([tag.id, tag.name, tag.count.toString()]); + }); + console.log( + table(data, { + border: getBorderCharacters("ramac"), + singleLine: true, + }), + ); + } + } catch (error) { + printErrorMessageWithReason("Failed to list all tags", error as object); + } }); tagsCmd @@ -32,9 +49,10 @@ tagsCmd .action(async (id) => { const api = getAPIClient(); - await api.tags.delete.mutate({ - tagId: id, - }); - - console.log("Successfully delete the tag with id:", id); + await api.tags.delete + .mutate({ + tagId: id, + }) + .then(printSuccess(`Successfully deleted the tag with the id "${id}"`)) + .catch(printError(`Failed to delete the tag with the id "${id}"`)); }); diff --git a/apps/cli/src/commands/whoami.ts b/apps/cli/src/commands/whoami.ts index b55bfa67..06a94e8f 100644 --- a/apps/cli/src/commands/whoami.ts +++ b/apps/cli/src/commands/whoami.ts @@ -1,3 +1,4 @@ +import { printError, printObject } from "@/lib/output"; import { getAPIClient } from "@/lib/trpc"; import { Command } from "@commander-js/extra-typings"; @@ -5,6 +6,12 @@ export const whoamiCmd = new Command() .name("whoami") .description("returns info about the owner of this API key") .action(async () => { - const resp = await getAPIClient().users.whoami.query(); - console.log(resp); + await getAPIClient() + .users.whoami.query() + .then(printObject) + .catch( + printError( + `Unable to fetch information about the owner of this API key`, + ), + ); }); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 12cd7a13..51a119b7 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -22,6 +22,7 @@ const program = new Command() .makeOptionMandatory(true) .env("HOARDER_SERVER_ADDR"), ) + .addOption(new Option("--json", "to output the result as JSON")) .version(process.env.npm_package_version ?? "0.0.0"); program.addCommand(bookmarkCmd); diff --git a/apps/cli/src/lib/globals.ts b/apps/cli/src/lib/globals.ts index 771136da..8a301cfe 100644 --- a/apps/cli/src/lib/globals.ts +++ b/apps/cli/src/lib/globals.ts @@ -1,6 +1,7 @@ export interface GlobalOptions { apiKey: string; serverAddr: string; + json?: true; } export let globalOpts: GlobalOptions | undefined = undefined; diff --git a/apps/cli/src/lib/output.ts b/apps/cli/src/lib/output.ts new file mode 100644 index 00000000..50000408 --- /dev/null +++ b/apps/cli/src/lib/output.ts @@ -0,0 +1,74 @@ +import { InspectOptions } from "util"; +import chalk from "chalk"; + +import { getGlobalOptions } from "./globals"; + +/** + * Prints an object either in a nicely formatted way or as JSON (depending on the command flag --json) + * + * @param output + */ +export function printObject( + output: object, + extraOptions?: InspectOptions, +): void { + if (getGlobalOptions().json) { + console.log(JSON.stringify(output, undefined, 4)); + } else { + console.dir(output, extraOptions); + } +} + +/** + * Used to output a status (success/error) and a message either as string or as JSON (depending on the command flag --json) + * + * @param success if the message is a successful message or an error + * @param output the message to output + */ +export function printStatusMessage(success: boolean, message: unknown): void { + const status = success ? "Success" : "Error"; + if (getGlobalOptions().json) { + console.log( + JSON.stringify( + { + status, + message, + }, + null, + 4, + ), + ); + } else { + const colorFunction = success ? chalk.green : chalk.red; + console.log(colorFunction(`${status}: ${message}`)); + } +} + +/** + * @param message The message that will be printed as a successful message + * @returns a function that can be used in a Promise on success + */ +export function printSuccess(message: string) { + return () => { + printStatusMessage(true, message); + }; +} + +/** + * @param message The message that will be printed as an error message + * @returns a function that can be used in a Promise on rejection + */ +export function printError(message: string) { + return (error: object) => { + printErrorMessageWithReason(message, error); + }; +} + +/** + * @param message The message that will be printed as an error message + * @param error an error object with the reason for the error + */ +export function printErrorMessageWithReason(message: string, error: object) { + const errorMessage = "message" in error ? error.message : error; + printStatusMessage(false, `${message}. Reason: ${errorMessage}`); +}