UNPKG

@papra/cli

Version:

Command line interface for Papra, the document archiving platform.

720 lines (699 loc) 22.9 kB
import { defineCommand, runMain } from "citty"; import process from "node:process"; import * as prompts from "@clack/prompts"; import { cancel, isCancel } from "@clack/prompts"; import * as v from "valibot"; import fs, { readFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { homedir, platform } from "node:os"; import { safely } from "@corentinth/chisels"; import { createClient } from "@papra/api-sdk"; import mime from "mime-types"; import { FetchError } from "ofetch"; import pc from "picocolors"; import fs$1 from "node:fs"; import JSZip from "jszip"; //#region package.json var version = "0.2.2"; var description = "Command line interface for Papra, the document archiving platform."; //#endregion //#region src/commands/commands.models.ts function ensureString(value) { if (typeof value !== "string") throw new TypeError(`Expected a string, got ${typeof value}: ${value}`); return value; } //#endregion //#region src/commands/config/config.schemas.ts const apiUrlSchema = v.pipe(v.string(), v.url()); const apiKeySchema = v.string(); const cliConfigSchema = v.object({ apiUrl: v.optional(apiUrlSchema), apiKey: v.optional(apiKeySchema) }); //#endregion //#region src/commands/config/appdata.ts function getWindowsConfigDir() { return join(homedir(), "AppData", "Roaming"); } function getUnixConfigDir() { return join(homedir(), ".config"); } function getMacOSConfigDir() { return join(homedir(), "Library", "Application Support"); } function getConfigDir({ platform: platform$1 = platform() } = {}) { const customAppData = process.env.APPDATA; if (customAppData) return customAppData; if (platform$1 === "win32") return getWindowsConfigDir(); if (platform$1 === "darwin") return getMacOSConfigDir(); if (platform$1 === "linux") return getUnixConfigDir(); return getUnixConfigDir(); } function getConfigFilePath() { return join(getConfigDir(), "papra", "cli", "papra.cli-config.json"); } //#endregion //#region src/commands/config/config.services.ts async function getConfig() { const configFilePath = getConfigFilePath(); const fileExists$1 = await fs.access(configFilePath).then(() => true).catch(() => false); if (!fileExists$1) return {}; const config = await fs.readFile(configFilePath, "utf-8"); return v.parse(cliConfigSchema, JSON.parse(config)); } async function setConfig(rawConfig) { const config = v.parse(cliConfigSchema, rawConfig); const configFilePath = getConfigFilePath(); const dir = dirname(configFilePath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(configFilePath, JSON.stringify(config, null, 2)); } async function updateConfig(fn) { const currentConfig = await getConfig(); const newConfig = fn(currentConfig); await setConfig(newConfig); return newConfig; } //#endregion //#region src/commands/config/config.command.ts function defineConfigSetter({ name, description: description$1, configKey, argument }) { return defineCommand({ meta: { name, description: description$1 }, args: { [name]: { type: "positional", description: argument.description, valueHint: argument.valueHint, required: false } }, run: async ({ args }) => { const valueFromArgs = args[name]; if (valueFromArgs) { await updateConfig((config) => ({ ...config, [configKey]: ensureString(valueFromArgs) })); prompts.log.info(`${name} set to ${valueFromArgs}`); return; } const valueFromPrompt = await prompts.text({ message: argument.promptLabel }); if (prompts.isCancel(valueFromPrompt)) return; await updateConfig((config) => ({ ...config, [configKey]: valueFromPrompt })); prompts.log.info(`${name} set to ${valueFromPrompt}`); } }); } const configCommand = defineCommand({ meta: { name: "config", description: "Manage Papra CLI configuration" }, subCommands: { init: defineCommand({ meta: { name: "init", description: "Initialize the configuration" }, run: async () => { const group = await prompts.group({ apiUrl: () => prompts.text({ message: "Enter your instance URL (e.g. https://api.papra.app)", validate: (value) => { const result = v.safeParser(apiUrlSchema)(value); if (result.success) return void 0; return result.issues.map(({ message }) => message).join("\n"); } }), apiKey: () => prompts.text({ message: `Enter your API key (can be be found in your User Settings)`, validate: (value) => { const result = v.safeParser(apiKeySchema)(value); if (result.success) return void 0; return result.issues.map(({ message }) => message).join("\n"); } }) }, { onCancel: () => { prompts.cancel("Configuration initialization cancelled"); process.exit(0); } }); await updateConfig((config) => ({ ...config, ...group })); prompts.log.info("Configuration initialized!"); } }), list: defineCommand({ meta: { name: "list", description: "List all configuration values" }, run: async () => { const config = await getConfig(); const isEmpty = Object.keys(config).length === 0; if (isEmpty) { prompts.log.warn("No configuration values set"); return; } prompts.log.info(JSON.stringify(config, null, 2)); } }), set: defineCommand({ meta: { name: "set", description: "Set a configuration value" }, subCommands: { "api-key": defineConfigSetter({ name: "api-key", description: "Set the API key", configKey: "apiKey", argument: { description: "The API key", valueHint: "your-api-key", promptLabel: "Enter the API key" } }), "api-url": defineConfigSetter({ name: "api-url", description: "Set the API URL", configKey: "apiUrl", argument: { description: "The API URL", valueHint: "https://api.papra.app", promptLabel: "Enter the API URL" } }), "default-org-id": defineConfigSetter({ name: "default-org-id", description: "Set a default organization ID to use when running commands", configKey: "defaultOrganizationId", argument: { description: "The default organization ID", valueHint: "organization-id", promptLabel: "Enter the default organization ID" } }) } }) } }); //#endregion //#region src/client.models.ts function reportClientError(error) { const code = getCauseCode(error); if (error instanceof FetchError && error.data?.error?.message) { prompts.log.error(`Error: ${error.data.error.message}`); return; } if (typeof code === "string" && ["ERR_NETWORK", "ECONNREFUSED"].includes(code)) { prompts.log.error(`Failed to connect to the server: ${code}\n${error.message}`); return; } prompts.log.error(`${error.message}`); } function getCauseCode(error) { if (error.code) return error.code; if (error.cause instanceof Error) return getCauseCode(error.cause); return null; } //#endregion //#region src/errors/errors.models.ts function getErrorMessage(error) { if (!(error instanceof Error)) return String(error); if (error.name === "FetchError") { const fetchError = error; if (fetchError.response) return `API request failed with status ${fetchError.status} ${fetchError.statusText}`; return `Network error, server unreachable.\n${fetchError.message}`; } return error.message; } //#endregion //#region src/prompts/utils.ts async function exitOnCancel(promise) { const value = await promise; if (isCancel(value)) exit("Operation cancelled"); if (typeof value === "symbol") throw new TypeError("Unexpected symbol value"); return value; } function exit(message) { cancel(message.split("\n").join("\n ")); process.exit(1); } async function exitOnError(promise, { errorContext } = {}) { try { return await promise; } catch (error) { exit([errorContext, getErrorMessage(error)].filter(Boolean).join("\n")); } } //#endregion //#region src/organizations/organizations.usecases.ts async function promptForOrganization({ organizations }) { if (organizations.length === 0) exit("No organizations found. Please create one in the Papra dashboard first."); if (organizations.length === 1) { const { name, id } = organizations[0]; prompts.log.info(`One organization found.\n${pc.bold(name)} (${pc.dim(id)})`); return { organizationId: id }; } const selectedOrgId = await exitOnCancel(prompts.select({ message: "Select an organization to import stuff into:", options: organizations.map((org) => ({ value: org.id, label: `${pc.bold(org.name)} ${pc.dim(`(${org.id})`)}` })) })); return { organizationId: selectedOrgId }; } async function getOrganizationId({ apiClient, argOrganizationId }) { const { organizations } = await exitOnError(apiClient.listOrganizations(), { errorContext: "Failed to fetch organizations." }); if (argOrganizationId) { const org = organizations.find((org$1) => org$1.id === argOrganizationId); if (!org) exit(`No organization found with ID ${pc.bold(argOrganizationId)}.`); return { organizationId: org.id }; } return promptForOrganization({ apiClient, organizations }); } //#endregion //#region src/commands/documents/documents.arguments.ts const organizationIdArgument = { type: "string", description: "The organization ID. If not set, and you belong to multiple organizations, you'll be prompted to choose one.", alias: "o", valueHint: "organization-id" }; //#endregion //#region src/commands/documents/import/import.command.ts const importCommand$1 = defineCommand({ meta: { name: "import", description: "Import a document to Papra" }, args: { path: { type: "positional", description: "The path to the document to import", valueHint: "./document.pdf" }, organizationId: organizationIdArgument }, run: async ({ args }) => { const { apiKey, apiUrl } = await getConfig(); if (!apiKey) { prompts.cancel("No API key provided"); process.exit(1); } const apiClient = createClient({ apiKey, apiBaseUrl: apiUrl }); const { organizationId } = await getOrganizationId({ apiClient, argOrganizationId: args.organizationId }); await prompts.tasks([{ title: "Importing document", task: async () => { const fileBuffer = await readFile(args.path); const fileName = basename(args.path); const mimeType = mime.lookup(fileName) || "application/octet-stream"; const file = new File([fileBuffer], fileName, { type: mimeType }); const [, error] = await safely(apiClient.uploadDocument({ organizationId, file })); if (error) { reportClientError(error); process.exit(1); } prompts.log.info("Document imported successfully"); } }]); } }); //#endregion //#region src/commands/documents/documents.command.ts const documentsCommand = defineCommand({ meta: { name: "documents", description: "Manage documents" }, subCommands: { import: importCommand$1 } }); //#endregion //#region src/fs.ts function fileExists(path) { return fs$1.existsSync(path); } //#endregion //#region src/commands/import/paperless/paperless.usecases.ts async function extractManifest({ archivePath }) { const zipData = await readFile(archivePath); const zip = await JSZip.loadAsync(zipData); const manifestFile = zip.file("manifest.json"); if (!manifestFile) throw new Error("Invalid Paperless export: manifest.json not found"); const manifestContent = await manifestFile.async("text"); const manifest = JSON.parse(manifestContent); return manifest; } async function readArchive({ archivePath }) { const zipData = await readFile(archivePath); return await JSZip.loadAsync(zipData); } async function extractFileFromArchive({ archive, filePath }) { const file = archive.file(filePath); if (!file) throw new Error(`File not found in archive: ${filePath}`); return await file.async("nodebuffer"); } function parseManifest({ manifest }) { const documents = []; const tags = []; const correspondents = []; const documentTypes = []; for (const item of manifest) if (item.model === "documents.document") documents.push(item); else if (item.model === "documents.tag") tags.push(item); else if (item.model === "documents.correspondent") correspondents.push(item); else if (item.model === "documents.documenttype") documentTypes.push(item); return { documents, tags, correspondents, documentTypes }; } //#endregion //#region src/commands/import/paperless/paperless.wizard.ts function pluralize(count, singular, plural) { if (count === 1) return `${count} ${singular}`; return `${count} ${plural ?? `${singular}s`}`; } async function analyzeExport({ archivePath }) { const [manifest, error] = await safely(extractManifest({ archivePath })); if (error) exit(`Failed to extract manifest:\n${getErrorMessage(error)}`); if (!manifest) exit("Failed to extract manifest"); const preview = parseManifest({ manifest }); const analysis = { documentCount: preview.documents.length, tagCount: preview.tags.length }; prompts.note([ `${pc.green("✓")} Valid Paperless NGX export detected`, `${pc.green("✓")} Found ${pluralize(analysis.documentCount, "document")}`, `${pc.green("✓")} Found ${pluralize(analysis.tagCount, "tag")}` ].join("\n"), "Export file analysis"); return { manifest, analysis }; } async function analyzeAndHandleTags({ manifest, organizationId, apiClient }) { const preview = parseManifest({ manifest }); const { tags: existingTags } = await apiClient.listTags({ organizationId }); const missingTags = preview.tags.filter((tag) => !existingTags.some((existingTag) => existingTag.name === tag.fields.name)); prompts.note(preview.tags.length === 0 ? "No tags found in export" : [ `${pluralize(preview.tags.length, "tag")} found in export`, ` - ${pluralize(existingTags.length, "tag")} have the same name in the Papra organization`, ` - ${pluralize(missingTags.length, "tag")} need to be created` ].join("\n"), "Tags analysis"); if (preview.tags.length === 0) return { existingTags, missingTags: [], tagMapping: /* @__PURE__ */ new Map() }; const tagStrategy = await selectTagStrategy({ hasMissingTags: missingTags.length > 0 }); const tagMapping = await executeTagStrategy({ strategy: tagStrategy, missingTags, existingTags, allTags: preview.tags, organizationId, apiClient }); return { existingTags, missingTags, tagMapping }; } async function selectTagStrategy({ hasMissingTags }) { if (!hasMissingTags) return "create-and-map"; const strategy = await exitOnCancel(prompts.select({ message: "How should we handle tags?", options: [ { value: "create-and-map", label: "Create missing tags and map existing ones", hint: "Recommended" }, { value: "create-all", label: "Create all tags from export", hint: "Will create duplicate tags if names match" }, { value: "skip", label: "Skip tag import", hint: "Documents will be imported without tags" } ], initialValue: "create-and-map" })); return strategy; } async function executeTagStrategy({ strategy, missingTags, existingTags, allTags, organizationId, apiClient }) { const tagMapping = /* @__PURE__ */ new Map(); if (strategy === "skip") return tagMapping; const tagsToCreate = strategy === "create-all" ? allTags : missingTags; if (tagsToCreate.length > 0) { const prog = prompts.progress({ max: tagsToCreate.length }); prog.start(`Creating ${pluralize(tagsToCreate.length, "tag")}`); let createdCount = 0; for (const [index, tag] of tagsToCreate.entries()) { prog.message(`Creating tag ${index + 1}/${tagsToCreate.length}: ${tag.fields.name}`); const [result, error] = await safely(apiClient.createTag({ organizationId, name: tag.fields.name, color: tag.fields.color })); if (error) { prog.message(`Failed to create tag "${tag.fields.name}": ${getErrorMessage(error)}`); prompts.log.warn(`Failed to create tag "${tag.fields.name}": ${getErrorMessage(error)}`); } else if (result) { prog.message(`Created tag "${tag.fields.name}": ${result.tag.id}`); tagMapping.set(tag.pk, result.tag.id); createdCount++; } prog.advance(1); } prog.stop(`Created ${pluralize(createdCount, "tag")} ✓`); } if (strategy === "create-and-map") for (const existingTag of existingTags) { const paperlessTag = allTags.find((tag) => tag.fields.name === existingTag.name); if (paperlessTag && !tagMapping.has(paperlessTag.pk)) tagMapping.set(paperlessTag.pk, existingTag.id); } return tagMapping; } async function selectDocumentStrategy() { const strategy = await exitOnCancel(prompts.select({ message: "How should we import documents?", options: [ { value: "import-with-tags", label: "Import documents and apply tags", hint: "Recommended" }, { value: "import-without-tags", label: "Import documents without tags" }, { value: "skip", label: "Skip document import" } ], initialValue: "import-with-tags" })); return strategy; } async function importDocuments({ manifest, archivePath, organizationId, apiClient, tagMapping, applyTags }) { const preview = parseManifest({ manifest }); const documents = preview.documents; const stats = { documentsImported: 0, documentsSkipped: 0, documentsFailed: 0, tagsCreated: tagMapping.size, errors: [] }; const prog = prompts.progress({ max: documents.length }); prog.start(`Importing ${pluralize(documents.length, "document")}`); const archive = await readArchive({ archivePath }); for (const [index, document] of documents.entries()) { prog.message(`Importing document ${index + 1}/${documents.length}: ${document.fields.title}`); const [, error] = await safely(importSingleDocument({ document, archive, organizationId, apiClient, tagMapping: applyTags ? tagMapping : /* @__PURE__ */ new Map() })); if (error) { const isDuplicate = isDocumentAlreadyExistsError(error); if (isDuplicate) stats.documentsSkipped++; else { const errorMessage = getErrorMessage(error); stats.documentsFailed++; stats.errors.push({ fileName: document.fields.original_filename, error: errorMessage }); prog.message(`Failed to import document "${document.fields.title}"`); } } else { prog.message(`Imported document "${document.fields.title}"`); stats.documentsImported++; } prog.advance(1); } prog.stop(`Import completed ✓`); return stats; } async function importSingleDocument({ document, archive, organizationId, apiClient, tagMapping }) { const file = await createFileFromDocument({ document, archive }); const { document: uploadedDocument } = await apiClient.uploadDocument({ organizationId, file }); const documentTags = document.fields.tags.map((tagPk) => tagMapping.get(tagPk)).filter((tagId) => tagId !== void 0); for (const tagId of documentTags) { const [, error] = await safely(apiClient.addTagToDocument({ organizationId, documentId: uploadedDocument.id, tagId })); if (error) prompts.log.warn(`Failed to add tag (ID: ${tagId}) to document "${document.fields.title}": ${getErrorMessage(error)}`); } return uploadedDocument; } async function createFileFromDocument({ document, archive }) { const filePath = document.__exported_file_name__; const fileName = document.fields.original_filename ?? document.__exported_file_name__ ?? document.fields.title ?? "untitled"; const fileBuffer = await extractFileFromArchive({ archive, filePath }); const mimeType = document.fields.mime_type ?? mime.lookup(fileName) ?? "application/octet-stream"; return new File([fileBuffer], fileName, { type: mimeType }); } function displayImportSummary({ stats }) { const summaryLines = [ stats.tagsCreated > 0 ? `${pc.green("✓")} ${pluralize(stats.tagsCreated, "tag")} created` : null, `${pc.green("✓")} ${pluralize(stats.documentsImported, "document")} imported`, stats.documentsSkipped > 0 ? `${pc.yellow("⊘")} ${pluralize(stats.documentsSkipped, "document")} skipped (already exist)` : null, stats.documentsFailed > 0 ? `${pc.red("✗")} ${pluralize(stats.documentsFailed, "document")} failed` : null ].filter(Boolean); prompts.note(summaryLines.join("\n"), "Import summary"); if (stats.errors.length > 0) prompts.note(stats.errors.map(({ fileName, error }) => ` - ${fileName}: ${error}`).join("\n"), `Errors (${stats.errors.length})`); } function isDocumentAlreadyExistsError(error) { if (!error || typeof error !== "object") return false; const err = error; const status = err.status ?? err.statusCode; return status === 409; } //#endregion //#region src/commands/import/paperless/paperless.command.ts const paperlessCommand = defineCommand({ meta: { name: "paperless", description: "Import documents from a Paperless NGX export" }, args: { organizationId: organizationIdArgument, archivePath: { type: "positional", description: "The path to the Paperless NGX export archive", valueHint: "./export.zip", required: true } }, run: async ({ args }) => { const { apiKey, apiUrl } = await getConfig(); if (!apiKey) exit(`API key not found. Please create an api key in your Papra account and set it using the ${pc.bold("papra config init")} command.`); if (!fileExists(args.archivePath)) exit(`Archive file not found: ${args.archivePath}`); const apiClient = createClient({ apiKey, apiBaseUrl: apiUrl }); prompts.intro("Paperless NGX Import"); const { manifest, analysis } = await analyzeExport({ archivePath: args.archivePath }); if (analysis.documentCount === 0) { prompts.outro("No documents found in export"); return; } const { organizationId } = await getOrganizationId({ apiClient, argOrganizationId: args.organizationId }); const { tagMapping } = await analyzeAndHandleTags({ manifest, organizationId, apiClient }); const documentStrategy = await selectDocumentStrategy(); if (documentStrategy === "skip") { prompts.outro("Import cancelled"); return; } const stats = await importDocuments({ manifest, archivePath: args.archivePath, organizationId, apiClient, tagMapping, applyTags: documentStrategy === "import-with-tags" }); displayImportSummary({ stats }); prompts.outro("Import finished"); } }); //#endregion //#region src/commands/import/documents.command.ts const importCommand = defineCommand({ meta: { name: "import", description: "Import documents from external sources" }, subCommands: { paperless: paperlessCommand } }); //#endregion //#region src/config.ts const cliName = "papra"; //#endregion //#region src/cli.ts const main = defineCommand({ meta: { name: cliName, version, description }, subCommands: { documents: documentsCommand, config: configCommand, import: importCommand } }); runMain(main); //#endregion //# sourceMappingURL=cli.js.map