first commit
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { Clarizen, ClarizenApiError } from "@arco/clarizen-lib";
|
||||
|
||||
export function getApiKey(): string | undefined {
|
||||
return process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
|
||||
}
|
||||
|
||||
export function createClarizenClient(): Clarizen {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"Defina CLARIZEN_API_KEY ou API_KEY_AW no arquivo .env na raiz do projeto.",
|
||||
);
|
||||
}
|
||||
|
||||
return new Clarizen({
|
||||
baseUrl: process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com",
|
||||
apiToken: apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkClarizenAuth(
|
||||
clarizen: Clarizen,
|
||||
): Promise<
|
||||
{ ok: true; userId: string } | { ok: false; message: string; status?: number }
|
||||
> {
|
||||
try {
|
||||
const session = await clarizen.authentication.getSessionInfo();
|
||||
return { ok: true, userId: session.userId };
|
||||
} catch (err) {
|
||||
if (err instanceof ClarizenApiError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: err.message,
|
||||
status: err.status === 401 || err.status === 403 ? err.status : 502,
|
||||
};
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Falha de autenticação";
|
||||
return { ok: false, message, status: 502 };
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import "./load-env.js";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
outro,
|
||||
select,
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import {
|
||||
checkClarizenAuth,
|
||||
createClarizenClient,
|
||||
} from "./clarizen/client.js";
|
||||
import { formatFieldHint } from "./metadata/field-hints.js";
|
||||
import { getEntityMetadata, pickEntityType, pickField } from "./metadata/picker.js";
|
||||
import {
|
||||
formatValueForDisplay,
|
||||
parseFieldValue,
|
||||
ValueParseError,
|
||||
} from "./parse/value.js";
|
||||
import { formatFilterSummary } from "./query/filter.js";
|
||||
import { fetchAllEntities } from "./query/fetch-all.js";
|
||||
import { promptEntityFilter } from "./query/prompt-filter.js";
|
||||
import { bulkUpdateField } from "./update/bulk-field.js";
|
||||
import type { FieldDescription, JsonValue } from "@arco/clarizen-lib";
|
||||
|
||||
function exitCancel(): never {
|
||||
cancel("Operação cancelada.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function promptFieldValue(
|
||||
field: FieldDescription,
|
||||
): Promise<JsonValue | symbol> {
|
||||
console.log("\n" + formatFieldHint(field) + "\n");
|
||||
|
||||
if (field.nullable) {
|
||||
const action = await select({
|
||||
message: "O que deseja fazer com este campo?",
|
||||
options: [
|
||||
{ value: "set", label: "Definir um valor" },
|
||||
{ value: "clear", label: "Limpar campo (null)" },
|
||||
],
|
||||
});
|
||||
if (isCancel(action)) return action;
|
||||
if (action === "clear") return null;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const raw = await text({
|
||||
message: "Informe o novo valor",
|
||||
validate: (value) => {
|
||||
if (!field.nullable && !value?.trim()) {
|
||||
return "Valor obrigatório.";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (isCancel(raw)) return raw;
|
||||
|
||||
try {
|
||||
return parseFieldValue(raw, field);
|
||||
} catch (err) {
|
||||
if (err instanceof ValueParseError) {
|
||||
console.error(`\n Erro: ${err.message}\n`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
intro("Atualização em massa de campo — Clarizen");
|
||||
|
||||
const clarizen = createClarizenClient();
|
||||
|
||||
const authSpinner = spinner();
|
||||
authSpinner.start("Autenticando...");
|
||||
const auth = await checkClarizenAuth(clarizen);
|
||||
if (!auth.ok) {
|
||||
authSpinner.stop(`Falha na autenticação: ${auth.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
authSpinner.stop(`Autenticado (usuário: ${auth.userId})`);
|
||||
|
||||
const entityChoice = await pickEntityType(clarizen);
|
||||
if (isCancel(entityChoice)) exitCancel();
|
||||
|
||||
const entityMeta = await getEntityMetadata(clarizen, entityChoice.typeName);
|
||||
|
||||
const entityFilter = await promptEntityFilter(
|
||||
clarizen,
|
||||
entityChoice.typeName,
|
||||
entityMeta,
|
||||
);
|
||||
if (isCancel(entityFilter)) exitCancel();
|
||||
|
||||
const fieldChoice = await pickField(clarizen, entityChoice.typeName);
|
||||
if (isCancel(fieldChoice)) exitCancel();
|
||||
|
||||
const { field } = fieldChoice;
|
||||
const fieldName = field.name!;
|
||||
|
||||
const parsedValue = await promptFieldValue(field);
|
||||
if (isCancel(parsedValue)) exitCancel();
|
||||
|
||||
const fetchSpinner = spinner();
|
||||
fetchSpinner.start("Buscando objetos...");
|
||||
const records = await fetchAllEntities(
|
||||
clarizen,
|
||||
entityChoice.typeName,
|
||||
entityMeta,
|
||||
entityFilter,
|
||||
(count) => {
|
||||
fetchSpinner.message(`Buscando objetos... (${count} encontrados)`);
|
||||
},
|
||||
);
|
||||
fetchSpinner.stop(`${records.length} objeto(s) encontrado(s).`);
|
||||
|
||||
if (records.length === 0) {
|
||||
outro("Nenhum objeto encontrado com esse filtro.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entityLabel = entityChoice.label
|
||||
? `${entityChoice.typeName} (${entityChoice.label})`
|
||||
: entityChoice.typeName;
|
||||
|
||||
console.log(`
|
||||
Resumo da operação:
|
||||
Tipo: ${entityLabel}
|
||||
Filtro: ${formatFilterSummary(entityFilter)}
|
||||
Campo: ${field.label ?? fieldName} (${fieldName}) — ${field.type ?? "?"}
|
||||
Valor: ${formatValueForDisplay(parsedValue)}
|
||||
Objetos: ${records.length.toLocaleString("pt-BR")} registro(s)
|
||||
`);
|
||||
|
||||
const proceed = await confirm({
|
||||
message: `Confirma a atualização de ${records.length.toLocaleString("pt-BR")} objeto(s)?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (isCancel(proceed) || !proceed) exitCancel();
|
||||
|
||||
console.log("\nAtualizando...\n");
|
||||
|
||||
const result = await bulkUpdateField(
|
||||
clarizen,
|
||||
entityChoice.typeName,
|
||||
records,
|
||||
fieldName,
|
||||
parsedValue,
|
||||
);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
const errorFile = resolve(
|
||||
process.cwd(),
|
||||
`errors-${new Date().toISOString().replace(/[:.]/g, "-")}.json`,
|
||||
);
|
||||
writeFileSync(errorFile, JSON.stringify(result.errors, null, 2), "utf-8");
|
||||
console.log(`\n${result.errors.length} erro(s) — detalhes em: ${errorFile}`);
|
||||
}
|
||||
|
||||
outro(
|
||||
`Concluído: ${result.updated.toLocaleString("pt-BR")} atualizado(s), ${result.errors.length.toLocaleString("pt-BR")} erro(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { config } from "dotenv";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
for (const envPath of [
|
||||
resolve(rootDir, ".env"),
|
||||
resolve(rootDir, "../clarizen-lib/.env"),
|
||||
]) {
|
||||
if (existsSync(envPath)) {
|
||||
config({ path: envPath });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { FieldDescription, FieldType } from "@arco/clarizen-lib";
|
||||
|
||||
const TYPE_EXAMPLES: Record<FieldType, string> = {
|
||||
Boolean: "true, false, sim, não",
|
||||
String: 'texto livre, ex: "Meu valor"',
|
||||
Integer: "número inteiro, ex: 42",
|
||||
Long: "número inteiro, ex: 42",
|
||||
Double: "número decimal, ex: 3.14",
|
||||
DateTime: "2026-06-09 ou 2026-06-09T14:30:00Z",
|
||||
Date: "2026-06-09",
|
||||
Entity: 'ID da entidade, ex: /User/abc-123 ou {"id":"/User/abc-123"}',
|
||||
Duration: '{"value":5,"unit":"Days"}',
|
||||
Money: '{"value":100,"currency":"BRL"}',
|
||||
};
|
||||
|
||||
export function getFieldTypeExample(type: FieldType | undefined): string {
|
||||
if (!type) return "valor conforme o tipo do campo";
|
||||
return TYPE_EXAMPLES[type] ?? type;
|
||||
}
|
||||
|
||||
export function formatFieldHint(field: FieldDescription): string {
|
||||
const type = field.type ?? "desconhecido";
|
||||
const example = getFieldTypeExample(field.type);
|
||||
const lines = [
|
||||
`Tipo: ${type}`,
|
||||
`Exemplo: ${example}`,
|
||||
];
|
||||
|
||||
if (field.nullable) {
|
||||
lines.push("Este campo aceita valor vazio (null) para limpar.");
|
||||
}
|
||||
if (field.maxLength) {
|
||||
lines.push(`Tamanho máximo: ${field.maxLength} caracteres`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatFieldOptionLabel(field: FieldDescription): string {
|
||||
const label = field.label?.trim() || field.name || "?";
|
||||
const name = field.name ?? "?";
|
||||
const type = field.type ?? "?";
|
||||
return `${label} (${name}) — ${type}`;
|
||||
}
|
||||
|
||||
export function isUpdatableField(field: FieldDescription): boolean {
|
||||
if (!field.name?.trim()) return false;
|
||||
if (field.updateable === false) return false;
|
||||
if (field.calculated === true) return false;
|
||||
if (field.createOnly === true) return false;
|
||||
if (field.internal === true) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Clarizen, EntityDescription, FieldDescription } from "@arco/clarizen-lib";
|
||||
import { isCancel, select, text } from "@clack/prompts";
|
||||
import {
|
||||
formatFieldOptionLabel,
|
||||
isUpdatableField,
|
||||
} from "./field-hints.js";
|
||||
|
||||
export interface EntityChoice {
|
||||
typeName: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FieldChoice {
|
||||
field: FieldDescription;
|
||||
entity: EntityDescription;
|
||||
}
|
||||
|
||||
async function describeEntity(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
): Promise<EntityDescription | undefined> {
|
||||
const result = await clarizen.metadata.describeMetadata({
|
||||
typeNames: [typeName],
|
||||
flags: ["fields"],
|
||||
});
|
||||
return result.entityDescriptions?.find((e) => e.typeName === typeName);
|
||||
}
|
||||
|
||||
export async function listEntityChoices(
|
||||
clarizen: Clarizen,
|
||||
): Promise<EntityChoice[]> {
|
||||
const { typeNames } = await clarizen.metadata.listEntities();
|
||||
const sorted = [...typeNames].sort((a, b) => a.localeCompare(b, "pt-BR"));
|
||||
return sorted.map((typeName) => ({ typeName }));
|
||||
}
|
||||
|
||||
function filterOptions<T extends { value: string; label: string }>(
|
||||
allOptions: T[],
|
||||
term: string,
|
||||
): T[] {
|
||||
const normalized = term.trim().toLowerCase();
|
||||
if (!normalized) return allOptions;
|
||||
|
||||
return allOptions.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(normalized) ||
|
||||
o.value.toLowerCase().includes(normalized),
|
||||
);
|
||||
}
|
||||
|
||||
async function pickFromList<T extends { value: string; label: string }>(
|
||||
message: string,
|
||||
options: T[],
|
||||
): Promise<string | symbol> {
|
||||
if (options.length === 0) {
|
||||
throw new Error("Nenhuma opção disponível.");
|
||||
}
|
||||
|
||||
if (options.length === 1) {
|
||||
return options[0]!.value;
|
||||
}
|
||||
|
||||
return select({ message, options });
|
||||
}
|
||||
|
||||
async function pickFromFiltered<T extends { value: string; label: string }>(
|
||||
message: string,
|
||||
allOptions: T[],
|
||||
filterPrompt: string,
|
||||
): Promise<string | symbol> {
|
||||
if (allOptions.length === 0) {
|
||||
throw new Error("Nenhuma opção disponível.");
|
||||
}
|
||||
|
||||
const mode = await select({
|
||||
message: filterPrompt,
|
||||
options: [
|
||||
{ value: "all", label: "Ver lista completa" },
|
||||
{ value: "filter", label: "Filtrar por texto" },
|
||||
],
|
||||
});
|
||||
if (isCancel(mode)) return mode;
|
||||
|
||||
let options = allOptions;
|
||||
|
||||
if (mode === "filter") {
|
||||
const filter = await text({
|
||||
message: "Digite parte do nome para filtrar",
|
||||
});
|
||||
if (isCancel(filter)) return filter;
|
||||
|
||||
options = filterOptions(allOptions, filter);
|
||||
if (options.length === 0) {
|
||||
console.log("Nenhum resultado para esse filtro. Tente outro termo.\n");
|
||||
return pickFromFiltered(message, allOptions, filterPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
return pickFromList(message, options);
|
||||
}
|
||||
|
||||
export async function pickEntityType(
|
||||
clarizen: Clarizen,
|
||||
): Promise<EntityChoice | symbol> {
|
||||
const choices = await listEntityChoices(clarizen);
|
||||
const options = choices.map((c) => ({
|
||||
value: c.typeName,
|
||||
label: c.typeName,
|
||||
}));
|
||||
|
||||
const selected = await pickFromFiltered(
|
||||
"Escolha o tipo de entidade (nível)",
|
||||
options,
|
||||
"Filtrar tipos de entidade",
|
||||
);
|
||||
|
||||
if (isCancel(selected)) return selected;
|
||||
|
||||
const entity = await describeEntity(clarizen, selected);
|
||||
return {
|
||||
typeName: selected,
|
||||
label: entity?.label,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUpdatableFields(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
): Promise<FieldChoice[]> {
|
||||
const entity = await describeEntity(clarizen, typeName);
|
||||
if (!entity) {
|
||||
throw new Error(`Metadata não encontrada para ${typeName}.`);
|
||||
}
|
||||
|
||||
const fields = (entity.fields ?? [])
|
||||
.filter(isUpdatableField)
|
||||
.sort((a, b) => {
|
||||
const labelA = a.label ?? a.name ?? "";
|
||||
const labelB = b.label ?? b.name ?? "";
|
||||
return labelA.localeCompare(labelB, "pt-BR");
|
||||
});
|
||||
|
||||
return fields.map((field) => ({ field, entity }));
|
||||
}
|
||||
|
||||
export async function pickField(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
): Promise<FieldChoice | symbol> {
|
||||
const fieldChoices = await getUpdatableFields(clarizen, typeName);
|
||||
|
||||
if (fieldChoices.length === 0) {
|
||||
throw new Error(`Nenhum campo atualizável encontrado para ${typeName}.`);
|
||||
}
|
||||
|
||||
const options = fieldChoices.map(({ field }) => ({
|
||||
value: field.name!,
|
||||
label: formatFieldOptionLabel(field),
|
||||
}));
|
||||
|
||||
const selected = await pickFromFiltered(
|
||||
"Escolha o campo a atualizar",
|
||||
options,
|
||||
"Filtrar campos",
|
||||
);
|
||||
|
||||
if (isCancel(selected)) return selected;
|
||||
|
||||
const match = fieldChoices.find((c) => c.field.name === selected);
|
||||
if (!match) {
|
||||
throw new Error(`Campo não encontrado: ${selected}`);
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
export async function getEntityMetadata(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
): Promise<EntityDescription> {
|
||||
const entity = await describeEntity(clarizen, typeName);
|
||||
if (!entity) {
|
||||
throw new Error(`Metadata não encontrada para ${typeName}.`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { FieldDescription, FieldType, JsonValue } from "@arco/clarizen-lib";
|
||||
import { getFieldTypeExample } from "../metadata/field-hints.js";
|
||||
|
||||
export class ValueParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValueParseError";
|
||||
}
|
||||
}
|
||||
|
||||
const BOOLEAN_TRUE = new Set(["true", "1", "sim", "s", "yes", "y"]);
|
||||
const BOOLEAN_FALSE = new Set(["false", "0", "não", "nao", "n", "no"]);
|
||||
|
||||
function parseBoolean(raw: string): boolean {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (BOOLEAN_TRUE.has(normalized)) return true;
|
||||
if (BOOLEAN_FALSE.has(normalized)) return false;
|
||||
throw new ValueParseError(
|
||||
`Valor booleano inválido: "${raw}". Use: ${getFieldTypeExample("Boolean")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseNumber(raw: string, integer: boolean): number {
|
||||
const trimmed = raw.trim();
|
||||
const value = Number(trimmed);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new ValueParseError(`Número inválido: "${raw}"`);
|
||||
}
|
||||
if (integer && !Number.isInteger(value)) {
|
||||
throw new ValueParseError(`Esperado número inteiro: "${raw}"`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEntity(raw: string): JsonValue {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (typeof parsed.id === "string") {
|
||||
return { id: parsed.id };
|
||||
}
|
||||
throw new ValueParseError('JSON de Entity deve conter "id".');
|
||||
} catch (err) {
|
||||
if (err instanceof ValueParseError) throw err;
|
||||
throw new ValueParseError(`JSON inválido para Entity: "${raw}"`);
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return { id: trimmed };
|
||||
}
|
||||
throw new ValueParseError(
|
||||
`Referência de Entity inválida: "${raw}". Use /Tipo/id ou {"id":"/Tipo/id"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string, typeName: string): JsonValue {
|
||||
try {
|
||||
return JSON.parse(raw.trim()) as JsonValue;
|
||||
} catch {
|
||||
throw new ValueParseError(
|
||||
`JSON inválido para ${typeName}: "${raw}". Exemplo: ${getFieldTypeExample(typeName as FieldType)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseByType(raw: string, type: FieldType | undefined): JsonValue {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
switch (type) {
|
||||
case "Boolean":
|
||||
return parseBoolean(trimmed);
|
||||
case "String":
|
||||
return trimmed;
|
||||
case "Integer":
|
||||
case "Long":
|
||||
return parseNumber(trimmed, true);
|
||||
case "Double":
|
||||
return parseNumber(trimmed, false);
|
||||
case "DateTime":
|
||||
case "Date":
|
||||
return trimmed;
|
||||
case "Entity":
|
||||
return parseEntity(trimmed);
|
||||
case "Duration":
|
||||
case "Money":
|
||||
return parseJsonObject(trimmed, type);
|
||||
default:
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFieldValue(
|
||||
raw: string,
|
||||
field: FieldDescription,
|
||||
): JsonValue {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (trimmed === "" && field.nullable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.toLowerCase() === "null" && field.nullable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed === "") {
|
||||
throw new ValueParseError("Valor não pode ser vazio.");
|
||||
}
|
||||
|
||||
return parseByType(raw, field.type);
|
||||
}
|
||||
|
||||
export function formatValueForDisplay(value: JsonValue): string {
|
||||
if (value === null) return "(vazio / null)";
|
||||
if (typeof value === "string") return `"${value}"`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Clarizen, EntityDescription, EntityId } from "@arco/clarizen-lib";
|
||||
import { buildWhereCondition, type EntityFilter } from "./filter.js";
|
||||
import { resolveDisplayField } from "./resolve-display-field.js";
|
||||
|
||||
export interface EntityRecord {
|
||||
id: EntityId;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 5000;
|
||||
|
||||
export async function fetchAllEntities(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
entityMeta: EntityDescription,
|
||||
filter: EntityFilter = { kind: "all" },
|
||||
onProgress?: (count: number) => void,
|
||||
): Promise<EntityRecord[]> {
|
||||
const displayField = resolveDisplayField(entityMeta);
|
||||
const where = buildWhereCondition(filter);
|
||||
const records: EntityRecord[] = [];
|
||||
let from = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await clarizen.data.entityQuery({
|
||||
typeName,
|
||||
fields: [displayField],
|
||||
...(where ? { where } : {}),
|
||||
paging: { from, limit: PAGE_SIZE },
|
||||
});
|
||||
|
||||
for (const entity of result.entities) {
|
||||
if (!entity.id) continue;
|
||||
const displayName = extractDisplayName(
|
||||
entity as Record<string, unknown>,
|
||||
displayField,
|
||||
);
|
||||
records.push({ id: entity.id, displayName });
|
||||
}
|
||||
|
||||
onProgress?.(records.length);
|
||||
|
||||
if (!result.paging?.hasMore) break;
|
||||
from += PAGE_SIZE;
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function extractDisplayName(
|
||||
entity: Record<string, unknown>,
|
||||
displayField: string,
|
||||
): string | undefined {
|
||||
const objectFields = entity.objectFields as Record<string, unknown> | undefined;
|
||||
const value = entity[displayField] ?? objectFields?.[displayField];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "object" && value !== null && "name" in value) {
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (name !== null && name !== undefined) return String(name);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Condition } from "@arco/clarizen-lib";
|
||||
|
||||
export type EntityFilter =
|
||||
| { kind: "all" }
|
||||
| {
|
||||
kind: "state";
|
||||
fieldName: string;
|
||||
stateValue: string;
|
||||
stateLabel: string;
|
||||
}
|
||||
| { kind: "czql"; whereClause: string };
|
||||
|
||||
export function formatFilterSummary(filter: EntityFilter): string {
|
||||
switch (filter.kind) {
|
||||
case "all":
|
||||
return "Todos";
|
||||
case "state":
|
||||
return `${filter.fieldName} = ${filter.stateLabel}`;
|
||||
case "czql":
|
||||
return `WHERE ${filter.whereClause}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWhereCondition(filter: EntityFilter): Condition | undefined {
|
||||
switch (filter.kind) {
|
||||
case "all":
|
||||
return undefined;
|
||||
case "state":
|
||||
return {
|
||||
leftExpression: { fieldName: filter.fieldName },
|
||||
operator: "Equal",
|
||||
rightExpression: { value: filter.stateValue },
|
||||
};
|
||||
case "czql":
|
||||
return { text: filter.whereClause };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Clarizen, EntityDescription } from "@arco/clarizen-lib";
|
||||
import { ClarizenApiError } from "@arco/clarizen-lib";
|
||||
import { isCancel, select, text } from "@clack/prompts";
|
||||
import type { EntityFilter } from "./filter.js";
|
||||
import { buildWhereCondition } from "./filter.js";
|
||||
import { detectStateFilterCapability } from "./state-field.js";
|
||||
import { resolveDisplayField } from "./resolve-display-field.js";
|
||||
|
||||
const BLOCKED_CZQL = /\b(DROP|DELETE|UPDATE|INSERT|ALTER|TRUNCATE)\b/i;
|
||||
|
||||
function validateWhereSyntax(whereClause: string): string | undefined {
|
||||
const trimmed = whereClause.trim();
|
||||
if (!trimmed) return "A cláusula WHERE não pode ser vazia.";
|
||||
if (trimmed.includes(";")) return "Não use ponto e vírgula na cláusula WHERE.";
|
||||
if (BLOCKED_CZQL.test(trimmed)) {
|
||||
return "A cláusula WHERE não pode conter comandos de modificação.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function validateWhereAgainstApi(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
entityMeta: EntityDescription,
|
||||
whereClause: string,
|
||||
): Promise<string | undefined> {
|
||||
const where = buildWhereCondition({ kind: "czql", whereClause });
|
||||
const displayField = resolveDisplayField(entityMeta);
|
||||
|
||||
try {
|
||||
await clarizen.data.entityQuery({
|
||||
typeName,
|
||||
fields: [displayField],
|
||||
where,
|
||||
paging: { limit: 1, from: 0 },
|
||||
});
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
if (err instanceof ClarizenApiError) {
|
||||
return err.message;
|
||||
}
|
||||
return err instanceof Error ? err.message : "Cláusula WHERE inválida.";
|
||||
}
|
||||
}
|
||||
|
||||
async function promptCzqlWhere(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
entityMeta: EntityDescription,
|
||||
): Promise<EntityFilter | symbol> {
|
||||
const displayField = resolveDisplayField(entityMeta);
|
||||
|
||||
console.log(`
|
||||
Exemplo: State = 'Active' AND Name LIKE 'Projeto%'
|
||||
O app aplicará: SELECT ${displayField} FROM ${typeName} WHERE <sua cláusula>
|
||||
`);
|
||||
|
||||
while (true) {
|
||||
const whereClause = await text({
|
||||
message: "Informe a cláusula WHERE (CZQL)",
|
||||
validate: validateWhereSyntax,
|
||||
});
|
||||
if (isCancel(whereClause)) return whereClause;
|
||||
|
||||
const apiError = await validateWhereAgainstApi(
|
||||
clarizen,
|
||||
typeName,
|
||||
entityMeta,
|
||||
whereClause.trim(),
|
||||
);
|
||||
if (apiError) {
|
||||
console.error(`\n Erro: ${apiError}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
return { kind: "czql", whereClause: whereClause.trim() };
|
||||
}
|
||||
}
|
||||
|
||||
async function promptStateFilter(
|
||||
capability: NonNullable<ReturnType<typeof detectStateFilterCapability>>,
|
||||
): Promise<EntityFilter | symbol> {
|
||||
const selected = await select({
|
||||
message: `Escolha o State (${capability.fieldName})`,
|
||||
options: capability.states.map((s) => ({
|
||||
value: s.value,
|
||||
label: s.label,
|
||||
})),
|
||||
});
|
||||
if (isCancel(selected)) return selected;
|
||||
|
||||
const match = capability.states.find((s) => s.value === selected);
|
||||
return {
|
||||
kind: "state",
|
||||
fieldName: capability.fieldName,
|
||||
stateValue: selected,
|
||||
stateLabel: match?.label ?? selected,
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptEntityFilter(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
entityMeta: EntityDescription,
|
||||
): Promise<EntityFilter | symbol> {
|
||||
const stateCapability = detectStateFilterCapability(entityMeta);
|
||||
|
||||
const options: Array<{ value: string; label: string }> = [
|
||||
{ value: "all", label: "Todos os objetos" },
|
||||
];
|
||||
|
||||
if (stateCapability) {
|
||||
options.push({
|
||||
value: "state",
|
||||
label: `Filtrar por State (${stateCapability.fieldName})`,
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: "czql",
|
||||
label: "Filtro customizado (CZQL WHERE)",
|
||||
});
|
||||
|
||||
const mode = await select({
|
||||
message: "Como deseja filtrar os objetos?",
|
||||
options,
|
||||
});
|
||||
if (isCancel(mode)) return mode;
|
||||
|
||||
switch (mode) {
|
||||
case "all":
|
||||
return { kind: "all" };
|
||||
case "state":
|
||||
if (!stateCapability) {
|
||||
throw new Error("Este tipo não possui filtro por State.");
|
||||
}
|
||||
return promptStateFilter(stateCapability);
|
||||
case "czql":
|
||||
return promptCzqlWhere(clarizen, typeName, entityMeta);
|
||||
default:
|
||||
return { kind: "all" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { EntityDescription } from "@arco/clarizen-lib";
|
||||
|
||||
const FALLBACK_FIELDS = ["Name", "Title", "Subject", "Description"] as const;
|
||||
|
||||
const NON_QUERYABLE = new Set(["id", "Id", "ID"]);
|
||||
|
||||
export function resolveDisplayField(entity: EntityDescription): string {
|
||||
const fieldNames = new Set(
|
||||
(entity.fields ?? [])
|
||||
.map((f) => f.name)
|
||||
.filter((name): name is string => Boolean(name?.trim())),
|
||||
);
|
||||
|
||||
const candidates = [
|
||||
entity.displayField,
|
||||
...FALLBACK_FIELDS,
|
||||
].filter((name): name is string => Boolean(name?.trim()));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (NON_QUERYABLE.has(candidate)) continue;
|
||||
if (fieldNames.size === 0 || fieldNames.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "Name";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { EntityDescription } from "@arco/clarizen-lib";
|
||||
|
||||
export interface StateFilterCapability {
|
||||
fieldName: string;
|
||||
states: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
function stateLabel(value: string): string {
|
||||
if (value.includes("/")) {
|
||||
return value.split("/").pop() ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveStateFieldName(entity: EntityDescription): string | undefined {
|
||||
const typeName = entity.typeName ?? "";
|
||||
const fields = entity.fields ?? [];
|
||||
const byName = new Map(
|
||||
fields
|
||||
.filter((f) => f.name?.trim())
|
||||
.map((f) => [f.name!, f] as const),
|
||||
);
|
||||
|
||||
if (byName.has("State")) return "State";
|
||||
|
||||
const typeState = `${typeName}State`;
|
||||
if (byName.has(typeState)) return typeState;
|
||||
|
||||
const stateField = fields.find(
|
||||
(f) =>
|
||||
f.name?.endsWith("State") &&
|
||||
f.type === "Entity" &&
|
||||
f.filterable !== false,
|
||||
);
|
||||
return stateField?.name;
|
||||
}
|
||||
|
||||
export function detectStateFilterCapability(
|
||||
entity: EntityDescription,
|
||||
): StateFilterCapability | null {
|
||||
const validStates = entity.validStates?.filter((s) => s.trim()) ?? [];
|
||||
if (validStates.length === 0) return null;
|
||||
|
||||
const fieldName = resolveStateFieldName(entity);
|
||||
if (!fieldName) return null;
|
||||
|
||||
return {
|
||||
fieldName,
|
||||
states: validStates.map((value) => ({
|
||||
value,
|
||||
label: stateLabel(value),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
ClarizenApiError,
|
||||
entityObjectPath,
|
||||
type Clarizen,
|
||||
type EntityId,
|
||||
type JsonValue,
|
||||
} from "@arco/clarizen-lib";
|
||||
import type { EntityRecord } from "../query/fetch-all.js";
|
||||
import { ProgressReporter } from "./progress.js";
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
export interface UpdateError {
|
||||
id: EntityId;
|
||||
displayName?: string;
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
export interface BulkUpdateResult {
|
||||
updated: number;
|
||||
errors: UpdateError[];
|
||||
}
|
||||
|
||||
interface BulkResponse {
|
||||
responses?: Array<{
|
||||
statusCode?: number;
|
||||
body?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
function shortId(id: EntityId): string {
|
||||
const str = String(id);
|
||||
return str.includes("/") ? str.split("/").pop()! : str;
|
||||
}
|
||||
|
||||
function buildUpdateBody(fieldName: string, value: JsonValue): string {
|
||||
return JSON.stringify({ [fieldName]: value });
|
||||
}
|
||||
|
||||
function extractErrorMessage(body: unknown): { message: string; errorCode?: string } {
|
||||
if (body && typeof body === "object") {
|
||||
const obj = body as Record<string, unknown>;
|
||||
const message =
|
||||
typeof obj.message === "string" ? obj.message : JSON.stringify(body);
|
||||
const errorCode =
|
||||
typeof obj.errorCode === "string" ? obj.errorCode : undefined;
|
||||
return { message, errorCode };
|
||||
}
|
||||
return { message: String(body) };
|
||||
}
|
||||
|
||||
async function updateSingle(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
id: EntityId,
|
||||
fieldName: string,
|
||||
value: JsonValue,
|
||||
): Promise<void> {
|
||||
await clarizen.objects.update(typeName, id, { [fieldName]: value });
|
||||
}
|
||||
|
||||
async function updateBatchBulk(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
batch: EntityRecord[],
|
||||
fieldName: string,
|
||||
value: JsonValue,
|
||||
): Promise<Array<{ record: EntityRecord; error?: UpdateError }>> {
|
||||
const requests = batch.map((record) => ({
|
||||
url: entityObjectPath(typeName, shortId(record.id)).replace(
|
||||
/^\/V2\.0\/services/,
|
||||
"",
|
||||
),
|
||||
method: "POST" as const,
|
||||
body: buildUpdateBody(fieldName, value),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = (await clarizen.bulk.execute({
|
||||
requests,
|
||||
})) as BulkResponse;
|
||||
|
||||
const responses = result.responses ?? [];
|
||||
return batch.map((record, index) => {
|
||||
const response = responses[index];
|
||||
if (!response || response.statusCode === 200) {
|
||||
return { record };
|
||||
}
|
||||
const { message, errorCode } = extractErrorMessage(response.body);
|
||||
return {
|
||||
record,
|
||||
error: {
|
||||
id: record.id,
|
||||
displayName: record.displayName,
|
||||
message,
|
||||
errorCode,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ClarizenApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Falha no lote bulk";
|
||||
return batch.map((record) => ({
|
||||
record,
|
||||
error: {
|
||||
id: record.id,
|
||||
displayName: record.displayName,
|
||||
message,
|
||||
errorCode:
|
||||
err instanceof ClarizenApiError ? String(err.errorCode) : undefined,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function retryIndividually(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
record: EntityRecord,
|
||||
fieldName: string,
|
||||
value: JsonValue,
|
||||
): Promise<UpdateError | undefined> {
|
||||
try {
|
||||
await updateSingle(clarizen, typeName, record.id, fieldName, value);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
if (err instanceof ClarizenApiError) {
|
||||
return {
|
||||
id: record.id,
|
||||
displayName: record.displayName,
|
||||
message: err.message,
|
||||
errorCode: String(err.errorCode),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
displayName: record.displayName,
|
||||
message: err instanceof Error ? err.message : "Erro desconhecido",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkUpdateField(
|
||||
clarizen: Clarizen,
|
||||
typeName: string,
|
||||
records: EntityRecord[],
|
||||
fieldName: string,
|
||||
value: JsonValue,
|
||||
): Promise<BulkUpdateResult> {
|
||||
const progress = new ProgressReporter(records.length);
|
||||
let updated = 0;
|
||||
const errors: UpdateError[] = [];
|
||||
|
||||
for (let i = 0; i < records.length; i += BATCH_SIZE) {
|
||||
const batch = records.slice(i, i + BATCH_SIZE);
|
||||
const results = await updateBatchBulk(
|
||||
clarizen,
|
||||
typeName,
|
||||
batch,
|
||||
fieldName,
|
||||
value,
|
||||
);
|
||||
|
||||
for (const { record, error } of results) {
|
||||
if (!error) {
|
||||
updated++;
|
||||
progress.update(updated + errors.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const retryError = await retryIndividually(
|
||||
clarizen,
|
||||
typeName,
|
||||
record,
|
||||
fieldName,
|
||||
value,
|
||||
);
|
||||
if (retryError) {
|
||||
errors.push(retryError);
|
||||
} else {
|
||||
updated++;
|
||||
}
|
||||
progress.update(updated + errors.length);
|
||||
}
|
||||
}
|
||||
|
||||
progress.finish();
|
||||
return { updated, errors };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const BAR_WIDTH = 30;
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "?";
|
||||
const totalSeconds = Math.ceil(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${seconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMinutes = minutes % 60;
|
||||
return `${hours}h ${remMinutes}m`;
|
||||
}
|
||||
|
||||
function renderBar(percent: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const filled = Math.round((clamped / 100) * BAR_WIDTH);
|
||||
const empty = BAR_WIDTH - filled;
|
||||
return `[${"█".repeat(filled)}${"░".repeat(empty)}]`;
|
||||
}
|
||||
|
||||
export class ProgressReporter {
|
||||
private readonly startTime = Date.now();
|
||||
private lastLineLength = 0;
|
||||
|
||||
constructor(private readonly total: number) {}
|
||||
|
||||
update(processed: number): void {
|
||||
const percent = this.total > 0 ? (processed / this.total) * 100 : 100;
|
||||
const elapsed = Date.now() - this.startTime;
|
||||
const avgMs = processed > 0 ? elapsed / processed : 0;
|
||||
const remaining = this.total - processed;
|
||||
const etaMs = processed > 0 ? remaining * avgMs : 0;
|
||||
|
||||
const line = [
|
||||
renderBar(percent),
|
||||
`${percent.toFixed(1)}%`,
|
||||
`(${processed}/${this.total})`,
|
||||
processed > 0 && remaining > 0 ? `~${formatDuration(etaMs)} restantes` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const padded =
|
||||
line.length < this.lastLineLength
|
||||
? line + " ".repeat(this.lastLineLength - line.length)
|
||||
: line;
|
||||
this.lastLineLength = padded.length;
|
||||
|
||||
process.stdout.write(`\r${padded}`);
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user