From 1f9e2d856f5df33122eab2d1266e42db7c494c48 Mon Sep 17 00:00:00 2001 From: Cory Dransfeldt Date: Sun, 8 Jun 2025 17:21:05 -0700 Subject: [PATCH] feat(cli): add support for repeated directus tasks --- cli/bin/index.js | 6 +- cli/lib/config.js | 27 ++++ cli/lib/directus/client.js | 80 +++++++++++ cli/lib/directus/relationHelpers.js | 46 ++++++ cli/lib/tasks/addEpisodeToShow.js | 80 +++++++++++ cli/lib/tasks/addLinkToShare.js | 93 +++++++++++++ cli/lib/tasks/addPost.js | 186 +++++++++++++++++++++++++ cli/lib/tasks/index.js | 25 ++++ cli/lib/tasks/updateReadingProgress.js | 43 ++++++ cli/package-lock.json | 17 ++- cli/package.json | 3 +- 11 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 cli/lib/directus/client.js create mode 100644 cli/lib/directus/relationHelpers.js create mode 100644 cli/lib/tasks/addEpisodeToShow.js create mode 100644 cli/lib/tasks/addLinkToShare.js create mode 100644 cli/lib/tasks/addPost.js create mode 100644 cli/lib/tasks/index.js create mode 100644 cli/lib/tasks/updateReadingProgress.js diff --git a/cli/bin/index.js b/cli/bin/index.js index 2f6565d..20aea6c 100755 --- a/cli/bin/index.js +++ b/cli/bin/index.js @@ -8,14 +8,16 @@ import { downloadAsset } from '../lib/download.js'; import { runRootScript } from '../lib/runScript.js'; import { handleExitError } from '../lib/handlers.js'; import { runJobsMenu } from '../lib/jobs.js'; +import { runTasksMenu } from '../lib/tasks/index.js'; process.on('unhandledRejection', (err) => handleExitError(err, 'Unhandled rejection')); process.on('uncaughtException', (err) => handleExitError(err, 'Uncaught exception')); -program.name('coryd').description('🪄 Run commands, jobs, download things and have fun.').version('2.0.0'); +program.name('coryd').description('🪄 Run commands, jobs, download things and have fun.').version('3.0.0'); program.command('init').description('Initialize CLI and populate required config.').action(initConfig); program.command('run [script]').description('Run site scripts and commands.').action(runRootScript); -program.command('jobs').description('Trigger jobs and tasks.').action(runJobsMenu); +program.command('tasks').description('Handle repeated tasks.').action(runTasksMenu); +program.command('jobs').description('Trigger jobs and scripts.').action(runJobsMenu); program.command('download').description('Download, name and store image assets.').action(downloadAsset); if (process.argv.length <= 2) { diff --git a/cli/lib/config.js b/cli/lib/config.js index 85a9bf8..adfbaa2 100644 --- a/cli/lib/config.js +++ b/cli/lib/config.js @@ -111,6 +111,33 @@ export const initConfig = async () => { ).bookPath : 'Media assets/books'; + if (config.directus?.apiUrl) { + const { updateApiUrl } = await inquirer.prompt([{ + type: 'confirm', + name: 'updateApiUrl', + message: `Directus API URL is already set to "${config.directus.apiUrl}". Do you want to update it?`, + default: false + }]); + + if (updateApiUrl) { + const { apiUrl } = await inquirer.prompt([{ + name: 'apiUrl', + message: 'Enter your Directus instance URL:', + validate: input => input.startsWith('http') || 'Must be a valid URL' + }]); + + config.directus.apiUrl = apiUrl; + } + } else { + const { apiUrl } = await inquirer.prompt([{ + name: 'apiUrl', + message: 'Enter your Directus URL:', + validate: input => input.startsWith('http') || 'Must be a valid URL' + }]); + + config.directus = { ...(config.directus || {}), apiUrl }; + } + config.globals = await fetchGlobals(); await fs.ensureDir(CACHE_DIR); diff --git a/cli/lib/directus/client.js b/cli/lib/directus/client.js new file mode 100644 index 0000000..402026f --- /dev/null +++ b/cli/lib/directus/client.js @@ -0,0 +1,80 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import { createDirectus, staticToken, rest } from '@directus/sdk'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +dotenv.config({ path: path.resolve(__dirname, '..', '..', '..', '.env') }); + +let directus; +let API_URL; + +export const initDirectusClient = (config) => { + API_URL = config.directus?.apiUrl; + + const token = process.env.DIRECTUS_API_TOKEN; + + if (!API_URL || !token) throw new Error('Missing Directus API URL or token.'); + + directus = createDirectus(API_URL).with(staticToken(process.env.DIRECTUS_API_TOKEN)).with(rest()); +}; + +export const getDirectusClient = () => { + if (!directus) throw new Error('Directus client not initialized.'); + + return directus; +}; + +const request = async (method, endpoint, body = null) => { + const API_TOKEN = process.env.DIRECTUS_API_TOKEN; + const res = await fetch(`${API_URL}/items/${endpoint}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${API_TOKEN}`, + }, + body: body ? JSON.stringify(body) : null, + }); + + if (!res.ok) { + const errorText = await res.text(); + + throw new Error(`Directus API error: ${res.status} ${errorText}`); + } + + return await res.json(); +}; + +export const searchItems = async (collection, query = '', filters = {}) => { + const API_TOKEN = process.env.DIRECTUS_API_TOKEN; + const params = new URLSearchParams(); + + if (query) params.append('search', query); + + params.append('limit', '50'); + + for (const [field, value] of Object.entries(filters)) { + params.append(`filter[${field}][_eq]`, value); + } + + try { + const res = await fetch(`${API_URL}/items/${collection}?${params.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${API_TOKEN}`, + }, + }); + const data = await res.json(); + + return data?.data ?? []; + } catch (err) { + console.error(`❌ Failed to search ${collection}:`, err.message); + return []; + } +}; + +export const updateItem = async (collection, id, values) => await request('PATCH', `${collection}/${id}`, values); + +export const createItem = async (collection, values) => await request('POST', collection, values); diff --git a/cli/lib/directus/relationHelpers.js b/cli/lib/directus/relationHelpers.js new file mode 100644 index 0000000..23bc7c6 --- /dev/null +++ b/cli/lib/directus/relationHelpers.js @@ -0,0 +1,46 @@ +import inquirer from 'inquirer'; +import { searchItems } from '../directus/client.js'; + +export const promptForMultipleRelations = async (collection, label = collection) => { + const selectedIds = new Set(); + + while (true) { + const { query } = await inquirer.prompt({ + name: 'query', + message: `🔍 Search ${label} (or leave blank to finish):` + }); + const trimmed = query.trim(); + + if (!trimmed) break; + + const results = await searchItems(collection, trimmed); + + if (!results.length) { + console.warn(`⚠️ No ${collection} found for "${query}".`); + continue; + } + + const { selected } = await inquirer.prompt({ + type: 'checkbox', + name: 'selected', + message: `✔ Select ${label} to add:`, + choices: results.map(item => ({ + name: item.name || item.title || item.id, + value: item.id + })) + }); + + selected.forEach(id => selectedIds.add(id)); + + const { again } = await inquirer.prompt({ + type: 'confirm', + name: 'again', + message: `Search and add more ${label}?`, + default: false + }); + + if (!again) break; + } + + return Array.from(selectedIds); +}; diff --git a/cli/lib/tasks/addEpisodeToShow.js b/cli/lib/tasks/addEpisodeToShow.js new file mode 100644 index 0000000..89fc5ac --- /dev/null +++ b/cli/lib/tasks/addEpisodeToShow.js @@ -0,0 +1,80 @@ +import inquirer from 'inquirer'; +import { loadConfig } from '../config.js'; +import { initDirectusClient, getDirectusClient, searchItems, createItem, updateItem } from '../directus/client.js'; + +export const addEpisodeToShow = async () => { + const config = await loadConfig(); + + initDirectusClient(config); + + const directus = getDirectusClient(); + const showResults = await inquirer.prompt({ + name: 'query', + message: 'Search for a show:', + }); + const matches = await searchItems('shows', showResults.query); + + if (!matches.length) { + console.warn('⚠️ No matching shows found.'); + return; + } + + const { showId } = await inquirer.prompt({ + type: 'list', + name: 'showId', + message: 'Select a show:', + choices: matches.map(s => ({ + name: s.title || s.name || s.id, + value: s.id, + })), + }); + const { season_number, episode_number, plays } = await inquirer.prompt([ + { + name: 'season_number', + message: 'Season number:', + validate: val => !isNaN(val), + }, + { + name: 'episode_number', + message: 'Episode number:', + validate: val => !isNaN(val), + }, + { + name: 'plays', + message: 'Play count:', + default: 0, + validate: val => !isNaN(val), + }, + ]); + const existing = await searchItems('episodes', `${season_number} ${episode_number}`); + const match = existing.find(e => + Number(e.season_number) === Number(season_number) && + Number(e.episode_number) === Number(episode_number) && + e.show === showId + ); + + if (match) { + const { update } = await inquirer.prompt({ + type: 'confirm', + name: 'update', + message: `Episode exists. Update play count from ${match.plays ?? 0} to ${plays}?`, + default: true, + }); + + if (update) { + await updateItem('episodes', match.id, { plays }); + console.log(`✅ Updated episode: S${season_number}E${episode_number}`); + } else { + console.warn('⚠️ Skipped update.'); + } + } else { + await createItem('episodes', { + season_number: Number(season_number), + episode_number: Number(episode_number), + plays: Number(plays), + show: showId, + }); + + console.log(`📺 Created episode S${season_number}E${episode_number}`); + } +}; diff --git a/cli/lib/tasks/addLinkToShare.js b/cli/lib/tasks/addLinkToShare.js new file mode 100644 index 0000000..9d17539 --- /dev/null +++ b/cli/lib/tasks/addLinkToShare.js @@ -0,0 +1,93 @@ +import inquirer from 'inquirer'; +import { loadConfig } from '../config.js'; +import { initDirectusClient, searchItems, createItem } from '../directus/client.js'; + +export const addLinkToShare = async () => { + const config = await loadConfig(); + + initDirectusClient(config); + + const { title, link, description, authorQuery } = await inquirer.prompt([{ + name: 'title', + message: '📝 Title for the link:', + validate: input => !!input || 'Title is required' + }, + { + name: 'link', + message: '🔗 URL to share:', + validate: input => input.startsWith('http') || 'Must be a valid URL' + }, + { + name: 'description', + message: '🗒 Description (optional):', + default: '' + }, + { + name: 'authorQuery', + message: '👤 Search for an author:', + }]); + const authorMatches = await searchItems('authors', authorQuery); + + if (!authorMatches.length) { + console.log('❌ No matching authors found.'); + return; + } + + const { author } = await inquirer.prompt({ + type: 'list', + name: 'author', + message: 'Select an author:', + choices: authorMatches.map(a => ({ + name: a.name || a.id, + value: a.id, + })) + }); + + let tagIds = []; + + while (true) { + const { query } = await inquirer.prompt({ + name: 'query', + message: '🏷 Search for tags (or leave blank to finish):', + }); + const trimmedQuery = query.trim(); + + if (!trimmedQuery) break; + + const tags = await searchItems('tags', trimmedQuery); + + if (!tags.length) { + console.warn(`⚠️ No tags found matching "${trimmedQuery}"`); + continue; + } + + const { selected } = await inquirer.prompt({ + type: 'checkbox', + name: 'selected', + message: '✔ Select tags to add:', + choices: tags.map(tag => ({ name: tag.name, value: tag.id })) + }); + + tagIds.push(...selected); + + const { again } = await inquirer.prompt({ + type: 'confirm', + name: 'again', + message: 'Search and select more tags?', + default: false, + }); + + if (!again) break; + } + + await createItem('links', { + title, + link, + description, + author, + link_tags: tagIds.map(tagId => ({ tags_id: tagId })), + date: new Date().toISOString() + }); + + console.log('✅ Link created successfully.'); +}; diff --git a/cli/lib/tasks/addPost.js b/cli/lib/tasks/addPost.js new file mode 100644 index 0000000..dd7a985 --- /dev/null +++ b/cli/lib/tasks/addPost.js @@ -0,0 +1,186 @@ +import inquirer from 'inquirer'; +import { loadConfig } from '../config.js'; +import { initDirectusClient, createItem, searchItems } from '../directus/client.js'; +import { promptForMultipleRelations } from '../directus/relationHelpers.js'; + +const ASSOCIATED_MEDIA_TYPES = ['artists', 'books', 'movies', 'shows', 'genres']; +const BLOCK_COLLECTIONS = [ + 'youtube_player', + 'github_banner', + 'npm_banner', + 'rss_banner', + 'calendar_banner', + 'forgejo_banner' +]; + +export const addPost = async () => { + const config = await loadConfig(); + + initDirectusClient(config); + + const { title, description, content, featured } = await inquirer.prompt([{ + name: 'title', + message: '📝 Title:', + validate: input => !!input || 'Title is required' + }, + { + name: 'description', + message: '🗒 Description:', + default: '' + }, + { + name: 'content', + message: '📄 Content:', + default: '' + }, + { + type: 'confirm', + name: 'featured', + message: '⭐ Featured?', + default: false + }]); + + let tagIds = []; + + while (true) { + const { query } = await inquirer.prompt({ + name: 'query', + message: '🏷 Search for tags (or leave blank to finish):', + }); + const trimmedQuery = query.trim(); + + if (!trimmedQuery) break; + + const tags = await searchItems('tags', trimmedQuery); + + if (!tags.length) { + console.warn(`⚠️ No tags found matching "${trimmedQuery}"`); + continue; + } + + const { selected } = await inquirer.prompt({ + type: 'checkbox', + name: 'selected', + message: '✔ Select tags to add:', + choices: tags.map(tag => ({ name: tag.name, value: tag.id })) + }); + + tagIds.push(...selected); + + const { again } = await inquirer.prompt({ + type: 'confirm', + name: 'again', + message: 'Search and select more tags?', + default: false, + }); + + if (!again) break; + } + + const selectedBlocks = []; + const { includeBlocks } = await inquirer.prompt({ + type: 'confirm', + name: 'includeBlocks', + message: '➕ Add blocks?', + default: false + }); + + if (includeBlocks) { + while (true) { + const { collection } = await inquirer.prompt({ + type: 'list', + name: 'collection', + message: '🧱 Choose a block collection (or Cancel to finish):', + choices: [...BLOCK_COLLECTIONS, new inquirer.Separator(), 'Cancel'] + }); + + if (collection === 'Cancel') break; + + const { query } = await inquirer.prompt({ + name: 'query', + message: `🔍 Search ${collection}:` + }); + const results = await searchItems(collection, query); + + if (!results.length) { + console.warn(`⚠️ No items found in "${collection}" matching "${query}"`); + continue; + } + + const { itemId } = await inquirer.prompt({ + type: 'list', + name: 'itemId', + message: `Select an item from ${collection}:`, + choices: results.map(item => ({ + name: item.title || item.name || item.id, + value: item.id + })) + }); + + selectedBlocks.push({ collection, item: itemId }); + + const { again } = await inquirer.prompt({ + type: 'confirm', + name: 'again', + message: '➕ Add another block?', + default: false + }); + + if (!again) break; + } + } + + const associatedMediaPayload = {}; + const { includeMedia } = await inquirer.prompt({ + type: 'confirm', + name: 'includeMedia', + message: '➕ Add associated media?', + default: false + }); + + if (includeMedia) { + for (const mediaType of ASSOCIATED_MEDIA_TYPES) { + const { query } = await inquirer.prompt({ + name: 'query', + message: `🔎 Search for ${mediaType} to associate (or leave blank to skip):` + }); + + if (!query.trim()) continue; + + const matches = await searchItems(mediaType, query.trim()); + + if (!matches.length) { + console.warn(`⚠️ No ${mediaType} found matching "${query.trim()}"`); + continue; + } + + const { selected } = await inquirer.prompt({ + type: 'checkbox', + name: 'selected', + message: `✔ Select ${mediaType} to associate:`, + choices: matches.map(m => ({ + name: m.name_string || m.title || m.name || m.label || m.id, + value: m.id + })) + }); + + if (selected.length) associatedMediaPayload[`${mediaType}`] = selected.map(id => ({ [`${mediaType}_id`]: id })); + } + } + + const media = await promptForMultipleRelations('media', 'Associated media'); + const payload = { + title, + description, + content, + featured, + date: new Date().toISOString(), + post_tags: tagIds.map(tagId => ({ tags_id: tagId })), + blocks: selectedBlocks, + ...associatedMediaPayload + }; + + await createItem('posts', payload); + + console.log('✅ Post created successfully.'); +}; diff --git a/cli/lib/tasks/index.js b/cli/lib/tasks/index.js new file mode 100644 index 0000000..2d0a180 --- /dev/null +++ b/cli/lib/tasks/index.js @@ -0,0 +1,25 @@ +import inquirer from 'inquirer'; +import { addPost } from './addPost.js'; +import { addLinkToShare } from './addLinkToShare.js'; +import { addEpisodeToShow } from './addEpisodeToShow.js'; +import { updateReadingProgress } from './updateReadingProgress.js'; + +const TASKS = [ + { name: '📄 Add post', handler: addPost }, + { name: '🔗 Add link to share', handler: addLinkToShare }, + { name: '➕ Add episode to show', handler: addEpisodeToShow }, + { name: '📚 Update reading progress', handler: updateReadingProgress }, +]; + +export const runTasksMenu = async () => { + const { task } = await inquirer.prompt([ + { + type: 'list', + name: 'task', + message: 'Select a task to run:', + choices: TASKS.map(t => ({ name: t.name, value: t.handler })) + } + ]); + + await task(); +}; diff --git a/cli/lib/tasks/updateReadingProgress.js b/cli/lib/tasks/updateReadingProgress.js new file mode 100644 index 0000000..7ceb632 --- /dev/null +++ b/cli/lib/tasks/updateReadingProgress.js @@ -0,0 +1,43 @@ +import inquirer from 'inquirer'; +import { loadConfig } from '../config.js'; +import { initDirectusClient, searchItems, updateItem } from '../directus/client.js'; + +export const updateReadingProgress = async () => { + const config = await loadConfig(); + + initDirectusClient(config); + + const readingBooks = await searchItems('books', '', { read_status: 'started' }); + + if (!readingBooks.length) { + console.log('📖 No books currently marked as "started".'); + return; + } + + const { bookId } = await inquirer.prompt({ + type: 'list', + name: 'bookId', + message: '📚 Select a book to update progress:', + choices: readingBooks.map(book => { + const title = book.title || book.name || `Book #${book.id}`; + const progress = book.progress ?? 0; + + return { + name: `${title} (${progress}%)`, + value: book.id + }; + }) + }); + const { progress } = await inquirer.prompt({ + name: 'progress', + message: '📕 New progress percentage (0–100):', + validate: input => { + const num = Number(input); + return !isNaN(num) && num >= 0 && num <= 100 || 'Enter a number from 0 to 100'; + } + }); + + await updateItem('books', bookId, { progress: Number(progress) }); + + console.log(`✅ Updated book progress to ${progress}%`); +}; diff --git a/cli/package-lock.json b/cli/package-lock.json index d448c78..1e780ff 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,13 +1,14 @@ { "name": "coryd", - "version": "2.0.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coryd", - "version": "2.0.0", + "version": "3.0.0", "dependencies": { + "@directus/sdk": "^19.1.0", "chalk": "^5.4.1", "commander": "^14.0.0", "figlet": "^1.8.1", @@ -20,6 +21,18 @@ "coryd": "bin/index.js" } }, + "node_modules/@directus/sdk": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@directus/sdk/-/sdk-19.1.0.tgz", + "integrity": "sha512-Nqem9BsvvGyVtAa69mGPtoMoMVkZxdIREdsWvvTzNF4/1XqaFfEiFL7PhtUNfc46/Nufus2+QUKYQbNiAWe3ZA==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/directus/directus?sponsor=1" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.8.tgz", diff --git a/cli/package.json b/cli/package.json index 7e82c59..66a654e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "coryd", - "version": "2.0.0", + "version": "3.0.0", "description": "The CLI for my site to run scripts, manage and download assets.", "type": "module", "bin": { @@ -11,6 +11,7 @@ }, "author": "Cory Dransfeldt", "dependencies": { + "@directus/sdk": "^19.1.0", "chalk": "^5.4.1", "commander": "^14.0.0", "figlet": "^1.8.1",