From 8b95bc196e54fb51eb71a7ad52ef2109ac5cb4c2 Mon Sep 17 00:00:00 2001 From: j4ckxyz Date: Sun, 19 Apr 2026 22:59:08 +0100 Subject: [PATCH] Add guided posting with drafts, styles, and queue Make the default experience beginner-friendly with a start flow, review-before-publish editing, saved drafts, reusable styles, scheduled publishing, and retry queue support while keeping advanced flag-based upload available. --- README.md | 42 ++- src/cli.ts | 694 ++++++++++++++++++++++++++++++++++++++------------ src/drafts.ts | 82 ++++++ src/oauth.ts | 18 ++ src/queue.ts | 85 +++++++ src/tui.ts | 215 ++++++++++++++-- src/types.ts | 16 ++ 7 files changed, 967 insertions(+), 185 deletions(-) create mode 100644 src/drafts.ts create mode 100644 src/queue.ts diff --git a/README.md b/README.md index 989e591..f232eb0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # grain -`grain` is a Bun CLI for uploading galleries to Grain.social. +`grain` is a simple command-line uploader for Grain.social. ## Quick start @@ -15,10 +15,14 @@ grain ```bash grain help +grain start grain login --handle j4ck.xyz grain whoami grain update grain logout +grain drafts list +grain queue run +grain styles list ``` Small interactive animations are enabled in TTY by default. Disable them with: @@ -27,7 +31,39 @@ Small interactive animations are enabled in TTY by default. Disable them with: GRAIN_NO_ANIM=1 grain ``` -Run `grain` with no subcommand to open the interactive upload wizard. +Run `grain` with no subcommand (or `grain start`) for the guided posting flow. + +## Guided flow + +- `grain start` opens a beginner-friendly posting flow. +- Includes a **Review before publish** step where you can publish, edit, or save draft. +- You can save unfinished galleries and resume later with `grain drafts resume --id `. +- Optional scheduling: choose a future publish time and it is added to queue. +- Optional retry queue: if network upload fails, it can auto-save to queue and retry later. + +Queue commands: + +```bash +grain queue list +grain queue run +grain queue clear +``` + +Draft commands: + +```bash +grain drafts list +grain drafts resume --id d_... +grain drafts delete --id d_... +``` + +Reusable posting styles: + +```bash +grain styles list +grain styles save --name "Street" --cw "violence" --exif include +grain styles delete --name "Street" +``` ## Upload basics @@ -35,6 +71,8 @@ Run `grain` with no subcommand to open the interactive upload wizard. grain upload-gallery \ --title "Morning walk" \ --description "Hi @alice.com #nature https://example.com" \ + --schedule-at "2026-05-01T09:30:00" \ + --queue-on-fail \ --alt "Trees by the coast" \ --alt "Clouds over the beach" \ --image @img1.jpg \ diff --git a/src/cli.ts b/src/cli.ts index fe34c35..e324af9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,14 @@ import { getOption, getOptionList, hasOption, parseArgs } from "./args"; -import { resolveDidToPds, resolveHandleToDid } from "./atproto"; +import { loadConfig, saveConfig } from "./config"; +import { deleteDraft, getDraft, listDrafts, saveDraft, type UploadDraft } from "./drafts"; import { GrainError, toGrainError } from "./errors"; import { parseContentWarnings, uploadGallery, validateUploadOptions } from "./gallery"; import { buildGrainGalleryUrl, buildGrainLibraryUrl } from "./links"; import { normalizeLocalPathToken, parsePositionalMediaToken } from "./media-input"; -import { getAuthorizedAgent, loginWithOAuth, logoutOAuth } from "./oauth"; -import { promptForAltTextFallback, runUploadWizard } from "./tui"; -import type { AltAiConfig, MediaInput } from "./types"; +import { getAuthorizedAgent, getSessionInfo, loginWithOAuth, logoutOAuth } from "./oauth"; +import { clearQueue, enqueueUpload, listQueuedUploads, removeQueuedUpload, updateQueuedUpload } from "./queue"; +import { promptForAltTextFallback, reviewUploadPlan, runStartFlow, runUploadWizard } from "./tui"; +import type { AltAiConfig, MediaInput, PostingStyle } from "./types"; import { runSelfUpdate } from "./update"; const LENS_FRAMES = ["[lens: . ]", "[lens: .. ]", "[lens: ...]", "[lens: clear]"]; @@ -30,28 +32,22 @@ async function animateStatus(label: string, frames: string[], delayMs = 70): Pro function printHelp(): void { console.log(`grain -CLI for posting Grain.social galleries. - -Login uses secure browser-based sign-in. - -Defaults: - - AI alt text: disabled (opt-in) - - EXIF metadata: included - - Location metadata: optional but recommended for better gallery context +Simple uploader for Grain.social. Commands: + grain start grain help grain login [--handle ] - grain whoami [--debug] + grain whoami + grain whoami --debug grain update grain logout - grain upload-gallery --title [--description <text>] [--location-name <name>] [--location-value <h3>] [--country <code>] [--locality <name>] [--region <name>] [--street <value>] [--postal-code <code>] [--place-name <name>] [--cw <label1,label2>] [--alt <text> ...] [--image <path> ...] [--image-url <url> ...] [--exif include|exclude] [--alt-ai-endpoint <url> --alt-ai-api-key <key> --alt-ai-model <model>] <image-path-or-url ...> + grain drafts [list|resume|delete] [--id <draft-id>] + grain queue [list|run|clear] + grain styles [list|save|delete] [--name <style-name>] + grain upload-gallery --title <title> [--description <text>] [--location-name <name>] [--location-value <h3>] [--country <code>] [--locality <name>] [--region <name>] [--street <value>] [--postal-code <code>] [--place-name <name>] [--cw <label1,label2>] [--alt <text> ...] [--image <path> ...] [--image-url <url> ...] [--exif include|exclude] [--schedule-at <iso-date>] [--queue-on-fail] [--alt-ai-endpoint <url> --alt-ai-api-key <key> --alt-ai-model <model>] <image-path-or-url ...> -Run without a command to start the interactive upload wizard. - -Examples: - grain login --handle j4ck.xyz - grain upload-gallery --title "Morning walk" --description "Hi @alice.com #nature https://example.com" --image @img1.jpg --image-url https://example.com/pic.jpg --exif include +Run without a command to start guided posting. `); } @@ -97,6 +93,212 @@ async function promptLine(message: string, allowEmpty = false): Promise<string> } } +function parseAltAi(parsed: ReturnType<typeof parseArgs>): AltAiConfig | undefined { + const endpoint = getOption(parsed.options, "alt-ai-endpoint") ?? process.env.GRAIN_ALT_AI_ENDPOINT; + const apiKey = getOption(parsed.options, "alt-ai-api-key") ?? process.env.GRAIN_ALT_AI_API_KEY; + const model = getOption(parsed.options, "alt-ai-model") ?? process.env.GRAIN_ALT_AI_MODEL; + + const hasAny = [endpoint, apiKey, model].some((value) => Boolean(value)); + if (!hasAny) { + return undefined; + } + + if (!endpoint || !apiKey || !model) { + throw new GrainError( + "invalid_alt_ai_config", + "Alt-text AI requires --alt-ai-endpoint, --alt-ai-api-key, and --alt-ai-model (or GRAIN_ALT_AI_* env vars).", + ); + } + + return { + endpoint: endpoint.replace(/\/$/, ""), + apiKey, + model, + }; +} + +function parseMediaInputs(parsed: ReturnType<typeof parseArgs>): MediaInput[] { + const imageArgs = getOptionList(parsed.options, "image").map((value) => ({ + kind: "path", + value: normalizeLocalPathToken(value), + }) satisfies MediaInput); + const imageUrlArgs = getOptionList(parsed.options, "image-url").map((value) => ({ kind: "url", value }) satisfies MediaInput); + const positionalMedia = parsed.positional.map(parsePositionalMediaToken); + return [...imageArgs, ...imageUrlArgs, ...positionalMedia]; +} + +function isNetworkFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const message = error.message.toLowerCase(); + return ( + message.includes("network") || + message.includes("fetch") || + message.includes("timed out") || + message.includes("connection") || + message.includes("econn") + ); +} + +function styleFromUpload(name: string, upload: { + description?: string; + locationName?: string; + locationValue?: string; + placeName?: string; + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + cw?: string; + exifMode: "include" | "exclude"; +}): PostingStyle { + return { + name, + description: upload.description, + locationName: upload.locationName, + locationValue: upload.locationValue, + placeName: upload.placeName, + street: upload.street, + locality: upload.locality, + region: upload.region, + postalCode: upload.postalCode, + country: upload.country, + cw: upload.cw, + exifMode: upload.exifMode, + }; +} + +async function upsertStyle(style: PostingStyle): Promise<void> { + const config = (await loadConfig()) ?? {}; + const styles = config.styles ?? []; + await saveConfig({ + ...config, + styles: [...styles.filter((entry) => entry.name !== style.name), style], + }); +} + +type PublishPayload = { + title: string; + description?: string; + locationName?: string; + locationValue?: string; + placeName?: string; + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + cw?: string; + exifMode: "include" | "exclude"; + mediaInputs: MediaInput[]; + altTexts: string[]; + altAi?: AltAiConfig; + scheduleAt?: string; + queueIfOffline?: boolean; + onAltTextNeeded?: typeof promptForAltTextFallback; +}; + +function queuePayloadFromPublish(payload: PublishPayload) { + return { + title: payload.title, + description: payload.description, + locationName: payload.locationName, + locationValue: payload.locationValue, + placeName: payload.placeName, + street: payload.street, + locality: payload.locality, + region: payload.region, + postalCode: payload.postalCode, + country: payload.country, + cw: payload.cw, + exifMode: payload.exifMode, + mediaInputs: payload.mediaInputs, + altTexts: payload.altTexts, + altAi: payload.altAi, + }; +} + +async function publishUpload(payload: PublishPayload): Promise<void> { + if (payload.scheduleAt) { + const when = new Date(payload.scheduleAt); + if (Number.isNaN(when.getTime())) { + throw new GrainError("invalid_schedule", `Invalid schedule timestamp: ${payload.scheduleAt}`); + } + if (when.getTime() > Date.now()) { + const queued = await enqueueUpload({ + ...queuePayloadFromPublish(payload), + notBefore: payload.scheduleAt, + }); + console.log(`Scheduled for later as queue item ${queued.id} (${payload.scheduleAt}).`); + return; + } + } + + const { agent, did } = await getAuthorizedAgent(false); + const contentWarnings = parseContentWarnings(payload.cw); + const address = { + name: payload.placeName, + street: payload.street, + locality: payload.locality, + region: payload.region, + postalCode: payload.postalCode, + country: payload.country, + }; + + validateUploadOptions({ + agent, + did, + title: payload.title, + description: payload.description, + locationName: payload.locationName, + locationValue: payload.locationValue, + address, + contentWarnings, + mediaInputs: payload.mediaInputs, + altTexts: payload.altTexts, + altAi: payload.altAi, + exifMode: payload.exifMode, + }); + + console.log(`Uploading ${payload.mediaInputs.length} image(s) to Grain.social...`); + try { + const result = await uploadGallery({ + agent, + did, + title: payload.title, + description: payload.description, + locationName: payload.locationName, + locationValue: payload.locationValue, + address, + contentWarnings, + mediaInputs: payload.mediaInputs, + altTexts: payload.altTexts, + altAi: payload.altAi, + exifMode: payload.exifMode, + onAltTextNeeded: payload.onAltTextNeeded, + }); + + const galleryUrl = buildGrainGalleryUrl(result.galleryUri); + console.log(`Created gallery: ${result.galleryUri}`); + if (galleryUrl) { + console.log(`Gallery URL: ${galleryUrl}`); + } + for (const uri of result.photoUris) { + console.log(`Photo: ${uri}`); + } + console.log(`Grain library: ${buildGrainLibraryUrl(did)}`); + } catch (error) { + if (payload.queueIfOffline && isNetworkFailure(error)) { + const queued = await enqueueUpload(queuePayloadFromPublish(payload)); + console.log(`Upload failed due to network issue. Saved to retry queue as ${queued.id}.`); + return; + } + throw error; + } +} + async function cmdLogin(argv: string[]): Promise<void> { const parsed = parseArgs(argv); if (hasOption(parsed.options, "pds") || hasOption(parsed.options, "app-password")) { @@ -118,9 +320,7 @@ async function cmdLogin(argv: string[]): Promise<void> { await animateStatus("Starting secure browser login", LENS_FRAMES, 65); const result = await loginWithOAuth(handle); - console.log(`Logged in as ${result.handle}`); - console.log(`DID: ${result.did}`); console.log(`Grain library: ${result.libraryUrl}`); } @@ -128,68 +328,30 @@ async function cmdWhoAmI(argv: string[]): Promise<void> { const parsed = parseArgs(argv); const debug = hasOption(parsed.options, "debug"); const { did, handle } = await getAuthorizedAgent(false); - console.log(`Handle: ${handle}`); console.log(`Grain library: ${buildGrainLibraryUrl(did)}`); - if (!debug) { - return; + if (debug) { + const session = await getSessionInfo(); + if (session?.expiresAt) { + console.log(`Session refresh expiry: ${session.expiresAt}`); + } else { + console.log("Session refresh expiry: unknown"); + } } - - const didFromHandle = await resolveHandleToDid(handle); - const pdsFromDid = await resolveDidToPds(did); - console.log(`DID: ${did}`); - console.log(`Resolved DID from handle: ${didFromHandle}`); - console.log(`Resolved PDS from DID: ${pdsFromDid}`); } async function cmdLogout(): Promise<void> { await logoutOAuth(); - console.log("Logged out and removed saved OAuth session."); + console.log("Logged out and removed saved session."); } async function cmdUpdate(): Promise<void> { await runSelfUpdate(); } -function parseAltAi(parsed: ReturnType<typeof parseArgs>): AltAiConfig | undefined { - const endpoint = getOption(parsed.options, "alt-ai-endpoint") ?? process.env.GRAIN_ALT_AI_ENDPOINT; - const apiKey = getOption(parsed.options, "alt-ai-api-key") ?? process.env.GRAIN_ALT_AI_API_KEY; - const model = getOption(parsed.options, "alt-ai-model") ?? process.env.GRAIN_ALT_AI_MODEL; - - const hasAny = [endpoint, apiKey, model].some((value) => Boolean(value)); - if (!hasAny) { - return undefined; - } - - if (!endpoint || !apiKey || !model) { - throw new GrainError( - "invalid_alt_ai_config", - "Alt-text AI requires --alt-ai-endpoint, --alt-ai-api-key, and --alt-ai-model (or GRAIN_ALT_AI_* env vars).", - ); - } - - return { - endpoint: endpoint.replace(/\/$/, ""), - apiKey, - model, - }; -} - -function parseMediaInputs(parsed: ReturnType<typeof parseArgs>): MediaInput[] { - const imageArgs = getOptionList(parsed.options, "image").map((value) => ({ - kind: "path", - value: normalizeLocalPathToken(value), - }) satisfies MediaInput); - const imageUrlArgs = getOptionList(parsed.options, "image-url").map((value) => ({ kind: "url", value }) satisfies MediaInput); - const positionalMedia = parsed.positional.map(parsePositionalMediaToken); - return [...imageArgs, ...imageUrlArgs, ...positionalMedia]; -} - async function cmdUploadGallery(argv: string[]): Promise<void> { const parsed = parseArgs(argv); - const { agent, did } = await getAuthorizedAgent(false); - const title = requireOption(parsed.options, "title"); const description = getOption(parsed.options, "description"); const locationName = getOption(parsed.options, "location-name"); @@ -198,121 +360,317 @@ async function cmdUploadGallery(argv: string[]): Promise<void> { const altTexts = normalizeManualAltList(getOptionList(parsed.options, "alt")); const altAi = parseAltAi(parsed); const exifMode = normalizeExifMode(getOption(parsed.options, "exif")); + const scheduleAt = getOption(parsed.options, "schedule-at"); + const cw = getOption(parsed.options, "cw"); - const warningsRaw = getOption(parsed.options, "cw"); - const contentWarnings = parseContentWarnings(warningsRaw); - - const address = { - name: getOption(parsed.options, "place-name"), + await publishUpload({ + title, + description, + locationName, + locationValue, + placeName: getOption(parsed.options, "place-name"), street: getOption(parsed.options, "street"), locality: getOption(parsed.options, "locality"), region: getOption(parsed.options, "region"), postalCode: getOption(parsed.options, "postal-code"), country: getOption(parsed.options, "country"), - }; - - validateUploadOptions({ - agent, - did, - title, - description, - locationName, - locationValue, - address, - contentWarnings, + cw, + exifMode, mediaInputs, altTexts, altAi, - exifMode, + scheduleAt, + queueIfOffline: hasOption(parsed.options, "queue-on-fail"), }); +} - console.log(`Uploading ${mediaInputs.length} image(s) to Grain.social...`); - const result = await uploadGallery({ - agent, - did, - title, - description, - locationName, - locationValue, - address, - contentWarnings, - mediaInputs, - altTexts, - altAi, - exifMode, +function draftInputFromUpload(upload: { + title: string; + description?: string; + locationName?: string; + locationValue?: string; + placeName?: string; + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + cw?: string; + exifMode: "include" | "exclude"; + mediaInputs: MediaInput[]; + altTexts: string[]; + altAi?: AltAiConfig; + scheduleAt?: string; +}) { + return { + ...upload, + }; +} + +async function runGuidedPost(initial?: UploadDraft): Promise<void> { + let wizard = initial + ? await runUploadWizard({ + title: initial.title, + description: initial.description, + locationName: initial.locationName, + locationValue: initial.locationValue, + placeName: initial.placeName, + street: initial.street, + locality: initial.locality, + region: initial.region, + postalCode: initial.postalCode, + country: initial.country, + cw: initial.cw, + exifMode: initial.exifMode, + mediaInputs: initial.mediaInputs, + altTexts: initial.altTexts, + altAi: initial.altAi, + scheduleAt: initial.scheduleAt, + }) + : await runUploadWizard(); + + while (true) { + const review = await reviewUploadPlan(wizard); + if (review === "edit") { + wizard = await runUploadWizard(wizard); + continue; + } + + if (review === "save_draft") { + const saved = await saveDraft(draftInputFromUpload(wizard)); + if (initial) { + await deleteDraft(initial.id); + } + console.log(`Saved draft ${saved.id}. Resume later with: grain drafts resume --id ${saved.id}`); + return; + } + + await publishUpload({ + ...wizard, + queueIfOffline: true, + onAltTextNeeded: promptForAltTextFallback, + }); + if (initial) { + await deleteDraft(initial.id); + } + return; + } +} + +async function cmdStart(): Promise<void> { + const config = await loadConfig(); + const drafts = await listDrafts(); + const startFlow = await runStartFlow({ + styles: config?.styles ?? [], + draftCount: drafts.length, }); - const galleryUrl = buildGrainGalleryUrl(result.galleryUri); - console.log(`Created gallery: ${result.galleryUri}`); - if (galleryUrl) { - console.log(`Gallery URL: ${galleryUrl}`); + if (startFlow.action === "save_draft") { + const saved = await saveDraft(draftInputFromUpload(startFlow.upload)); + console.log(`Saved draft ${saved.id}.`); + return; + } + + if (startFlow.saveAsStyleName) { + await upsertStyle( + styleFromUpload(startFlow.saveAsStyleName, { + description: startFlow.upload.description, + locationName: startFlow.upload.locationName, + locationValue: startFlow.upload.locationValue, + placeName: startFlow.upload.placeName, + street: startFlow.upload.street, + locality: startFlow.upload.locality, + region: startFlow.upload.region, + postalCode: startFlow.upload.postalCode, + country: startFlow.upload.country, + cw: startFlow.upload.cw, + exifMode: startFlow.upload.exifMode, + }), + ); + console.log(`Saved style: ${startFlow.saveAsStyleName}`); } - for (const uri of result.photoUris) { - console.log(`Photo: ${uri}`); + + let upload = startFlow.upload; + while (true) { + const review = await reviewUploadPlan(upload); + if (review === "edit") { + upload = await runUploadWizard(upload); + continue; + } + if (review === "save_draft") { + const saved = await saveDraft(draftInputFromUpload(upload)); + console.log(`Saved draft ${saved.id}.`); + return; + } + + await publishUpload({ + ...upload, + queueIfOffline: startFlow.queueIfOffline, + onAltTextNeeded: promptForAltTextFallback, + }); + return; } - console.log(`Grain library: ${buildGrainLibraryUrl(did)}`); } -async function cmdWizard(): Promise<void> { - const { agent, did } = await getAuthorizedAgent(false); - const wizard = await runUploadWizard(); +async function cmdDrafts(argv: string[]): Promise<void> { + const parsed = parseArgs(argv); + const sub = parsed.positional[0] ?? "list"; - if (!wizard.locationName) { - console.log("Tip: adding location improves discovery and search context."); + if (sub === "list") { + const drafts = await listDrafts(); + if (drafts.length === 0) { + console.log("No drafts saved."); + return; + } + for (const draft of drafts) { + console.log(`${draft.id} ${draft.title} (${draft.mediaInputs.length} image(s))`); + } + return; } - validateUploadOptions({ - agent, - did, - title: wizard.title, - description: wizard.description, - locationName: wizard.locationName, - locationValue: wizard.locationValue, - address: { - name: wizard.placeName, - street: wizard.street, - locality: wizard.locality, - region: wizard.region, - postalCode: wizard.postalCode, - country: wizard.country, - }, - contentWarnings: parseContentWarnings(wizard.cw), - mediaInputs: wizard.mediaInputs, - altTexts: wizard.altTexts, - altAi: wizard.altAi, - exifMode: wizard.exifMode, - }); + const id = getOption(parsed.options, "id"); + if (!id) { + throw new GrainError("missing_draft_id", "Provide --id for this drafts action."); + } - console.log(`Uploading ${wizard.mediaInputs.length} image(s) to Grain.social...`); - const result = await uploadGallery({ - agent, - did, - title: wizard.title, - description: wizard.description, - locationName: wizard.locationName, - locationValue: wizard.locationValue, - address: { - name: wizard.placeName, - street: wizard.street, - locality: wizard.locality, - region: wizard.region, - postalCode: wizard.postalCode, - country: wizard.country, - }, - contentWarnings: parseContentWarnings(wizard.cw), - mediaInputs: wizard.mediaInputs, - altTexts: wizard.altTexts, - altAi: wizard.altAi, - exifMode: wizard.exifMode, - onAltTextNeeded: promptForAltTextFallback, - }); + if (sub === "delete") { + await deleteDraft(id); + console.log(`Deleted draft ${id}.`); + return; + } - const galleryUrl = buildGrainGalleryUrl(result.galleryUri); - console.log(`Created gallery: ${result.galleryUri}`); - if (galleryUrl) { - console.log(`Gallery URL: ${galleryUrl}`); + if (sub === "resume") { + const draft = await getDraft(id); + if (!draft) { + throw new GrainError("draft_not_found", `Draft not found: ${id}`); + } + await runGuidedPost(draft); + return; } - console.log(`Grain library: ${buildGrainLibraryUrl(did)}`); + + throw new GrainError("unknown_drafts_command", `Unknown drafts subcommand: ${sub}`); +} + +async function cmdQueue(argv: string[]): Promise<void> { + const parsed = parseArgs(argv); + const sub = parsed.positional[0] ?? "list"; + + if (sub === "clear") { + await clearQueue(); + console.log("Cleared retry queue."); + return; + } + + if (sub === "list") { + const items = await listQueuedUploads(); + if (items.length === 0) { + console.log("Retry queue is empty."); + return; + } + for (const item of items) { + console.log(`${item.id} ${item.title} attempts:${item.attempts}${item.notBefore ? ` not-before:${item.notBefore}` : ""}`); + } + return; + } + + if (sub === "run") { + const items = await listQueuedUploads(); + if (items.length === 0) { + console.log("Retry queue is empty."); + return; + } + + for (const item of items) { + if (item.notBefore && new Date(item.notBefore).getTime() > Date.now()) { + continue; + } + + try { + await publishUpload({ + title: item.title, + description: item.description, + locationName: item.locationName, + locationValue: item.locationValue, + placeName: item.placeName, + street: item.street, + locality: item.locality, + region: item.region, + postalCode: item.postalCode, + country: item.country, + cw: item.cw, + exifMode: item.exifMode, + mediaInputs: item.mediaInputs, + altTexts: item.altTexts, + altAi: item.altAi, + scheduleAt: item.notBefore, + queueIfOffline: false, + }); + await removeQueuedUpload(item.id); + } catch (error) { + await updateQueuedUpload({ + ...item, + attempts: item.attempts + 1, + }); + console.log(`Queue item ${item.id} failed again: ${error instanceof Error ? error.message : String(error)}`); + } + } + return; + } + + throw new GrainError("unknown_queue_command", `Unknown queue subcommand: ${sub}`); +} + +async function cmdStyles(argv: string[]): Promise<void> { + const parsed = parseArgs(argv); + const sub = parsed.positional[0] ?? "list"; + const config = (await loadConfig()) ?? {}; + const styles = config.styles ?? []; + + if (sub === "list") { + if (styles.length === 0) { + console.log("No styles saved."); + return; + } + for (const style of styles) { + console.log(`- ${style.name}`); + } + return; + } + + const name = getOption(parsed.options, "name"); + if (!name) { + throw new GrainError("missing_style_name", "Provide --name for this styles action."); + } + + if (sub === "delete") { + await saveConfig({ + ...config, + styles: styles.filter((style) => style.name !== name), + }); + console.log(`Deleted style ${name}.`); + return; + } + + if (sub === "save") { + await upsertStyle({ + name, + description: getOption(parsed.options, "description"), + locationName: getOption(parsed.options, "location-name"), + locationValue: getOption(parsed.options, "location-value"), + placeName: getOption(parsed.options, "place-name"), + street: getOption(parsed.options, "street"), + locality: getOption(parsed.options, "locality"), + region: getOption(parsed.options, "region"), + postalCode: getOption(parsed.options, "postal-code"), + country: getOption(parsed.options, "country"), + cw: getOption(parsed.options, "cw"), + exifMode: normalizeExifMode(getOption(parsed.options, "exif")), + }); + console.log(`Saved style ${name}.`); + return; + } + + throw new GrainError("unknown_styles_command", `Unknown styles subcommand: ${sub}`); } async function run(): Promise<void> { @@ -321,7 +679,7 @@ async function run(): Promise<void> { const rest = argv.slice(1); if (!command) { - await cmdWizard(); + await cmdStart(); return; } @@ -332,6 +690,10 @@ async function run(): Promise<void> { printHelp(); break; } + case "start": { + await cmdStart(); + break; + } case "login": { await cmdLogin(rest); break; @@ -352,6 +714,18 @@ async function run(): Promise<void> { await cmdUploadGallery(rest); break; } + case "drafts": { + await cmdDrafts(rest); + break; + } + case "queue": { + await cmdQueue(rest); + break; + } + case "styles": { + await cmdStyles(rest); + break; + } default: { throw new GrainError("unknown_command", `Unknown command: ${command}`, "Run `grain help` for command usage."); } diff --git a/src/drafts.ts b/src/drafts.ts new file mode 100644 index 0000000..c5a341c --- /dev/null +++ b/src/drafts.ts @@ -0,0 +1,82 @@ +import { chmod, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { getConfigPath } from "./config"; +import type { AltAiConfig, MediaInput } from "./types"; + +export type UploadDraft = { + id: string; + createdAt: string; + updatedAt: string; + title: string; + description?: string; + locationName?: string; + locationValue?: string; + placeName?: string; + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + cw?: string; + exifMode: "include" | "exclude"; + mediaInputs: MediaInput[]; + altTexts: string[]; + altAi?: AltAiConfig; + scheduleAt?: string; +}; + +function draftsPath(): string { + const dir = dirname(getConfigPath()); + return join(dir, "drafts.json"); +} + +async function readDrafts(): Promise<UploadDraft[]> { + const path = draftsPath(); + const file = Bun.file(path); + if (!(await file.exists())) { + return []; + } + + const text = await file.text(); + if (!text.trim()) { + return []; + } + + return JSON.parse(text) as UploadDraft[]; +} + +async function writeDrafts(items: UploadDraft[]): Promise<void> { + const path = draftsPath(); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, `${JSON.stringify(items, null, 2)}\n`); + await chmod(path, 0o600); +} + +export async function listDrafts(): Promise<UploadDraft[]> { + return readDrafts(); +} + +export async function getDraft(id: string): Promise<UploadDraft | undefined> { + const drafts = await readDrafts(); + return drafts.find((draft) => draft.id === id); +} + +export async function saveDraft(payload: Omit<UploadDraft, "id" | "createdAt" | "updatedAt">): Promise<UploadDraft> { + const drafts = await readDrafts(); + const now = new Date().toISOString(); + const next: UploadDraft = { + ...payload, + id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + createdAt: now, + updatedAt: now, + }; + + drafts.push(next); + await writeDrafts(drafts); + return next; +} + +export async function deleteDraft(id: string): Promise<void> { + const drafts = await readDrafts(); + await writeDrafts(drafts.filter((draft) => draft.id !== id)); +} diff --git a/src/oauth.ts b/src/oauth.ts index 604bf44..62e3db0 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -466,6 +466,24 @@ export async function getAuthorizedAgent(forceRefresh = false): Promise<{ agent: }; } +export async function getSessionInfo(): Promise<{ expiresAt?: string } | undefined> { + const config = await loadConfig(); + if (!config?.oauth) { + return undefined; + } + + const oauth = normalizeOAuthConfig(config.oauth); + const stores = makeStores(); + const session = await stores.sessionStore.get(oauth.activeDid); + if (!session) { + return undefined; + } + + return { + expiresAt: session.tokenSet.expires_at, + }; +} + export async function logoutOAuth(): Promise<void> { const config = await loadConfig(); if (!config?.oauth) { diff --git a/src/queue.ts b/src/queue.ts new file mode 100644 index 0000000..c6be2ff --- /dev/null +++ b/src/queue.ts @@ -0,0 +1,85 @@ +import { chmod, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { getConfigPath } from "./config"; +import type { AltAiConfig, MediaInput } from "./types"; + +export type QueuedUpload = { + id: string; + createdAt: string; + attempts: number; + notBefore?: string; + title: string; + description?: string; + locationName?: string; + locationValue?: string; + placeName?: string; + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + cw?: string; + exifMode: "include" | "exclude"; + mediaInputs: MediaInput[]; + altTexts: string[]; + altAi?: AltAiConfig; +}; + +function queuePath(): string { + const dir = dirname(getConfigPath()); + return join(dir, "upload-queue.json"); +} + +async function readQueue(): Promise<QueuedUpload[]> { + const path = queuePath(); + const file = Bun.file(path); + if (!(await file.exists())) { + return []; + } + + const text = await file.text(); + if (!text.trim()) { + return []; + } + + return JSON.parse(text) as QueuedUpload[]; +} + +async function writeQueue(items: QueuedUpload[]): Promise<void> { + const path = queuePath(); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, `${JSON.stringify(items, null, 2)}\n`); + await chmod(path, 0o600); +} + +export async function listQueuedUploads(): Promise<QueuedUpload[]> { + return readQueue(); +} + +export async function enqueueUpload(item: Omit<QueuedUpload, "id" | "createdAt" | "attempts">): Promise<QueuedUpload> { + const queue = await readQueue(); + const next: QueuedUpload = { + ...item, + id: `q_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + createdAt: new Date().toISOString(), + attempts: 0, + }; + queue.push(next); + await writeQueue(queue); + return next; +} + +export async function removeQueuedUpload(id: string): Promise<void> { + const queue = await readQueue(); + await writeQueue(queue.filter((item) => item.id !== id)); +} + +export async function updateQueuedUpload(item: QueuedUpload): Promise<void> { + const queue = await readQueue(); + const next = queue.map((entry) => (entry.id === item.id ? item : entry)); + await writeQueue(next); +} + +export async function clearQueue(): Promise<void> { + await writeQueue([]); +} diff --git a/src/tui.ts b/src/tui.ts index ad59125..049e5f8 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -3,11 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { GrainError } from "./errors"; import { parseWizardMediaEntry } from "./media-input"; -import type { AltAiConfig, MediaInput } from "./types"; +import type { AltAiConfig, MediaInput, PostingStyle } from "./types"; type Prompt = { askText(message: string, options?: { allowEmpty?: boolean; defaultValue?: string }): Promise<string>; askYesNo(message: string, defaultYes?: boolean): Promise<boolean>; + askChoice(message: string, choices: string[], defaultIndex?: number): Promise<number>; }; const FOCUS_FRAMES = ["[focus: . ]", "[focus: .. ]", "[focus: ...]", "[focus: ok ]"]; @@ -80,7 +81,22 @@ function buildPrompt(): Prompt { } } - return { askText, askYesNo }; + async function askChoice(message: string, choices: string[], defaultIndex = 0): Promise<number> { + while (true) { + stdout.write(`${message}\n`); + for (let i = 0; i < choices.length; i += 1) { + stdout.write(` ${i + 1}) ${choices[i]}\n`); + } + const raw = await askText("Choose a number", { allowEmpty: true, defaultValue: String(defaultIndex + 1) }); + const index = Number.parseInt(raw, 10) - 1; + if (Number.isFinite(index) && index >= 0 && index < choices.length) { + return index; + } + stdout.write("Please choose one of the listed numbers.\n"); + } + } + + return { askText, askYesNo, askChoice }; } export async function openInNativeViewer(bytes: Uint8Array, mimeType: string): Promise<void> { @@ -133,20 +149,47 @@ export type UploadWizardResult = { mediaInputs: MediaInput[]; altTexts: string[]; altAi?: AltAiConfig; + scheduleAt?: string; }; -export async function runUploadWizard(): Promise<UploadWizardResult> { +type StartFlowResult = + | { + action: "post"; + upload: UploadWizardResult; + saveAsStyleName?: string; + queueIfOffline: boolean; + } + | { + action: "save_draft"; + upload: UploadWizardResult; + }; + +export async function runUploadWizard(initial: Partial<UploadWizardResult> = {}): Promise<UploadWizardResult> { const prompt = buildPrompt(); console.log("grain - interactive gallery upload"); await animateLine("Setting up your gallery", FOCUS_FRAMES); console.log("Tip: local paths must start with @, for example @photo.jpg or @./images/a.png"); - const title = await prompt.askText("Gallery title"); - const descriptionRaw = await prompt.askText("Description (supports @mentions #hashtags links)", { allowEmpty: true }); + const title = await prompt.askText("Gallery title", { defaultValue: initial.title }); + const descriptionRaw = await prompt.askText("Description (supports @mentions #hashtags links)", { + allowEmpty: true, + defaultValue: initial.description, + }); const description = descriptionRaw || undefined; - const addLocation = await prompt.askYesNo("Add location?"); + const hasInitialLocation = Boolean( + initial.locationName || + initial.locationValue || + initial.placeName || + initial.street || + initial.locality || + initial.region || + initial.postalCode || + initial.country, + ); + + const addLocation = await prompt.askYesNo("Add location?", hasInitialLocation); let locationName: string | undefined; let locationValue: string | undefined; let placeName: string | undefined; @@ -157,26 +200,38 @@ export async function runUploadWizard(): Promise<UploadWizardResult> { let country: string | undefined; if (addLocation) { - locationName = await prompt.askText("Location name (display)"); - locationValue = await prompt.askText("Location value (H3 index)"); - placeName = (await prompt.askText("Address place name", { allowEmpty: true })) || undefined; - street = (await prompt.askText("Address street", { allowEmpty: true })) || undefined; - locality = (await prompt.askText("Address locality", { allowEmpty: true })) || undefined; - region = (await prompt.askText("Address region", { allowEmpty: true })) || undefined; - postalCode = (await prompt.askText("Address postal code", { allowEmpty: true })) || undefined; - country = (await prompt.askText("Address country code", { allowEmpty: true })) || undefined; - } - - const cw = (await prompt.askText("Content warnings (comma-separated labels, optional)", { allowEmpty: true })) || undefined; - const includeExif = await prompt.askYesNo("Include EXIF metadata? (default yes)", true); + locationName = await prompt.askText("Location name (display)", { defaultValue: initial.locationName }); + locationValue = await prompt.askText("Location value (H3 index)", { defaultValue: initial.locationValue }); + placeName = + (await prompt.askText("Address place name", { allowEmpty: true, defaultValue: initial.placeName })) || undefined; + street = (await prompt.askText("Address street", { allowEmpty: true, defaultValue: initial.street })) || undefined; + locality = (await prompt.askText("Address locality", { allowEmpty: true, defaultValue: initial.locality })) || undefined; + region = (await prompt.askText("Address region", { allowEmpty: true, defaultValue: initial.region })) || undefined; + postalCode = + (await prompt.askText("Address postal code", { allowEmpty: true, defaultValue: initial.postalCode })) || undefined; + country = (await prompt.askText("Address country code", { allowEmpty: true, defaultValue: initial.country })) || undefined; + } + + const cw = + (await prompt.askText("Content warnings (comma-separated labels, optional)", { + allowEmpty: true, + defaultValue: initial.cw, + })) || undefined; + const includeExif = await prompt.askYesNo("Include EXIF metadata? (default yes)", (initial.exifMode ?? "include") === "include"); const exifMode: "include" | "exclude" = includeExif ? "include" : "exclude"; - const useAltAi = await prompt.askYesNo("Use AI alt text generation?", false); + const useAltAi = await prompt.askYesNo("Use AI alt text generation?", Boolean(initial.altAi)); let altAi: AltAiConfig | undefined; if (useAltAi) { - const endpoint = await prompt.askText("Alt AI endpoint", { defaultValue: process.env.GRAIN_ALT_AI_ENDPOINT }); - const apiKey = await prompt.askText("Alt AI API key", { defaultValue: process.env.GRAIN_ALT_AI_API_KEY }); - const model = await prompt.askText("Alt AI model", { defaultValue: process.env.GRAIN_ALT_AI_MODEL }); + const endpoint = await prompt.askText("Alt AI endpoint", { + defaultValue: initial.altAi?.endpoint ?? process.env.GRAIN_ALT_AI_ENDPOINT, + }); + const apiKey = await prompt.askText("Alt AI API key", { + defaultValue: initial.altAi?.apiKey ?? process.env.GRAIN_ALT_AI_API_KEY, + }); + const model = await prompt.askText("Alt AI model", { + defaultValue: initial.altAi?.model ?? process.env.GRAIN_ALT_AI_MODEL, + }); altAi = { endpoint: endpoint.replace(/\/$/, ""), apiKey, @@ -185,6 +240,13 @@ export async function runUploadWizard(): Promise<UploadWizardResult> { } const mediaInputs: MediaInput[] = []; + if ((initial.mediaInputs?.length ?? 0) > 0) { + const keepCurrent = await prompt.askYesNo(`Keep current ${initial.mediaInputs!.length} image(s)?`, true); + if (keepCurrent) { + mediaInputs.push(...initial.mediaInputs!); + } + } + while (true) { const value = await prompt.askText("Add image (@path or https://url). Empty to continue", { allowEmpty: true }); if (!value) { @@ -216,11 +278,118 @@ export async function runUploadWizard(): Promise<UploadWizardResult> { cw, exifMode, mediaInputs, - altTexts: [], + altTexts: initial.altTexts ?? [], altAi, + scheduleAt: initial.scheduleAt, }; } +function parseScheduleInput(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const date = new Date(trimmed); + if (Number.isNaN(date.getTime())) { + throw new GrainError("invalid_schedule", "Could not parse schedule time.", "Use an ISO date/time like 2026-05-01T09:30:00."); + } + return date.toISOString(); +} + +function styleToWizardBase(style: PostingStyle): Partial<UploadWizardResult> { + return { + description: style.description, + locationName: style.locationName, + locationValue: style.locationValue, + placeName: style.placeName, + street: style.street, + locality: style.locality, + region: style.region, + postalCode: style.postalCode, + country: style.country, + cw: style.cw, + exifMode: style.exifMode ?? "include", + }; +} + +export async function runStartFlow(input: { + styles: PostingStyle[]; + draftCount: number; +}): Promise<StartFlowResult> { + const prompt = buildPrompt(); + console.log("grain start - guided posting"); + await animateLine("Loading camera bag", FOCUS_FRAMES, 65); + + const modeChoices = ["Create and publish now (recommended)", "Save an unfinished draft"]; + if (input.draftCount > 0) { + modeChoices.push(`Resume saved draft (use grain drafts, ${input.draftCount} available)`); + } + + const mode = await prompt.askChoice("What do you want to do?", modeChoices, 0); + if (mode === 1) { + const upload = await runUploadWizard(); + return { action: "save_draft", upload }; + } + + if (mode === 2) { + throw new GrainError("resume_draft_from_start", "Use `grain drafts` to pick and resume a saved draft."); + } + + let chosenStyle: PostingStyle | undefined; + if (input.styles.length > 0) { + const useStyle = await prompt.askYesNo("Use a saved posting style?", true); + if (useStyle) { + const index = await prompt.askChoice( + "Pick a style", + input.styles.map((style) => style.name), + 0, + ); + chosenStyle = input.styles[index]; + } + } + + const upload = await runUploadWizard(chosenStyle ? styleToWizardBase(chosenStyle) : {}); + + const scheduleInput = await prompt.askText("Schedule time (optional, ISO format)", { + allowEmpty: true, + defaultValue: upload.scheduleAt, + }); + upload.scheduleAt = parseScheduleInput(scheduleInput); + + const queueIfOffline = await prompt.askYesNo("Queue and retry automatically if publish fails?", true); + const saveAsStyle = await prompt.askYesNo("Save this setup as a reusable style?", false); + const saveAsStyleName = saveAsStyle ? await prompt.askText("Style name") : undefined; + + return { + action: "post", + upload, + saveAsStyleName, + queueIfOffline, + }; +} + +export async function reviewUploadPlan(upload: UploadWizardResult): Promise<"publish" | "edit" | "save_draft"> { + const prompt = buildPrompt(); + console.log("\nReview before publish"); + console.log(`- Title: ${upload.title}`); + console.log(`- Description: ${upload.description ?? "(none)"}`); + console.log(`- Images: ${upload.mediaInputs.length}`); + console.log(`- EXIF: ${upload.exifMode}`); + if (upload.scheduleAt) { + console.log(`- Schedule: ${upload.scheduleAt}`); + } + + const choice = await prompt.askChoice("Choose next step", ["Publish", "Edit details", "Save draft for later"], 0); + if (choice === 1) { + return "edit"; + } + if (choice === 2) { + return "save_draft"; + } + return "publish"; +} + export async function promptForAltTextFallback(context: { index: number; total: number; diff --git a/src/types.ts b/src/types.ts index f145ec8..a89e731 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,8 +7,24 @@ export type OAuthConfig = { loginAt: string; }; +export type PostingStyle = { + name: string; + description?: string; + locationName?: string; + locationValue?: string; + placeName?: string; + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + cw?: string; + exifMode?: "include" | "exclude"; +}; + export type AppConfig = { oauth?: OAuthConfig; + styles?: PostingStyle[]; }; export type RichTextFacet = { -- knotmirror 0.0.1