From fe7d6b2678a7763f1be989a84253ced10ac240a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20D=2E=20D=C3=ADaz?= Date: Sun, 24 Nov 2024 05:37:45 +0100 Subject: [PATCH] imp: command exportfile: xml + fix: search, read: --read-binary --- CHANGELOG.md | 3 + _locales/en/translation.json | 2 + _locales/es/translation.json | 2 + src/js/flow-typed/odoo.js | 13 ++ src/js/page/odoo/commands/backoffice/view.mjs | 5 +- src/js/page/odoo/commands/common/__all__.mjs | 2 + .../commands/common}/exportfile.mjs | 21 ++- src/js/page/odoo/commands/common/metadata.mjs | 10 +- src/js/page/odoo/commands/common/read.mjs | 40 +++--- src/js/page/odoo/commands/common/search.mjs | 24 ++-- src/js/page/odoo/longpolling.mjs | 13 +- src/js/page/odoo/net_utils/xml.mjs | 136 ++++++++++++++++++ src/js/page/odoo/rpc.mjs | 6 +- src/js/page/odoo/terminal.mjs | 6 +- src/js/page/terminal/commands/__all__.mjs | 2 - src/js/page/terminal/utils/csv.mjs | 20 ++- 16 files changed, 236 insertions(+), 69 deletions(-) rename src/js/page/{terminal/commands => odoo/commands/common}/exportfile.mjs (69%) create mode 100644 src/js/page/odoo/net_utils/xml.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbfcc8..4c9bb5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ ``` IMP: Command 'exportfile': Add CSV format [RFC-4180] (issue #138) +IMP: Command 'exportfile': Add XML odoo format IMP: Interpreter: Add unknown lexer type (issue $139) + +FIX: Command 'search', 'read': --read-binary ``` **11.6.0** diff --git a/_locales/en/translation.json b/_locales/en/translation.json index a201d5b..8955194 100644 --- a/_locales/en/translation.json +++ b/_locales/en/translation.json @@ -290,6 +290,7 @@ }, "cmdExportFile": { "args": { + "delimiter": "The delimiter", "filename": "The filename", "format": "The format to use for exporting", "noHeader": "Don't use header", @@ -297,6 +298,7 @@ }, "definition": "Exports the command result to a text/json file", "detail": "Exports the command result to a text/json file.", + "invalidValue": "Invalid value: need a recordset with csv and xml", "resultExported": "Command result exported to '{{filename}}' file" }, "cmdExportVar": { diff --git a/_locales/es/translation.json b/_locales/es/translation.json index 44eaacd..755cfd9 100644 --- a/_locales/es/translation.json +++ b/_locales/es/translation.json @@ -290,6 +290,7 @@ }, "cmdExportFile": { "args": { + "delimiter": "The delimiter", "filename": "El nombre del archivo", "format": "El formato a utilizar", "noHeader": "No usar cabecera", @@ -297,6 +298,7 @@ }, "definition": "Exporta el resultado del comando a un archivo de texto/json", "detail": "Exporta el resultado del comando a un archivo de texto/json.", + "invalidValue": "Valor inválido: se requiere un 'recordset' con csv y xml", "resultExported": "Resultado del comando exportado al archivo '{{filename}}'" }, "cmdExportVar": { diff --git a/src/js/flow-typed/odoo.js b/src/js/flow-typed/odoo.js index 124d757..dbba115 100644 --- a/src/js/flow-typed/odoo.js +++ b/src/js/flow-typed/odoo.js @@ -18,6 +18,7 @@ declare type OdooSession = { }; declare type OdooSessionInfo = Object; declare type OdooSessionInfoUserContext = Object; +declare type OdooLongpollingData = Object; declare type OdooLongpollingItem = [string, string] | {...}; declare type OdooSearchResponse = Object; @@ -26,3 +27,15 @@ declare type OdooService = Object; declare type BusService = OdooService; declare type BarcodeService = OdooService; declare type UserService = OdooService; + +declare type OdooQueryRPCParams = Object; + +declare type OdooMetadataInfo = { + id: number, + create_uid: number, + create_date: string, + write_uid: number, + write_date: string, + noupdate: boolean, + xmlid: string, +}; diff --git a/src/js/page/odoo/commands/backoffice/view.mjs b/src/js/page/odoo/commands/backoffice/view.mjs index e59249d..4cff502 100644 --- a/src/js/page/odoo/commands/backoffice/view.mjs +++ b/src/js/page/odoo/commands/backoffice/view.mjs @@ -13,8 +13,7 @@ import {ARG} from '@trash/constants'; import type {CMDCallbackArgs, CMDDef} from '@trash/interpreter'; import type Terminal from '@terminal/terminal'; -// $FlowFixMe -export type CMDViewOnSelectedCallback = (records: $ReadOnlyArray) => mixed; +export type CMDViewOnSelectedCallback = (records: $ReadOnlyArray) => mixed; function openSelectCreateDialog( model: string, @@ -77,7 +76,7 @@ async function cmdViewModelRecord(this: Terminal, kwargs: CMDCallbackArgs): Prom type: 'ir.actions.act_window', name: i18n.t('cmdView.result.viewRecord', 'View Record'), res_model: kwargs.model, - res_id: records[0].id || records[0], + res_id: records[0]?.id !== 'undefined' ? records[0]?.id : records[0], views: [[false, 'form']], target: 'current', context: context, diff --git a/src/js/page/odoo/commands/common/__all__.mjs b/src/js/page/odoo/commands/common/__all__.mjs index d469bec..aaedb8e 100644 --- a/src/js/page/odoo/commands/common/__all__.mjs +++ b/src/js/page/odoo/commands/common/__all__.mjs @@ -45,6 +45,7 @@ import cmdWS from './ws'; import cmdURL from './url'; import cmdInfo from './info'; import cmdNotify from './notify'; +import cmdExportFile from './exportfile'; import type VMachine from '@trash/vmachine'; export default function (vm: VMachine) { @@ -91,4 +92,5 @@ export default function (vm: VMachine) { vm.registerCommand('url', cmdURL()); vm.registerCommand('info', cmdInfo()); vm.registerCommand('notify', cmdNotify()); + vm.registerCommand('exportfile', cmdExportFile()); } diff --git a/src/js/page/terminal/commands/exportfile.mjs b/src/js/page/odoo/commands/common/exportfile.mjs similarity index 69% rename from src/js/page/terminal/commands/exportfile.mjs rename to src/js/page/odoo/commands/common/exportfile.mjs index 8401866..5c28ba1 100644 --- a/src/js/page/terminal/commands/exportfile.mjs +++ b/src/js/page/odoo/commands/common/exportfile.mjs @@ -5,7 +5,9 @@ // $FlowIgnore import i18n from 'i18next'; import save2file from '@terminal/utils/save2file'; -import {stringify} from '@terminal/utils/csv'; +import Recordset from '@terminal/core/recordset'; +import csvStringify from '@terminal/utils/csv'; +import xmlStringify from '@odoo/net_utils/xml'; import uniqueId from '@trash/utils/unique_id'; import {ARG} from '@trash/constants'; import type {CMDCallbackArgs, CMDCallbackContext, CMDDef} from '@trash/interpreter'; @@ -18,9 +20,17 @@ async function cmdExportFile(this: Terminal, kwargs: CMDCallbackArgs, ctx: CMDCa if (kwargs.format === 'json') { mime = 'text/json'; data = JSON.stringify(kwargs.value, null, 4); - } else if (kwargs.format === 'csv') { - mime = 'text/csv'; - data = stringify(kwargs.value, !kwargs.no_header); + } else if (kwargs.format === 'csv' || kwargs.format === 'xml') { + if (!(kwargs.value instanceof Recordset)) { + throw new Error(i18n.t('cmdExportFile.invalidValue', 'Invalid value: must be a recordset with csv and xml')); + } + if (kwargs.format === 'csv') { + mime = 'text/csv'; + data = csvStringify(kwargs.value, !kwargs.no_header, kwargs.delimiter); + } else if (kwargs.format === 'xml') { + mime = 'text/xml'; + data = await xmlStringify(kwargs.value, await this.getContext()); + } } save2file(filename, mime, data); ctx.screen.print( @@ -36,8 +46,9 @@ export default function (): Partial { detail: i18n.t('cmdExportFile.detail', 'Exports the command result to a text/json file.'), args: [ [ARG.Flag, ['no-header', 'no-header'], false, i18n.t('cmdExportFile.args.noHeader', "Don't use header"), false], - [ARG.String, ['f', 'format'], false, i18n.t('cmdExportFile.args.format', 'The format to use for exporting'), 'json', ['json', 'csv']], + [ARG.String, ['f', 'format'], false, i18n.t('cmdExportFile.args.format', 'The format to use for exporting'), 'json', ['json', 'csv', 'xml']], [ARG.String, ['fn', 'filename'], false, i18n.t('cmdExportFile.args.filename', 'The filename')], + [ARG.String, ['d', 'delimiter'], false, i18n.t('cmdExportFile.args.delimiter', 'The delimiter'), ','], [ARG.Any, ['v', 'value'], true, i18n.t('cmdExportFile.args.value', 'The value to export')], ], example: "-c 'search res.partner'", diff --git a/src/js/page/odoo/commands/common/metadata.mjs b/src/js/page/odoo/commands/common/metadata.mjs index 17ada69..588e958 100644 --- a/src/js/page/odoo/commands/common/metadata.mjs +++ b/src/js/page/odoo/commands/common/metadata.mjs @@ -11,18 +11,10 @@ import {ARG} from '@trash/constants'; import type {CMDCallbackArgs, CMDCallbackContext, CMDDef} from '@trash/interpreter'; import type Terminal from '@odoo/terminal'; -type MetadataInfo = { - create_uid: number, - create_date: string, - write_uid: number, - write_date: string, - noupdate: boolean, - xmlid: string, -}; async function cmdMetadata(this: Terminal, kwargs: CMDCallbackArgs, ctx: CMDCallbackContext) { const metadata = ( - await callModelMulti<$ReadOnlyArray>( + await callModelMulti<$ReadOnlyArray>( kwargs.model, [kwargs.id], 'get_metadata', diff --git a/src/js/page/odoo/commands/common/read.mjs b/src/js/page/odoo/commands/common/read.mjs index db5f29d..8efab5d 100644 --- a/src/js/page/odoo/commands/common/read.mjs +++ b/src/js/page/odoo/commands/common/read.mjs @@ -18,25 +18,29 @@ async function cmdSearchModelRecordId(this: Terminal, kwargs: CMDCallbackArgs, c const bin_fields = []; // Due to possible problems with binary fields it is necessary to filter them out - if (search_all_fields && !kwargs.read_binary) { - // $FlowFixMe - const fieldDefs = await callModel<{[string]: Object}>( - kwargs.model, - 'fields_get', - [fields], - null, - await this.getContext(), - kwargs.options, - ); + if (search_all_fields) { + if (!kwargs.read_binary) { + // $FlowFixMe + const fieldDefs = await callModel<{[string]: Object}>( + kwargs.model, + 'fields_get', + [fields], + null, + await this.getContext(), + kwargs.options, + ); - fields = []; - Object.entries(fieldDefs).forEach(item => { - if (item[1].type === 'binary') { - bin_fields.push(item[0]); - } else { - fields.push(item[0]); - } - }); + fields = []; + Object.entries(fieldDefs).forEach(item => { + if (item[1].type === 'binary') { + bin_fields.push(item[0]); + } else { + fields.push(item[0]); + } + }); + } else { + fields = false; + } } const result = await searchRead(kwargs.model, [['id', 'in', kwargs.id]], fields, await this.getContext()); diff --git a/src/js/page/odoo/commands/common/search.mjs b/src/js/page/odoo/commands/common/search.mjs index 0cd2888..bb80925 100644 --- a/src/js/page/odoo/commands/common/search.mjs +++ b/src/js/page/odoo/commands/common/search.mjs @@ -53,17 +53,21 @@ async function cmdSearchModelRecord(this: Terminal, kwargs: CMDCallbackArgs, ctx // Due to possible problems with binary fields it is necessary to filter them out const bin_fields = []; - if (search_all_fields && !kwargs.read_binary) { - const fieldDefs = await getFieldsInfo(kwargs.model, false, await this.getContext(), kwargs.options); + if (search_all_fields) { + if (!kwargs.read_binary) { + const fieldDefs = await getFieldsInfo(kwargs.model, false, await this.getContext(), kwargs.options); - fields = []; - Object.entries(fieldDefs).forEach(item => { - if (item[1].type === 'binary') { - bin_fields.push(item[0]); - } else { - fields.push(item[0]); - } - }); + fields = []; + Object.entries(fieldDefs).forEach(item => { + if (item[1].type === 'binary') { + bin_fields.push(item[0]); + } else { + fields.push(item[0]); + } + }); + } else { + fields = false; + } } const result = await searchRead(kwargs.model, kwargs.domain, fields, await this.getContext(), Object.assign({}, kwargs.options, { diff --git a/src/js/page/odoo/longpolling.mjs b/src/js/page/odoo/longpolling.mjs index 981e129..b1fb342 100644 --- a/src/js/page/odoo/longpolling.mjs +++ b/src/js/page/odoo/longpolling.mjs @@ -20,18 +20,16 @@ export default class Longpolling { let has_listener = false; if (typeof OdooVerMajor === 'number') { if (OdooVerMajor <= 11) { - // $FlowFixMe - this.#getBusService().on('notification', this, this.#onBusNotification.bind(this)); + this.#getBusService().on('notification', this, this.onBusNotification); has_listener = true; } else if (OdooVerMajor >= 16) { - // $FlowFixMe - this.#busServ('addEventListener', 'notification', this.#onBusNotification.bind(this)); + this.#busServ('addEventListener', 'notification', this.onBusNotification); has_listener = true; } } if (!has_listener) { this.#busServ('onNotification', this, (data: $ReadOnlyArray) => - this.#onBusNotification(data), + this.onBusNotification(data), ); } } @@ -104,15 +102,14 @@ export default class Longpolling { return getStorageItem('terminal_longpolling_mode') === 'verbose'; } - // $FlowFixMe - #getNotificationsData(data: Object): $ReadOnlyArray { + #getNotificationsData(data: OdooLongpollingData): $ReadOnlyArray { const OdooVerMajor = getOdooVersion('major'); if (typeof OdooVerMajor === 'number' && OdooVerMajor >= 16) { return data.detail; } return data; } - #onBusNotification(data: $ReadOnlyArray) { + onBusNotification(data: $ReadOnlyArray) { if (this.isVerbose()) { this.#terminal.onBusNotification(this.#getNotificationsData(data)); } diff --git a/src/js/page/odoo/net_utils/xml.mjs b/src/js/page/odoo/net_utils/xml.mjs new file mode 100644 index 0000000..b706f8f --- /dev/null +++ b/src/js/page/odoo/net_utils/xml.mjs @@ -0,0 +1,136 @@ +// @flow strict +// Copyright Alexandre Díaz +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import callModelMulti from '@odoo/osv/call_model_multi'; +import callModel from '@odoo/osv/call_model'; +import type Recordset from '@terminal/core/recordset'; + +const IGNORED_FIELDS = [ + 'id', + 'display_name', + 'create_uid', + 'create_date', + 'write_uid', + 'write_date', + '__last_update', +]; + +function getFieldIds(field_data: $ReadOnlyArray<$ReadOnlyArray | [number, string]>, field_info: {[string]: string | number}): $ReadOnlyArray { + const field_ids = []; + if (field_info.type === 'many2many') { + if (field_data.length) { + field_ids.push(...field_data); + } + } else if (field_data.length) { + field_ids.push(field_data[0]); + } + // $FlowFixMe + return field_ids; +} + +function hasUnsafeChar(value: string): boolean { + return /[<>&'"]/.test(value); +} + +async function getXMLIds(model: string, ids: $ReadOnlyArray, context: ?{[string]: mixed}): Promise<{[number]: string}> { + const metadatas = ( + await callModelMulti<$ReadOnlyArray>( + model, + ids, + 'get_metadata', + null, + null, + context, + ) + ); + return Object.fromEntries(metadatas.map((item) => [item.id, item.xmlid])); +} + +function createRecordField(model: string, field_name: string, value: mixed, field_info: {[string]: string | number}, field_xmlids: ?{[string]: $ReadOnlyArray}): string | void { + if (!(value instanceof Array) && typeof value === 'object') { + return; + } + let value_str = new String(value).toString(); + if (hasUnsafeChar(value_str)) { + value_str = ``; + } + if (field_info.type === 'boolean') { + return `\t\t\n`; + } else if ((field_info.type === 'one2many' || field_info.type === 'many2one' || field_info.type === 'many2many' || field_info.type === 'reference')) { + if (!(value instanceof Array)) { + return; + } + // $FlowFixMe + const xmlids: {[number]: string} = field_xmlids[field_name]; + if (!xmlids) { + return; + } + const field_ids = getFieldIds(value, field_info); + // $FlowFixMe + const xmlids_ids = Object.keys(xmlids).filter((item) => field_ids.includes(Number(item))).map((item) => xmlids[item]); + if (xmlids_ids.length === 1 && field_info.type !== 'many2many' && field_info.type !== 'one2many') { + return `\t\t\n`; + } else { + const refs = xmlids_ids.filter((item) => item).map((item) => `Command.link(ref('${item}'))`); + if (refs.length === 0) { + return; + } + return `\t\t\n`; + } + } + return `\t\t${value_str}\n`; +} + +export default async function(items: Recordset, context: ?{[string]: mixed}): Promise { + const model = items.model; + const main_xml_ids = await getXMLIds(model, items.ids, context); + const field_infos: {[string]: {[string]: string | number}} = await callModel( + model, + 'fields_get', + [false], + null, + context, + ); + + const field_infos_entries = Object.entries(field_infos); + const field_xmlids = field_infos_entries.filter(([_field_name, field_info]) => field_info.relation).map(([field_name, field_info]) => { + const field_ids: Array = []; + // $FlowFixMe + for (const item of items) { + const field_data = item[field_name]; + if (typeof field_data !== 'undefined') { + field_ids.push(...getFieldIds(field_data, field_info)); + } + } + if (field_ids.length) { + // $FlowFixMe + return getXMLIds(field_info.relation, field_ids, context).then((res_xml_ids) => [field_name, res_xml_ids]); + } + return Promise.resolve(); + }); + const xmlids_result = (await Promise.all(field_xmlids)).filter((item) => item); + // $FlowFixMe + const xmlids_result_obj = Object.fromEntries(xmlids_result); + + let res = '\n\n'; + // $FlowFixMe + for (const item of items) { + res += `\t\n`; + const fields = Object.entries(item); + for (const [field, value] of fields) { + const field_info = field_infos[field]; + if (!field_info.store || (field_info.type !== 'boolean' && value === false) || IGNORED_FIELDS.includes(field)) { + continue; + } + // $FlowFixMe + const record = createRecordField(model, field, value, field_info, xmlids_result_obj); + if (typeof record !== 'undefined') { + res += record; + } + } + res += '\t\n'; + } + res += '\n'; + return res; +} diff --git a/src/js/page/odoo/rpc.mjs b/src/js/page/odoo/rpc.mjs index 79d62e6..9091f10 100644 --- a/src/js/page/odoo/rpc.mjs +++ b/src/js/page/odoo/rpc.mjs @@ -17,8 +17,7 @@ export type BuildQueryOptions = { model: string, offset: number, orderBy: $ReadOnlyArray, - // $FlowFixMe - params: Object, + params: OdooQueryRPCParams, route: string, lazy: boolean, expand: Boolean, @@ -50,8 +49,7 @@ export type BuildQuery = { */ function buildQuery(options: Partial): BuildQuery { let route = ''; - // $FlowFixMe - const params: Object = options.params || {}; + const params: OdooQueryRPCParams = options.params || {}; let orderBy: $ReadOnlyArray = []; if (typeof options.route !== 'undefined') { route = options.route; diff --git a/src/js/page/odoo/terminal.mjs b/src/js/page/odoo/terminal.mjs index 914af1a..b452c13 100644 --- a/src/js/page/odoo/terminal.mjs +++ b/src/js/page/odoo/terminal.mjs @@ -80,9 +80,9 @@ export default class OdooTerminal extends Terminal { */ onCoreClick(ev: MouseEvent) { super.onCoreClick(ev); - if (ev.target instanceof HTMLElement && ev.target.classList.contains('o_terminal_read_bin_field')) { - // $FlowFixMe - this.#onTryReadBinaryField(ev.target); + const target = ev.target; + if (target instanceof HTMLElement && target.classList.contains('o_terminal_read_bin_field')) { + this.#onTryReadBinaryField(target); } } diff --git a/src/js/page/terminal/commands/__all__.mjs b/src/js/page/terminal/commands/__all__.mjs index a873702..b9d857c 100644 --- a/src/js/page/terminal/commands/__all__.mjs +++ b/src/js/page/terminal/commands/__all__.mjs @@ -6,7 +6,6 @@ import cmdChrono from './chrono'; import cmdClear from './clear'; import cmdContextTerm from './context_term'; import cmdDis from './dis'; -import cmdExportFile from './exportfile'; import cmdExportVar from './exportvar'; import cmdGenFile from './genfile'; import cmdHelp from './help'; @@ -26,7 +25,6 @@ export default function (vm: VMachine) { vm.registerCommand('context_term', cmdContextTerm()); vm.registerCommand('alias', cmdAlias()); vm.registerCommand('exportvar', cmdExportVar()); - vm.registerCommand('exportfile', cmdExportFile()); vm.registerCommand('chrono', cmdChrono()); vm.registerCommand('jobs', cmdJobs()); vm.registerCommand('toggle_term', cmdToggleTerm()); diff --git a/src/js/page/terminal/utils/csv.mjs b/src/js/page/terminal/utils/csv.mjs index e22a335..242f9d7 100644 --- a/src/js/page/terminal/utils/csv.mjs +++ b/src/js/page/terminal/utils/csv.mjs @@ -2,9 +2,12 @@ // Copyright Alexandre Díaz // License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -function sanitizeValue(value: string): string { +import type Recordset from '@terminal/core/recordset'; + + +function sanitizeValue(value: string, regex: RegExp): string { let res = value; - if (/[",\n]/.test(res)) { + if (regex.test(res)) { res = res.replace(/"/g, '""'); res = `"${res}"`; } @@ -12,17 +15,20 @@ function sanitizeValue(value: string): string { } // More Info: https://datatracker.ietf.org/doc/html/rfc4180 -export function stringify(items: $ReadOnlyArray<{...}>, use_header: boolean = false): string { +export default function(items: Recordset, use_header: boolean = false, delimiter: string = ','): string { + const san_regex = new RegExp(`["\n${delimiter}]`); let res = ''; if (use_header) { - const headers = Object.keys(items[0]).map((value) => sanitizeValue(new String(value).toString())); + // $FlowFixMe + const headers = Object.keys(items[0]).map((value) => sanitizeValue(new String(value).toString(), san_regex)); if (headers.length > 0) { - res += `${headers.join(',')}\n`; + res += `${headers.join(delimiter)}\n`; } } + // $FlowFixMe for (const item of items) { - const san_values = Object.values(item).map((value) => sanitizeValue(new String(value).toString())); - res += `${san_values.join(',')}\n`; + const san_values = Object.values(item).map((value) => sanitizeValue(new String(value).toString(), san_regex)); + res += `${san_values.join(delimiter)}\n`; } return res; }