diff --git a/skills/outline-cli/SKILL.md b/skills/outline-cli/SKILL.md index 7791794..28e1551 100644 --- a/skills/outline-cli/SKILL.md +++ b/skills/outline-cli/SKILL.md @@ -15,6 +15,7 @@ Use this skill when the user wants to interact with their Outline wiki/knowledge - `ol doc open ` - Open document in browser - `ol doc create --title "Title" --collection ` - Create document - `ol col list` - List collections +- `ol att create --document ` - Upload attachment ## Output Formats @@ -71,6 +72,13 @@ ol col update --name "New Name" ol col delete --confirm ``` +### Attachments +```bash +ol att create --document # Upload file to document (content type auto-detected) +ol att create --document --content-type "image/png" # Explicit content type +ol att create --document --json # Output JSON with attachment URL +``` + ### Authentication ```bash ol auth login # Configure API token and base URL @@ -108,6 +116,13 @@ ol col list --json ol doc list --collection --sort title --direction ASC ``` +### Upload an image to a document +```bash +ol att create ./screenshot.png --document --json +# Returns: { "url": "/api/attachments.redirect?id=...", ... } +# Use the URL in markdown: ![alt text](/api/attachments.redirect?id=...) +``` + ### Bulk export with ndjson ```bash ol doc list --ndjson --full | jq -r '.title' diff --git a/src/commands/attachment.ts b/src/commands/attachment.ts new file mode 100644 index 0000000..ec2f22c --- /dev/null +++ b/src/commands/attachment.ts @@ -0,0 +1,60 @@ +import { existsSync, statSync } from 'node:fs' +import { basename, extname } from 'node:path' +import chalk from 'chalk' +import type { Command } from 'commander' +import { apiUpload } from '../lib/api.js' +import { resolveDocumentId } from '../lib/refs.js' + +const CONTENT_TYPE_MAP: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.zip': 'application/zip', +} + +function guessContentType(filePath: string): string { + const ext = extname(filePath).toLowerCase() + return CONTENT_TYPE_MAP[ext] ?? 'application/octet-stream' +} + +export function registerAttachmentCommand(program: Command): void { + const attachment = program.command('attachment').alias('att').description('Manage attachments') + + attachment + .command('create ') + .description('Upload a file attachment to a document') + .requiredOption('--document ', 'Document ID, URL, or name') + .option('--content-type ', 'MIME type (auto-detected from extension)') + .option('--json', 'Output JSON') + .action(async (file: string, opts) => { + if (!existsSync(file)) { + console.error(chalk.red(`File not found: ${file}`)) + process.exit(1) + } + + const documentId = await resolveDocumentId(opts.document) + const contentType = opts.contentType ?? guessContentType(file) + const fileSize = statSync(file).size + + const result = await apiUpload(file, { + name: basename(file), + size: fileSize, + contentType, + documentId, + }) + + if (opts.json) { + console.log(JSON.stringify(result.attachment, null, 2)) + } else { + const att = result.attachment + const name = chalk.bold(att.name) + const size = chalk.dim(`${Math.round(att.size / 1024)}KB`) + const url = chalk.cyan(att.url) + console.log(chalk.green('Uploaded:'), `${name} ${size}\n${url}`) + } + }) +} diff --git a/src/index.ts b/src/index.ts index e908b71..c7ae9a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Command } from 'commander' +import { registerAttachmentCommand } from './commands/attachment.js' import { registerAuthCommand } from './commands/auth.js' import { registerChangelogCommand } from './commands/changelog.js' import { registerCollectionCommand } from './commands/collection.js' @@ -23,6 +24,7 @@ Note for AI/LLM agents: Default JSON shows essential fields; use --full for all fields.`, ) +registerAttachmentCommand(program) registerAuthCommand(program) registerSearchCommand(program) registerDocumentCommand(program) diff --git a/src/lib/api.ts b/src/lib/api.ts index c8af5d3..6368ed1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' import { fetchWithRetry } from '../transport/fetch-with-retry.js' import { getApiToken, getBaseUrl } from './auth.js' import { type SpinnerOptions, withSpinner } from './spinner.js' @@ -22,6 +24,7 @@ const API_SPINNER_CONFIG: Record = { 'collections.create': { text: 'Creating collection...', color: 'green' }, 'collections.update': { text: 'Updating collection...', color: 'yellow' }, 'collections.delete': { text: 'Deleting collection...', color: 'yellow' }, + 'attachments.create': { text: 'Uploading attachment...', color: 'green' }, } export interface Pagination { @@ -91,3 +94,67 @@ export async function apiRequest(path: string, body: object = {}): Promise rawApiRequest(path, body)) } + +interface AttachmentCreateResponse { + uploadUrl: string + form: Record + attachment: { + id: string + name: string + contentType: string + size: number + url: string + documentId: string | null + } +} + +/** + * Two-step file upload for Outline attachments: + * 1. POST JSON to attachments.create → get presigned upload URL + form fields + * 2. POST multipart form-data to the presigned URL with the file + */ +async function rawApiUpload( + filePath: string, + metadata: { name: string; size: number; contentType: string; documentId: string }, +): Promise { + // Step 1: Create attachment record and get presigned URL + const { data } = await rawApiRequest('attachments.create', metadata) + + // Step 2: Upload file to presigned URL + const form = new FormData() + for (const [key, value] of Object.entries(data.form)) { + form.append(key, value) + } + const fileBuffer = readFileSync(filePath) + const fileBlob = new Blob([fileBuffer.buffer as ArrayBuffer]) + form.append('file', fileBlob, basename(filePath)) + + const uploadRes = await fetchWithRetry({ + url: data.uploadUrl, + options: { + method: 'POST', + body: form, + }, + }) + + if (!uploadRes.ok) { + throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`) + } + + return data +} + +/** + * Public file upload function with spinner support. + */ +export async function apiUpload( + filePath: string, + metadata: { name: string; size: number; contentType: string; documentId: string }, +): Promise { + const spinnerConfig = API_SPINNER_CONFIG['attachments.create'] ?? { + text: 'Uploading...', + color: 'green' as const, + } + + return withSpinner(spinnerConfig, () => rawApiUpload(filePath, metadata)) +}