@papra/cli
Version:
Command line interface for Papra, the document archiving platform.
720 lines (699 loc) • 22.9 kB
JavaScript
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